Files
goauto/server/app/goauto/aimatching/normalizer.go
T

166 lines
5.5 KiB
Go

package aimatching
import (
"regexp"
"strconv"
"strings"
"sync"
"unicode"
"github.com/liuzl/gocc"
)
var (
traditionalOnce sync.Once
traditionalToSimplified *gocc.OpenCC
traditionalErr error
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
)
// Normalize makes a deterministic comparison key. It intentionally removes
// spacing and folds full-width, case and Traditional Chinese differences. For
// weight ranges it uses 斤 as the common unit (1 公斤 = 2 斤); it does not infer
// a size from a nearby value or perform any fuzzy match.
func Normalize(raw string) string {
traditionalOnce.Do(func() { traditionalToSimplified, traditionalErr = gocc.New("t2s") })
value := strings.TrimSpace(raw)
if traditionalErr == nil {
if converted, err := traditionalToSimplified.Convert(value); err == nil {
value = converted
}
}
value = widthAndSpaceFold(value)
value = strings.ToLower(value)
return weightPattern.ReplaceAllStringFunc(value, normalizeWeight)
}
func widthAndSpaceFold(raw string) string {
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch {
case unicode.IsSpace(r):
continue
case r == '\u3000':
continue
case r >= '\uff01' && r <= '\uff5e':
b.WriteRune(r - 0xfee0)
default:
b.WriteRune(r)
}
}
return b.String()
}
func normalizeWeight(raw string) string {
parts := weightPattern.FindStringSubmatch(raw)
if len(parts) != 4 {
return raw
}
convert := func(value string) string {
number, err := strconv.ParseFloat(value, 64)
if err != nil {
return value
}
unit := strings.ToLower(parts[3])
if unit == "公斤" || unit == "千克" || unit == "kg" {
number *= 2
}
return strconv.FormatFloat(number, 'f', -1, 64)
}
first := convert(parts[1])
if parts[2] == "" {
return first + "斤"
}
return first + "-" + convert(parts[2]) + "斤"
}
// DeterministicMatch returns a selection only when every requested dimension
// has exactly one normalized-equal PDD candidate. More than one candidate is
// deliberately treated as no match instead of picking the first label.
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
if !colorOK || !sizeOK {
return MatchResult{}, false
}
result := matchResult(request, SourceExact, "", "", color, size, "规格名称标准化后唯一一致")
return result, true
}
func exactSizeCandidate(target string, candidates []string) (string, bool) {
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
if key == "" {
return exactCandidate(target, candidates)
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) == 1 {
return matches[0], true
}
return "", false
}
// ManualMatch records an already-confirmed mapping without reinterpreting it.
// The caller owns the human-confirmation check; this helper only creates the
// same bounded task snapshot shape used by automatic decisions.
func ManualMatch(request MatchRequest, color, size string) MatchResult {
return RecordedMatch(request, "manual_mapping", color, size, "已使用人工确认的规格映射")
}
// RecordedMatch serializes a previously confirmed task mapping. It accepts
// only the established task sources and does not run normalization or AI.
func RecordedMatch(request MatchRequest, source, color, size, reason string) MatchResult {
if source != "manual_mapping" && source != SourceExact && source != SourceAI {
source = "manual_mapping"
}
return matchResult(request, source, "", "", strings.TrimSpace(color), strings.TrimSpace(size), reason)
}
// NoMatchDecision is used when the probe or provider cannot produce a valid
// exact PDD label. It preserves the observed candidates without exposing a
// provider URL, credential or raw UI data.
func NoMatchDecision(request MatchRequest, source, reason string) DecisionSnapshot {
return matchResult(request, source, "", "", "", "", reason).Decision
}
func exactCandidate(target string, candidates []string) (string, bool) {
if strings.TrimSpace(target) == "" {
return "", true
}
normalized := Normalize(target)
if normalized == "" {
return "", false
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || Normalize(candidate) != normalized || seen[candidate] {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
returnCandidate := ""
if len(matches) == 1 {
returnCandidate = matches[0]
}
return returnCandidate, len(matches) == 1
}
func matchResult(request MatchRequest, source, provider, model, color, size, reason string) MatchResult {
decision := DecisionSnapshot{Source: source, Provider: provider, Model: model, Candidates: CandidateSnapshot{Colors: append([]string(nil), request.Colors...), Sizes: append([]string(nil), request.Sizes...)}, Reason: reason}
decision.Matched.Color, decision.Matched.Size = color, size
return MatchResult{MappedColor: color, MappedSize: size, Source: source, Decision: decision}
}