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") + } +}