fix(server): 「一键匹配颜色和尺码」未拿到结论的规格值重问一次 (#299)

规格匹配有三条路径,#295 只覆盖了采购侧两条。虾皮商品详情页「一键匹配」走的是
shopeeproduct.suggestMappings,仍然一次失败即放弃。实测虾皮 1528 的「黑色」在
PDD 8514 里唯一对应(黑色-冰块猫,9 个在售组合),却被判为「AI 未给出可靠建议」。

这条路径是批量调用,不能照搬 #295 的逐值重试:

- 只把没拿到可靠结论的 source 组成第二次 SuggestBatch,已有结论的不重问。
- 第二次沿用同一份 candidates。
- SuggestBatch 整体报错时也重试一次;两条重试路径互斥,总调用严格不超过 2 次。

判据一律未动:候选集校验、candidateValues 的 defensive 检查、
AutoConfirmMinConfidence 门槛、preserved 与确定性匹配分支全部保持原样。

实施:grok-4.6(派单试点),改动经独立复核与重跑验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-16 15:46:08 +08:00
co-authored by Claude Opus 5
parent 08d77bd982
commit 9cdc9f325c
2 changed files with 302 additions and 4 deletions
+46 -4
View File
@@ -190,17 +190,48 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
return AISuggestResponse{}, aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
}
result, err := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
suggestReq := aimatching.SuggestRequest{
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: sources, Candidates: candidates,
})
}
result, err := aiService.SuggestBatch(ctx, suggestReq)
suggestCalls := 1
if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
result, err = aiService.SuggestBatch(ctx, suggestReq)
suggestCalls++
if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
}
}
decisions := result.Decisions
if decisions == nil {
decisions = map[string]aimatching.SuggestDecision{}
}
var retrySources []aimatching.SuggestSource
for _, source := range sources {
decision, ok := decisions[source.ID]
if unreliableSuggestDecision(decision, ok, candidateLabelByID, candidateValues) {
retrySources = append(retrySources, source)
}
}
if len(retrySources) > 0 && suggestCalls < 2 {
retryResult, retryErr := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: retrySources, Candidates: candidates,
})
if retryErr == nil {
for _, source := range retrySources {
if decision, ok := retryResult.Decisions[source.ID]; ok {
decisions[source.ID] = decision
}
}
}
}
for i, p := range placeholders {
sourceID := fmt.Sprintf("s%d", i+1)
decision, ok := result.Decisions[sourceID]
decision, ok := decisions[sourceID]
item := &response.Items[p.itemIndex]
if !ok || decision.CandidateID == "" {
item.Reason = "AI 未给出可靠建议,请人工选择"
@@ -252,6 +283,17 @@ func deterministicColorMatch(value string, candidates []string) string {
return match.MappedColor
}
// unreliableSuggestDecision is true when SuggestBatch gave no usable pick:
// missing decision, empty candidate, or a candidate that cannot be translated
// back onto the live PDD selectable set.
func unreliableSuggestDecision(decision aimatching.SuggestDecision, ok bool, candidateLabelByID map[string]string, candidateValues map[string]bool) bool {
if !ok || decision.CandidateID == "" {
return true
}
pddValue, ok := candidateLabelByID[decision.CandidateID]
return !ok || !candidateValues[pddValue]
}
// aiSuggestErrorMessage turns an aimatching error into a message safe to
// return to the admin UI (no credential, no raw provider body).
func aiSuggestErrorMessage(err error) string {
@@ -3,10 +3,13 @@ package shopeeproduct
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
@@ -25,6 +28,49 @@ func seedEnabledAISetting(t *testing.T, baseURL string, minConfidence float64) m
return models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: baseURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: minConfidence}
}
type capturedSuggestPrompt struct {
Sources []struct {
ID string `json:"id"`
Label string `json:"label"`
} `json:"sources"`
Candidates []struct {
ID string `json:"id"`
Label string `json:"label"`
} `json:"candidates"`
}
func parseSuggestPrompt(t *testing.T, body []byte) capturedSuggestPrompt {
t.Helper()
var payload struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode chat request: %v", err)
}
var user string
for _, message := range payload.Messages {
if message.Role == "user" {
user = message.Content
}
}
var prompt capturedSuggestPrompt
if err := json.Unmarshal([]byte(user), &prompt); err != nil {
t.Fatalf("decode suggest prompt: %v", err)
}
return prompt
}
func sourceIDsOf(prompt capturedSuggestPrompt) []string {
ids := make([]string, len(prompt.Sources))
for i, source := range prompt.Sources {
ids[i] = source.ID
}
return ids
}
func TestSuggestColorMappingsRejectsStaleContextVersion(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
@@ -158,3 +204,213 @@ func TestSuggestMappingsSkipConfirmedValuesStillValid(t *testing.T) {
t.Fatalf("expected preserved confirmed mapping without calling AI: %+v", preview.Items)
}
}
func TestSuggestMappingsRetriesOnlyUnreliableSources(t *testing.T) {
var mu sync.Mutex
var prompts []capturedSuggestPrompt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
http.Error(w, "read", http.StatusInternalServerError)
return
}
prompt := parseSuggestPrompt(t, body)
mu.Lock()
call := len(prompts)
prompts = append(prompts, prompt)
mu.Unlock()
content := `{"suggestions":[
{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"first call match"},
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"no pick"}
]}`
if call >= 1 {
content = `{"suggestions":[
{"sourceId":"s2","candidateId":"c1","confidence":0.96,"reason":"retry match"}
]}`
}
chatCompletionResponder(content)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-PARTIAL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
{Name: "浅黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(prompts) != 2 {
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
}
if got := sourceIDsOf(prompts[0]); len(got) != 2 || got[0] != "s1" || got[1] != "s2" {
t.Fatalf("first call sources: %v", got)
}
if got := sourceIDsOf(prompts[1]); len(got) != 1 || got[0] != "s2" {
t.Fatalf("second call must retry only the unreliable source, got %v", got)
}
if len(prompts[0].Candidates) == 0 {
t.Fatal("first call missing candidates")
}
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
if string(firstCandidates) != string(secondCandidates) {
t.Fatalf("retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
}
if len(preview.Items) != 2 {
t.Fatalf("expected 2 items, got %+v", preview.Items)
}
if preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" || preview.Items[0].Reason != "first call match" {
t.Fatalf("concluded source must keep first-call result: %+v", preview.Items[0])
}
if preview.Items[1].Status != "ai_matched" || preview.Items[1].PDDValue != "黑色" || preview.Items[1].Reason != "retry match" {
t.Fatalf("unreliable source must be filled from retry: %+v", preview.Items[1])
}
if preview.MatchedCount != 2 || preview.PendingCount != 0 {
t.Fatalf("unexpected counts: %+v", preview)
}
}
func TestSuggestMappingsRetriesSuggestBatchErrorOnce(t *testing.T) {
var mu sync.Mutex
var prompts []capturedSuggestPrompt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
http.Error(w, "read", http.StatusInternalServerError)
return
}
prompt := parseSuggestPrompt(t, body)
mu.Lock()
call := len(prompts)
prompts = append(prompts, prompt)
mu.Unlock()
if call == 0 {
http.Error(w, "provider down", http.StatusBadGateway)
return
}
chatCompletionResponder(`{"suggestions":[
{"sourceId":"s1","candidateId":"c1","confidence":0.97,"reason":"recovered"}
]}`)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-ERR", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("second SuggestBatch success should recover: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(prompts) != 2 {
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
}
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
if string(firstCandidates) != string(secondCandidates) {
t.Fatalf("error retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
}
if got, want := sourceIDsOf(prompts[0]), sourceIDsOf(prompts[1]); len(got) != len(want) || (len(got) > 0 && got[0] != want[0]) {
t.Fatalf("error retry must resend the same sources: first=%v second=%v", got, want)
}
if len(preview.Items) != 1 || preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" {
t.Fatalf("unexpected preview: %+v", preview.Items)
}
}
func TestSuggestMappingsDoesNotExceedTwoSuggestBatchCalls(t *testing.T) {
var mu sync.Mutex
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
mu.Unlock()
chatCompletionResponder(`{"suggestions":[
{"sourceId":"s1","candidateId":"","confidence":0,"reason":"none"},
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"none"}
]}`)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-CAP", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
{Name: "浅黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("SuggestBatch must be called at most twice, got %d", calls)
}
if preview.PendingCount != 2 || preview.MatchedCount != 0 {
t.Fatalf("still-unreliable after retry should stay pending: %+v", preview)
}
for _, item := range preview.Items {
if item.Reason != "AI 未给出可靠建议,请人工选择" || item.Status != "pending" {
t.Fatalf("unexpected pending item: %+v", item)
}
}
}
func TestUnreliableSuggestDecision(t *testing.T) {
labels := map[string]string{"c1": "黑色"}
values := map[string]bool{"黑色": true}
if !unreliableSuggestDecision(aimatching.SuggestDecision{}, false, labels, values) {
t.Fatal("missing decision must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: ""}, true, labels, values) {
t.Fatal("empty candidate must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c9"}, true, labels, values) {
t.Fatal("unknown candidate id must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, map[string]string{"c1": "红色"}, values) {
t.Fatal("label outside candidate set must be retried")
}
if unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, labels, values) {
t.Fatal("in-set candidate must not be retried")
}
}