481 lines
19 KiB
Go
481 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"cmautobuy/admin/model"
|
|
"cmautobuy/admin/repository"
|
|
"cmautobuy/admin/spec"
|
|
)
|
|
|
|
const (
|
|
AISpecMatchPromptVersion = "spec_prompt_v1"
|
|
maxAIModelCandidates = 24
|
|
)
|
|
|
|
type AIMatchSnapshot struct {
|
|
Provider model.AIProviderConfig
|
|
ConfigFingerprint string
|
|
Secret string
|
|
Client AIModelClient
|
|
}
|
|
|
|
type AISpecMatchResult struct {
|
|
Outcome string
|
|
Message string
|
|
Source string
|
|
OptionKey string
|
|
ConfidenceBPS int
|
|
ConfidenceSet bool
|
|
ContextVersion string
|
|
ModelCalled bool
|
|
}
|
|
|
|
type aiCandidateBinding struct {
|
|
ID string
|
|
Choice PddOptionChoice
|
|
}
|
|
|
|
// LoadActiveAIMatchSnapshot 固定新匹配动作使用的服务商配置;调用方可在整个批次复用它。
|
|
func LoadActiveAIMatchSnapshot(ctx context.Context, db *sql.DB, secrets AISecretStore, policy AIEndpointPolicy) (AIMatchSnapshot, error) {
|
|
provider, err := repository.GetEnabledAIProvider(db)
|
|
if err != nil {
|
|
return AIMatchSnapshot{}, err
|
|
}
|
|
if provider == nil {
|
|
return AIMatchSnapshot{}, fmt.Errorf("尚未启用 AI 服务商,请管理员先完成连接测试并启用")
|
|
}
|
|
fingerprint := aiProviderFingerprint(*provider)
|
|
if provider.LastTestStatus != "succeeded" || provider.LastTestFingerprint != fingerprint {
|
|
return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商配置未通过有效连接测试")
|
|
}
|
|
secret, err := secrets.Get(provider.ProviderID)
|
|
if err != nil {
|
|
return AIMatchSnapshot{}, err
|
|
}
|
|
if secret == "" {
|
|
return AIMatchSnapshot{}, fmt.Errorf("当前 AI 服务商未配置 API Key")
|
|
}
|
|
if err := policy.ValidateResolved(ctx, provider.BaseURL); err != nil {
|
|
return AIMatchSnapshot{}, err
|
|
}
|
|
client := NewOpenAICompatibleModelClient(NewSafeAIHTTPClient(policy, time.Duration(provider.TimeoutSeconds)*time.Second))
|
|
return AIMatchSnapshot{Provider: *provider, ConfigFingerprint: fingerprint, Secret: secret, Client: client}, nil
|
|
}
|
|
|
|
// MatchSybSpecWithAI 对一条 SYB 商品执行规则优先、AI 补充的候选匹配。
|
|
func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snapshot AIMatchSnapshot, sybID, expectedVersion string) (AISpecMatchResult, error) {
|
|
if actor == nil || !actor.IsActive() {
|
|
return AISpecMatchResult{}, ErrUnauthenticated
|
|
}
|
|
orderContext, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
|
if err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if orderContext == nil {
|
|
return AISpecMatchResult{}, fmt.Errorf("顺运宝明细不存在")
|
|
}
|
|
version := mappingContextVersion(*orderContext)
|
|
if expectedVersion != "" && expectedVersion != version {
|
|
return AISpecMatchResult{Outcome: "stale", Message: "数据已变化,请刷新后重试", ContextVersion: version}, nil
|
|
}
|
|
key, err := spec.SpecKey(orderContext.Order.ProductSpec)
|
|
if err != nil || orderContext.Order.SpecKey == "" || key != orderContext.Order.SpecKey {
|
|
return AISpecMatchResult{Outcome: "rejected", Message: "顺运宝未提供有效规格", ContextVersion: version}, nil
|
|
}
|
|
if orderContext.PddGoodsID == "" || orderContext.PddCollectStatus != string(model.CollectCollected) || strings.TrimSpace(orderContext.PddSkusJSON) == "" {
|
|
return AISpecMatchResult{Outcome: "rejected", Message: "当前 PDD 商品尚未完成采集", ContextVersion: version}, nil
|
|
}
|
|
if mappingIsValid(*orderContext) {
|
|
source := orderContext.MappingSource
|
|
if source == "" {
|
|
source = "manual"
|
|
}
|
|
return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source,
|
|
OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS,
|
|
ConfidenceSet: orderContext.MappingConfidenceSet, ContextVersion: version}, nil
|
|
}
|
|
choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON)
|
|
if err != nil {
|
|
return AISpecMatchResult{}, fmt.Errorf("读取 PDD 规格失败: %w", err)
|
|
}
|
|
baseDecision := newAISpecDecision(*orderContext, *actor, snapshot, version)
|
|
if len(choices) == 0 {
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "PDD 没有当前可购买规格")
|
|
}
|
|
match := rankSpecChoices(orderContext.Order.ProductSpec, choices, keys, names)
|
|
if match.PreselectOptionKey != "" {
|
|
choice := choiceByKey(match.Choices, match.PreselectOptionKey)
|
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices))
|
|
if utf8.RuneCountInString(choice.Key) > 191 {
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度")
|
|
}
|
|
baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true
|
|
baseDecision.Reason = choice.RecommendationReason
|
|
return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision)
|
|
}
|
|
resolved, resolveReason := resolveExtraDimensionSignals(orderContext.Order.ProductSpec, match.Choices, keys, names)
|
|
if resolveReason != "" {
|
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices))
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", resolveReason)
|
|
}
|
|
match = rankSpecChoices(orderContext.Order.ProductSpec, resolved, keys, names)
|
|
if match.PreselectOptionKey != "" {
|
|
choice := choiceByKey(match.Choices, match.PreselectOptionKey)
|
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(match.Choices))
|
|
if utf8.RuneCountInString(choice.Key) > 191 {
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "规则候选规格键超过可保存长度")
|
|
}
|
|
baseDecision.ChosenOptionKey, baseDecision.ConfidenceBPS, baseDecision.ConfidenceSet = choice.Key, 10000, true
|
|
baseDecision.Reason = choice.RecommendationReason
|
|
return saveAutomaticMapping(db, *actor, *orderContext, choice, "rule", baseDecision)
|
|
}
|
|
eligible := make([]PddOptionChoice, 0, len(match.Choices))
|
|
for _, choice := range match.Choices {
|
|
if choice.MatchLevel != "冲突" && utf8.RuneCountInString(choice.Key) <= 191 {
|
|
eligible = append(eligible, choice)
|
|
}
|
|
}
|
|
if len(eligible) == 0 {
|
|
baseDecision.CandidatesJSON = `[]`
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "全部候选都与顺运宝规格明确冲突")
|
|
}
|
|
if len(eligible) > maxAIModelCandidates {
|
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible))
|
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选过多,请先人工缩小范围")
|
|
}
|
|
bindings := bindAICandidates(eligible)
|
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindings)
|
|
if snapshot.Client == nil || snapshot.Secret == "" || snapshot.Provider.ProviderID == "" {
|
|
return recordAIMatchWithoutSave(db, baseDecision, "failed", "AI 服务商运行快照不可用")
|
|
}
|
|
request := AIModelMatchRequest{ProductTitle: truncateRunes(orderContext.Order.Title, 300), SourceSpec: truncateRunes(orderContext.Order.ProductSpec, 300)}
|
|
for _, binding := range bindings {
|
|
request.Candidates = append(request.Candidates, AIModelCandidate{ID: binding.ID,
|
|
Label: truncateRunes(binding.Choice.Label, 300), Options: boundedAIOptions(binding.Choice.Options)})
|
|
}
|
|
response, modelErr := snapshot.Client.Match(ctx, snapshot.Provider, snapshot.Secret, request)
|
|
if modelErr != nil {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", safeAIError(modelErr))
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
if err := validateAIModelMatchResponse(response); err != nil {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "failed", err.Error())
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
baseDecision.ChosenCandidateID = response.CandidateID
|
|
baseDecision.ConfidenceBPS = response.ConfidenceBPS
|
|
baseDecision.ConfidenceSet = true
|
|
baseDecision.Reason = response.Reason
|
|
baseDecision.ConflictDimensionsJSON = stringArrayJSON(response.ConflictDimensions)
|
|
baseDecision.MissingDimensionsJSON = stringArrayJSON(response.MissingDimensions)
|
|
if response.Conclusion != "match" {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", response.Reason)
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
selected, found := bindingByID(bindings, response.CandidateID)
|
|
if !found {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型返回了不在候选白名单中的编号")
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
baseDecision.ChosenOptionKey = selected.Choice.Key
|
|
if len(response.ConflictDimensions) > 0 || len(response.MissingDimensions) > 0 {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型报告仍有冲突或缺失维度")
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
if response.ConfidenceBPS < snapshot.Provider.ConfidenceThresholdBPS {
|
|
result, auditErr := recordAIMatchWithoutSave(db, baseDecision, "rejected", "模型置信度低于管理员设置的自动写入阈值")
|
|
result.ModelCalled = true
|
|
return result, auditErr
|
|
}
|
|
result, err := saveAutomaticMapping(db, *actor, *orderContext, selected.Choice, "ai", baseDecision)
|
|
result.ModelCalled = true
|
|
return result, err
|
|
}
|
|
|
|
func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybOrderContext, selected PddOptionChoice, source string, decision model.AISpecMatchDecision) (AISpecMatchResult, error) {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
current, err := repository.GetSybOrderContext(tx, original.Order.SybID)
|
|
if err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if current == nil || mappingContextVersion(*current) != decision.ContextVersion {
|
|
decision.Outcome, decision.Reason = "stale", "保存前数据或规则已变化"
|
|
if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
latestChoice, err := findPddChoice(current.PddSkusJSON, selected.Key)
|
|
if err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if latestChoice == nil {
|
|
decision.Outcome, decision.Reason = "stale", "所选 PDD 规格已不存在或不可购买"
|
|
if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
if source == "ai" {
|
|
choices, keys, names, parseErr := pddOptionChoices(current.PddSkusJSON)
|
|
if parseErr != nil {
|
|
return AISpecMatchResult{}, parseErr
|
|
}
|
|
match := rankSpecChoices(current.Order.ProductSpec, choices, keys, names)
|
|
resolved, reason := resolveExtraDimensionSignals(current.Order.ProductSpec, match.Choices, keys, names)
|
|
if reason != "" || !eligibleChoiceContains(current.Order.ProductSpec, resolved, keys, names, selected.Key) {
|
|
decision.Outcome, decision.Reason = "stale", "保存前候选硬校验不再通过"
|
|
if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: "stale", Message: decision.Reason, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
}
|
|
mappedAt := model.NowISO()
|
|
mapping := model.SpecMapping{ShopeeGoodsID: current.Order.ShopeeGoodsID, SpecKey: current.Order.SpecKey,
|
|
PddGoodsID: current.PddGoodsID, PddOptionKey: latestChoice.Key, PddOptions: latestChoice.OptionsJSON,
|
|
SpecRaw: current.Order.ProductSpec, MappedAt: mappedAt, MappedBy: actor.UserID, Source: source,
|
|
ConfidenceBPS: decision.ConfidenceBPS, ConfidenceSet: true, SourceReason: truncateRunes(decision.Reason, 500),
|
|
SourceVersion: SpecMatchRulesVersion, ContextVersion: decision.ContextVersion}
|
|
if source == "ai" {
|
|
mapping.SourceProviderID, mapping.SourceModel = decision.ProviderID, decision.Model
|
|
mapping.SourceVersion = decision.PromptVersion + "+" + decision.RulesVersion
|
|
}
|
|
if err := repository.UpsertAutomaticSpecMapping(tx, mapping); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
actual, err := repository.GetSpecMapping(tx, mapping.ShopeeGoodsID, mapping.SpecKey, mapping.PddGoodsID)
|
|
if err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if actual == nil {
|
|
return AISpecMatchResult{}, fmt.Errorf("自动规格映射保存后无法读取")
|
|
}
|
|
if actual.Source != source || actual.ContextVersion != mapping.ContextVersion || actual.PddOptionKey != mapping.PddOptionKey {
|
|
outcome := "reused"
|
|
actualSource := actual.Source
|
|
if actualSource == "manual" || actualSource == "" {
|
|
outcome, actualSource = "manual_exists", "manual"
|
|
}
|
|
decision.Outcome, decision.Reason, decision.ChosenOptionKey = outcome, "保存时发现已有映射,自动结果未覆盖", actual.PddOptionKey
|
|
if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource,
|
|
OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS,
|
|
ConfidenceSet: actual.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
decision.Outcome = source + "_saved"
|
|
decision.ChosenOptionKey = latestChoice.Key
|
|
decision.DecidedAt = mappedAt
|
|
if err := repository.InsertAISpecMatchDecision(tx, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source,
|
|
OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS,
|
|
ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
|
|
func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) {
|
|
decision.Outcome, decision.Reason = outcome, truncateRunes(reason, 500)
|
|
if decision.CandidatesJSON == "" {
|
|
decision.CandidatesJSON = `[]`
|
|
}
|
|
if decision.ConflictDimensionsJSON == "" {
|
|
decision.ConflictDimensionsJSON = `[]`
|
|
}
|
|
if decision.MissingDimensionsJSON == "" {
|
|
decision.MissingDimensionsJSON = `[]`
|
|
}
|
|
if err := repository.InsertAISpecMatchDecision(db, decision); err != nil {
|
|
return AISpecMatchResult{}, err
|
|
}
|
|
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS,
|
|
ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
|
}
|
|
|
|
func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision {
|
|
return model.AISpecMatchDecision{ShopeeGoodsID: c.Order.ShopeeGoodsID, SpecKey: c.Order.SpecKey,
|
|
PddGoodsID: c.PddGoodsID, ContextVersion: version, RulesVersion: SpecMatchRulesVersion,
|
|
PromptVersion: AISpecMatchPromptVersion, ProviderID: snapshot.Provider.ProviderID,
|
|
ProviderName: snapshot.Provider.Name, Model: snapshot.Provider.Model,
|
|
ConfigFingerprint: snapshot.ConfigFingerprint, CandidatesJSON: `[]`,
|
|
ConflictDimensionsJSON: `[]`, MissingDimensionsJSON: `[]`, DecidedBy: actor.UserID, DecidedAt: model.NowISO()}
|
|
}
|
|
|
|
func bindAICandidates(choices []PddOptionChoice) []aiCandidateBinding {
|
|
result := make([]aiCandidateBinding, 0, len(choices))
|
|
for i, choice := range choices {
|
|
result = append(result, aiCandidateBinding{ID: fmt.Sprintf("C%02d", i+1), Choice: choice})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func bindingByID(bindings []aiCandidateBinding, id string) (aiCandidateBinding, bool) {
|
|
for _, binding := range bindings {
|
|
if binding.ID == id {
|
|
return binding, true
|
|
}
|
|
}
|
|
return aiCandidateBinding{}, false
|
|
}
|
|
|
|
func candidateAuditJSON(bindings []aiCandidateBinding) string {
|
|
type item struct {
|
|
ID string `json:"id"`
|
|
OptionKey string `json:"option_key"`
|
|
}
|
|
values := make([]item, 0, len(bindings))
|
|
for _, binding := range bindings {
|
|
values = append(values, item{ID: binding.ID, OptionKey: binding.Choice.Key})
|
|
}
|
|
raw, _ := json.Marshal(values)
|
|
return string(raw)
|
|
}
|
|
|
|
func resolveExtraDimensionSignals(raw string, choices []PddOptionChoice, keys, names []string) ([]PddOptionChoice, string) {
|
|
colorKey, sizeKey, ambiguous, _ := identifyDimensions(choices, keys, names)
|
|
if ambiguous {
|
|
return nil, "颜色或尺码维度定义不明确,请人工核对"
|
|
}
|
|
filtered := append([]PddOptionChoice(nil), choices...)
|
|
normalizedRaw := normalizeDimensionSignal(raw)
|
|
for _, key := range keys {
|
|
if key == colorKey || key == sizeKey {
|
|
continue
|
|
}
|
|
values := map[string]bool{}
|
|
for _, choice := range filtered {
|
|
values[choice.Options[key]] = true
|
|
}
|
|
if len(values) <= 1 {
|
|
continue
|
|
}
|
|
var matched []string
|
|
for value := range values {
|
|
normalizedValue := normalizeDimensionSignal(value)
|
|
if normalizedValue != "" && strings.Contains(normalizedRaw, normalizedValue) {
|
|
matched = append(matched, value)
|
|
}
|
|
}
|
|
sort.Strings(matched)
|
|
if len(matched) != 1 {
|
|
return nil, "存在无法从顺运宝规格确定的额外规格维度,请人工核对"
|
|
}
|
|
selectedValue := matched[0]
|
|
next := filtered[:0]
|
|
for _, choice := range filtered {
|
|
if choice.Options[key] == selectedValue {
|
|
next = append(next, choice)
|
|
}
|
|
}
|
|
filtered = append([]PddOptionChoice(nil), next...)
|
|
}
|
|
return filtered, ""
|
|
}
|
|
|
|
func normalizeDimensionSignal(raw string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(simplifyExplicit(raw)), ""))
|
|
}
|
|
|
|
func boundedAIOptions(options map[string]string) map[string]string {
|
|
result := make(map[string]string, len(options))
|
|
for key, value := range options {
|
|
result[truncateRunes(key, 64)] = truncateRunes(value, 128)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func eligibleChoiceContains(raw string, choices []PddOptionChoice, keys, names []string, optionKey string) bool {
|
|
match := rankSpecChoices(raw, choices, keys, names)
|
|
for _, choice := range match.Choices {
|
|
if choice.Key == optionKey && choice.MatchLevel != "冲突" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func choiceByKey(choices []PddOptionChoice, key string) PddOptionChoice {
|
|
for _, choice := range choices {
|
|
if choice.Key == key {
|
|
return choice
|
|
}
|
|
}
|
|
return PddOptionChoice{}
|
|
}
|
|
|
|
func validateAIModelMatchResponse(response AIModelMatchResponse) error {
|
|
if response.Conclusion != "match" && response.Conclusion != "uncertain" && response.Conclusion != "conflict" {
|
|
return fmt.Errorf("AI 模型结论字段无效")
|
|
}
|
|
if response.ConfidenceBPS < 0 || response.ConfidenceBPS > 10000 {
|
|
return fmt.Errorf("AI 模型置信度超出范围")
|
|
}
|
|
if response.Conclusion == "match" && strings.TrimSpace(response.CandidateID) == "" {
|
|
return fmt.Errorf("AI 模型没有返回候选编号")
|
|
}
|
|
if strings.TrimSpace(response.Reason) == "" || utf8.RuneCountInString(response.Reason) > 500 {
|
|
return fmt.Errorf("AI 模型理由为空或过长")
|
|
}
|
|
for _, values := range [][]string{response.ConflictDimensions, response.MissingDimensions} {
|
|
if len(values) > 8 {
|
|
return fmt.Errorf("AI 模型返回的维度列表过长")
|
|
}
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == "" || utf8.RuneCountInString(value) > 64 {
|
|
return fmt.Errorf("AI 模型返回的维度名称无效")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringArrayJSON(values []string) string {
|
|
if values == nil {
|
|
values = []string{}
|
|
}
|
|
raw, _ := json.Marshal(values)
|
|
return string(raw)
|
|
}
|
|
|
|
func truncateRunes(value string, max int) string {
|
|
runes := []rune(strings.TrimSpace(value))
|
|
if len(runes) > max {
|
|
runes = runes[:max]
|
|
}
|
|
return string(runes)
|
|
}
|