166 lines
5.5 KiB
Go
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}
|
|
}
|