feat: 放大 AI 规格候选上限 (#284)
This commit is contained in:
@@ -17,7 +17,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
AISpecMatchPromptVersion = "spec_prompt_v1"
|
AISpecMatchPromptVersion = "spec_prompt_v1"
|
||||||
maxAIModelCandidates = 24
|
// 单次模型调用最多携带 100 个已过滤的可购买候选。超过这个数量时,
|
||||||
|
// 候选本身仍会写入审计记录,但不会让模型在过于宽泛的范围里猜测。
|
||||||
|
maxAIModelCandidates = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
type AIMatchSnapshot struct {
|
type AIMatchSnapshot struct {
|
||||||
@@ -149,7 +151,7 @@ func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snap
|
|||||||
}
|
}
|
||||||
if len(eligible) > maxAIModelCandidates {
|
if len(eligible) > maxAIModelCandidates {
|
||||||
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible))
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindAICandidates(eligible))
|
||||||
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选过多,请先人工缩小范围")
|
return recordAIMatchWithoutSave(db, baseDecision, "rejected", "可购买候选超过 100 条,请先人工核对")
|
||||||
}
|
}
|
||||||
bindings := bindAICandidates(eligible)
|
bindings := bindAICandidates(eligible)
|
||||||
baseDecision.CandidatesJSON = candidateAuditJSON(bindings)
|
baseDecision.CandidatesJSON = candidateAuditJSON(bindings)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
@@ -71,6 +73,27 @@ func TestResolveExtraDimensionSignals_额外维度必须有确定信号(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMatchSybSpecWithAI_候选白名单低置信度和人工优先(t *testing.T) {
|
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) {
|
t.Run("合格AI结果直接保存", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
actor := seedAIMatchContext(t, db, "SYB-AI-SAVE")
|
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 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}]}`
|
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 {
|
func seedAIMatchContext(t *testing.T, db *sql.DB, sybID string) model.User {
|
||||||
return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices)
|
return seedAIMatchContextWithData(t, db, sybID, "黑色,M", collectedAIChoices)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user