fix(server): 规格匹配的 AI 调用重试一次,并区分无匹配与服务不可用 (#295)

AI 对同一输入会给出不同答案(deepseek-v4-flash 在 Temperature=0 下仍如此:
紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答「唯一匹配」),而匹配一个值
只调一次、失败即放弃,于是本该匹上的值因一次抽风永久留空。

- resolveSpecMatch 重试一次,上限 2 次调用。真失败(PDD 没有该颜色、白色有歧义)
  每次都会失败,重试更多只是浪费调用——实测连续三轮失败数稳定在 12。
- 区分 AI 明确「无匹配」与服务不可用,两者处置不同。此前都记成 failed,排查时
  分不开(#294 的遗留问题)。
- BatchSpecMatch 与 MatchArchiveSpecs 两条路径都接上。

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 14:59:16 +08:00
co-authored by Claude Opus 5
parent b2b8c0d1e0
commit 08d77bd982
6 changed files with 171 additions and 26 deletions
@@ -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
+7 -7
View File
@@ -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),
}
@@ -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++
@@ -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
}
@@ -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)
}
}
+2 -2
View File
@@ -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