diff --git a/admin/service/ai_specmatch.go b/admin/service/ai_specmatch.go index d708b50..c6236ab 100644 --- a/admin/service/ai_specmatch.go +++ b/admin/service/ai_specmatch.go @@ -17,7 +17,9 @@ import ( const ( AISpecMatchPromptVersion = "spec_prompt_v1" - maxAIModelCandidates = 24 + // 单次模型调用最多携带 100 个已过滤的可购买候选。超过这个数量时, + // 候选本身仍会写入审计记录,但不会让模型在过于宽泛的范围里猜测。 + maxAIModelCandidates = 100 ) type AIMatchSnapshot struct { @@ -149,7 +151,7 @@ func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snap } if len(eligible) > maxAIModelCandidates { baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible)) - return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选过多,请先人工缩小范围") + return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选超过 100 条,请先人工核对") } bindings := bindAICandidates(eligible) baseDecision.CandidatesJSON = candidateAuditJSON(bindings) diff --git a/admin/service/ai_specmatch_test.go b/admin/service/ai_specmatch_test.go index c49570b..17cddec 100644 --- a/admin/service/ai_specmatch_test.go +++ b/admin/service/ai_specmatch_test.go @@ -3,7 +3,9 @@ package service import ( "context" "database/sql" + "encoding/json" "errors" + "fmt" "testing" "cmautobuy/admin/model" @@ -71,6 +73,27 @@ func TestResolveExtraDimensionSignals_额外维度必须有确定信号(t *testi } func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testing.T) { + t.Run("100个合格候选允许调用模型", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContextWithData(t, db, "SYB-AI-100", "黑色,M", collectedAIChoicesWithCount(t, 100)) + fake := &fakeAIModelClient{response: AIModelMatchResponse{Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "候选之一规格一致"}} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-100", "") + if err != nil || result.Outcome != "ai_saved" || fake.calls != 1 || len(fake.last.Candidates) != 100 { + t.Fatalf("100 条候选应调用模型: result=%+v calls=%d candidate_count=%d err=%v", result, fake.calls, len(fake.last.Candidates), err) + } + }) + + t.Run("101个合格候选不调用模型也不保存映射", func(t *testing.T) { + db := newTestDB(t) + actor := seedAIMatchContextWithData(t, db, "SYB-AI-101", "黑色,M", collectedAIChoicesWithCount(t, 101)) + fake := &fakeAIModelClient{err: errors.New("候选超过上限时不应调用模型")} + result, err := MatchSybSpecWithAI(context.Background(), db, &actor, testAIMatchSnapshot(fake), "SYB-AI-101", "") + if err != nil || result.Outcome != "rejected" || result.Message != "可购买候选超过 100 条,请先人工核对" || fake.calls != 0 { + t.Fatalf("101 条候选应在模型调用前拒绝: result=%+v calls=%d err=%v", result, fake.calls, err) + } + assertNoAIMapping(t, db) + }) + t.Run("合格AI结果直接保存", func(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-AI-SAVE") @@ -165,6 +188,37 @@ func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testin const collectedAIChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"黑色","size":"M"},"price_cent":1180,"available":true},{"options":{"color":"白色","size":"L"},"price_cent":1280,"available":true}]}` const collectedRuleChoices = `{"goods_id":"737116531267","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(106-114斤)"},"price_cent":1180,"available":true},{"options":{"color":"黑色","size":"L(106-114斤)"},"price_cent":1280,"available":true}]}` +func collectedAIChoicesWithCount(t *testing.T, count int) string { + t.Helper() + type collectedSKU struct { + Options map[string]string `json:"options"` + PriceCent int64 `json:"price_cent"` + Available bool `json:"available"` + } + payload := struct { + GoodsID string `json:"goods_id"` + Dimensions []map[string]string `json:"dimensions"` + SKUs []collectedSKU `json:"skus"` + }{ + GoodsID: "737116531267", + Dimensions: []map[string]string{ + {"key": "color", "name": "颜色"}, + {"key": "size", "name": "尺码"}, + }, + SKUs: make([]collectedSKU, 0, count), + } + for index := 1; index <= count; index++ { + payload.SKUs = append(payload.SKUs, collectedSKU{ + Options: map[string]string{"color": fmt.Sprintf("黑色款%03d", index), "size": "M"}, PriceCent: 1180, Available: true, + }) + } + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + func seedAIMatchContext(t *testing.T, db *sql.DB, sybID string) model.User { return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices) }