From 9cdc9f325cbc23cfaf88671d586de07fedacf76f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 16 Sep 2026 15:46:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E3=80=8C=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E9=A2=9C=E8=89=B2=E5=92=8C=E5=B0=BA=E7=A0=81?= =?UTF-8?q?=E3=80=8D=E6=9C=AA=E6=8B=BF=E5=88=B0=E7=BB=93=E8=AE=BA=E7=9A=84?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E5=80=BC=E9=87=8D=E9=97=AE=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规格匹配有三条路径,#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 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/shopeeproduct/ai_suggest.go | 50 +++- .../goauto/shopeeproduct/ai_suggest_test.go | 256 ++++++++++++++++++ 2 files changed, 302 insertions(+), 4 deletions(-) diff --git a/server/app/goauto/shopeeproduct/ai_suggest.go b/server/app/goauto/shopeeproduct/ai_suggest.go index 9f999fe..7b2646f 100644 --- a/server/app/goauto/shopeeproduct/ai_suggest.go +++ b/server/app/goauto/shopeeproduct/ai_suggest.go @@ -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 { diff --git a/server/app/goauto/shopeeproduct/ai_suggest_test.go b/server/app/goauto/shopeeproduct/ai_suggest_test.go index 3e7ec29..3cb0a14 100644 --- a/server/app/goauto/shopeeproduct/ai_suggest_test.go +++ b/server/app/goauto/shopeeproduct/ai_suggest_test.go @@ -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") + } +}