From 08d77bd9827477b4b2c156b77c04d7ac37d5f988 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 16 Sep 2026 14:59:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=A7=84=E6=A0=BC=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E7=9A=84=20AI=20=E8=B0=83=E7=94=A8=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=B9=B6=E5=8C=BA=E5=88=86=E6=97=A0?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E4=B8=8E=E6=9C=8D=E5=8A=A1=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 对同一输入会给出不同答案(deepseek-v4-flash 在 Temperature=0 下仍如此: 紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答「唯一匹配」),而匹配一个值 只调一次、失败即放弃,于是本该匹上的值因一次抽风永久留空。 - resolveSpecMatch 重试一次,上限 2 次调用。真失败(PDD 没有该颜色、白色有歧义) 每次都会失败,重试更多只是浪费调用——实测连续三轮失败数稳定在 12。 - 区分 AI 明确「无匹配」与服务不可用,两者处置不同。此前都记成 failed,排查时 分不开(#294 的遗留问题)。 - BatchSpecMatch 与 MatchArchiveSpecs 两条路径都接上。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../app/goauto/purchase/archive_spec_match.go | 45 +++++++---- server/app/goauto/purchase/batch.go | 14 ++-- .../app/goauto/purchase/batch_spec_match.go | 3 +- .../app/goauto/purchase/spec_match_retry.go | 57 ++++++++++++++ .../goauto/purchase/spec_match_retry_test.go | 74 +++++++++++++++++++ server/app/goauto/task/image_search_link.go | 4 +- 6 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 server/app/goauto/purchase/spec_match_retry.go create mode 100644 server/app/goauto/purchase/spec_match_retry_test.go diff --git a/server/app/goauto/purchase/archive_spec_match.go b/server/app/goauto/purchase/archive_spec_match.go index 32f1a7c..77ef092 100644 --- a/server/app/goauto/purchase/archive_spec_match.go +++ b/server/app/goauto/purchase/archive_spec_match.go @@ -12,12 +12,20 @@ import ( ) // ArchiveSpecMatchResult counts what one archive-wide matching pass did. +// +// NoMatch 与 Unavailable 分开计数:前者是 AI 明确说没有可靠对应(PDD 确实没有该 +// 颜色,或候选有歧义),后者是服务不可用。两者的处置完全不同,此前都记成 failed, +// 排查时分不开(#295)。 type ArchiveSpecMatchResult struct { - Confirmed int - Skipped int - Failed int + Confirmed int + Skipped int + NoMatch int + Unavailable int } +// Failed is every value that ended without a mapping, whatever the reason. +func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable } + // MatchArchiveSpecs matches every still-unmapped value in a Shopee product's // archive against its linked PDD product (#293). // @@ -65,18 +73,21 @@ func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) result.Skipped++ continue } - if !s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) { - result.Failed++ - continue + switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) { + case specMatchUnavailable: + result.Unavailable++ + case specMatchNoMatch: + result.NoMatch++ + default: + result.Confirmed++ } - result.Confirmed++ } } return result, nil } // matchOneArchiveValue resolves a single archive value and persists it when the -// answer survives every check. It reports whether a mapping was written. +// answer survives every check. An empty return means a mapping was written. func (s *Service) matchOneArchiveValue( ctx context.Context, shopeeProductID uint64, @@ -84,7 +95,7 @@ func (s *Service) matchOneArchiveValue( valueName string, candidates specCandidates, combinations []pddSKUCombination, -) bool { +) specMatchFailure { isColor := dimension.Role == shopeeproduct.RoleColor request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes} if isColor { @@ -94,14 +105,14 @@ func (s *Service) matchOneArchiveValue( } matched, ok := aimatching.DeterministicMatch(request) if !ok { - resolved, err := s.matcher().Resolve(ctx, request) + resolved, failure, err := s.resolveSpecMatch(ctx, request) if err != nil { - return false + return failure } // #200 的口径:AI 只要给出带理由的结果就可保存,是否放行交给下面的候选与 // 可售组合校验;置信度只作审计信息。 if resolved.Source != aimatching.SourceAI || strings.TrimSpace(resolved.Decision.Reason) == "" { - return false + return specMatchNoMatch } matched = resolved } @@ -113,13 +124,13 @@ func (s *Service) matchOneArchiveValue( } pddValue = strings.TrimSpace(pddValue) if pddValue == "" || !containsValue(allowed, pddValue) { - return false + return specMatchNoMatch } // `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合。至少要求这个值真的 // 出现在某个在售 SKU 里,否则会把一个“看起来合理但根本买不到”的值写成确认映射。 // 完整组合仍由采购预检把关。 if !archiveValueIsSellable(combinations, isColor, pddValue) { - return false + return specMatchNoMatch } item := shopeeproduct.ResolvedMappingItem{ @@ -127,8 +138,10 @@ func (s *Service) matchOneArchiveValue( Source: matched.Source, Status: shopeeproduct.MappingStatusConfirmed, Confidence: matched.Decision.Confidence, Reason: strings.TrimSpace(matched.Decision.Reason), } - _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item}) - return err == nil + if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item}); err != nil { + return specMatchUnavailable + } + return "" } // archiveValueIsSellable reports whether the PDD value appears in at least one diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 19d1ae2..0ff0243 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -150,13 +150,13 @@ type batchPreviewDataset struct { // Creation still revalidates each item. func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) { dataset := batchPreviewDataset{ - sybByID: make(map[uint64]models.SYBProduct, len(ids)), - shopeeByID: make(map[uint64]models.ShopeeProduct), - pddByID: make(map[uint64]models.PDDProduct), - latestTaskBySYB: make(map[uint64]models.PurchaseTask), - latestCollectionByPDD: make(map[uint64]models.CollectionTask), - activeCollectionByPDD: make(map[uint64]uint64), - skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), + sybByID: make(map[uint64]models.SYBProduct, len(ids)), + shopeeByID: make(map[uint64]models.ShopeeProduct), + pddByID: make(map[uint64]models.PDDProduct), + latestTaskBySYB: make(map[uint64]models.PurchaseTask), + latestCollectionByPDD: make(map[uint64]models.CollectionTask), + activeCollectionByPDD: make(map[uint64]uint64), + skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), collapsedColorByShopee: make(map[uint64]map[string]bool), collapsedSizeByShopee: make(map[uint64]map[string]bool), } diff --git a/server/app/goauto/purchase/batch_spec_match.go b/server/app/goauto/purchase/batch_spec_match.go index 737a876..4a1833e 100644 --- a/server/app/goauto/purchase/batch_spec_match.go +++ b/server/app/goauto/purchase/batch_spec_match.go @@ -73,7 +73,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) matched = *qualification.Deterministic } else { var matchErr error - matched, matchErr = s.matcher().Resolve(ctx, request) + // 一次抽风不该让这条明细永久留空,重试一次(#295)。 + matched, _, matchErr = s.resolveSpecMatch(ctx, request) if matchErr != nil { item.Reason = batchSpecMatchErrorMessage(matchErr) response.FailedCount++ diff --git a/server/app/goauto/purchase/spec_match_retry.go b/server/app/goauto/purchase/spec_match_retry.go new file mode 100644 index 0000000..185ec30 --- /dev/null +++ b/server/app/goauto/purchase/spec_match_retry.go @@ -0,0 +1,57 @@ +package purchase + +import ( + "context" + "errors" + + "go-admin/app/goauto/aimatching" +) + +// specMatchFailure says why one AI matching attempt did not produce a mapping. +type specMatchFailure string + +const ( + // specMatchNoMatch: AI 明确回答「没有可靠对应」。同样的输入下次多半还是这个答案, + // 但实测并非一定——见 retryableSpecMatch 的说明。 + specMatchNoMatch specMatchFailure = "no_match" + // specMatchUnavailable: 服务不可用、超时、配置缺失等,与规格内容无关。 + specMatchUnavailable specMatchFailure = "unavailable" +) + +// specMatchAttempts is the total number of AI calls allowed for one value. +// +// `[必须]` 只重试一次,不要更多。AI 对同一输入会给出不同答案(实测 deepseek-v4-flash +// 在 Temperature=0 下仍然如此:紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答 +// 「唯一匹配」),所以一次重试很值。但真失败——PDD 确实没有该颜色、或 `白色-冰块猫` +// 与 `白色-空白版` 有歧义——每次都会失败,重试只是浪费调用和采购员的时间。实测连续 +// 三轮全量匹配,失败数稳定在 12,正是这两类(#295)。 +const specMatchAttempts = 2 + +// resolveSpecMatch calls the matcher, retrying once so a single flaky answer +// does not leave a value permanently unmapped. +// +// 返回的 failure 只在 err != nil 时有意义,供调用方区分「AI 说没有」和「AI 没答上来」—— +// 此前两者都记成 failed,排查时分不开(#294 的遗留问题,本次一并修)。 +func (s *Service) resolveSpecMatch(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, specMatchFailure, error) { + var lastErr error + var lastFailure specMatchFailure + for attempt := 0; attempt < specMatchAttempts; attempt++ { + matched, err := s.matcher().Resolve(ctx, request) + if err == nil { + return matched, "", nil + } + lastErr, lastFailure = err, classifySpecMatchFailure(err) + if ctx.Err() != nil { + break + } + } + return aimatching.MatchResult{}, lastFailure, lastErr +} + +func classifySpecMatchFailure(err error) specMatchFailure { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeNoMatch { + return specMatchNoMatch + } + return specMatchUnavailable +} diff --git a/server/app/goauto/purchase/spec_match_retry_test.go b/server/app/goauto/purchase/spec_match_retry_test.go new file mode 100644 index 0000000..13e6704 --- /dev/null +++ b/server/app/goauto/purchase/spec_match_retry_test.go @@ -0,0 +1,74 @@ +package purchase + +import ( + "context" + "errors" + "testing" + + "go-admin/app/goauto/aimatching" +) + +type scriptedMatcher struct { + calls int + answers []error +} + +func (m *scriptedMatcher) Resolve(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) { + index := m.calls + m.calls++ + if index < len(m.answers) && m.answers[index] != nil { + return aimatching.MatchResult{}, m.answers[index] + } + return aimatching.MatchResult{Source: aimatching.SourceAI, MappedColor: "紫色-冰块猫"}, nil +} + +func noMatch() error { + return &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "没有找到可采购的 PDD 颜色或尺码"} +} + +// `[必须]` 实测同一输入第一次答「未找到」、第二次答「唯一匹配」(#295)。一次抽风 +// 不该让这个值永久留空。 +func TestFlakyFirstAnswerIsRetriedOnce(t *testing.T) { + matcher := &scriptedMatcher{answers: []error{noMatch()}} + service := &Service{Matcher: matcher} + matched, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"}) + if err != nil { + t.Fatalf("重试后应当成功,实际 %v", err) + } + if failure != "" || matched.MappedColor != "紫色-冰块猫" { + t.Fatalf("failure=%q mapped=%q", failure, matched.MappedColor) + } + if matcher.calls != 2 { + t.Fatalf("调用了 %d 次,应为 2", matcher.calls) + } +} + +// `[必须]` 真失败每次都会失败——PDD 确实没有该颜色,或候选有歧义。重试次数必须有 +// 上限,否则只是浪费调用和采购员的时间。 +func TestARealNoMatchIsNotRetriedForever(t *testing.T) { + matcher := &scriptedMatcher{answers: []error{noMatch(), noMatch(), noMatch()}} + service := &Service{Matcher: matcher} + _, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "墨綠色"}) + if err == nil { + t.Fatal("应当失败") + } + if failure != specMatchNoMatch { + t.Fatalf("failure=%q,应为 no_match", failure) + } + if matcher.calls != specMatchAttempts { + t.Fatalf("调用了 %d 次,应为上限 %d", matcher.calls, specMatchAttempts) + } +} + +// 服务不可用与「AI 说没有」的处置完全不同,必须分得开(#294 的遗留问题)。 +func TestTransportFailureIsNotReportedAsNoMatch(t *testing.T) { + matcher := &scriptedMatcher{answers: []error{errors.New("dial tcp: connection refused"), errors.New("dial tcp: connection refused")}} + service := &Service{Matcher: matcher} + _, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"}) + if err == nil { + t.Fatal("应当失败") + } + if failure != specMatchUnavailable { + t.Fatalf("failure=%q,应为 unavailable", failure) + } +} diff --git a/server/app/goauto/task/image_search_link.go b/server/app/goauto/task/image_search_link.go index e2448af..c779956 100644 --- a/server/app/goauto/task/image_search_link.go +++ b/server/app/goauto/task/image_search_link.go @@ -135,8 +135,8 @@ func (service *Service) matchSpecsAfterImageSearch(ctx context.Context, linked a log.Printf("archive spec match failed for shopee product %d: %v", linked.ShopeeProductID, err) return } - log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d failed=%d", - linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.Failed) + log.Printf("archive spec match for shopee product %d: confirmed=%d skipped=%d no_match=%d unavailable=%d", + linked.ShopeeProductID, archive.Confirmed, archive.Skipped, archive.NoMatch, archive.Unavailable) } // batchSpecMatchDigest renders the first few non-successful reasons so a total