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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user