feat: 增加规格匹配建议与决策审计 (#90)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SpecMatchRulesVersion = "rules_v1"
|
||||
maxCandidateWidthRatio = 5.0
|
||||
minCandidateWidthAllowanceJin = 40.0
|
||||
maxMidpointDistanceJin = 20.0
|
||||
)
|
||||
|
||||
type matchLevel int
|
||||
|
||||
const (
|
||||
matchConflict matchLevel = iota
|
||||
matchC
|
||||
matchB
|
||||
matchA
|
||||
)
|
||||
|
||||
type specFeatures struct {
|
||||
size string
|
||||
weightFrom float64
|
||||
weightTo float64
|
||||
hasWeight bool
|
||||
colors map[string]bool
|
||||
}
|
||||
|
||||
type SpecMatchResult struct {
|
||||
Choices []PddOptionChoice
|
||||
SuggestedOptionKey string
|
||||
PreselectOptionKey string
|
||||
Notice string
|
||||
}
|
||||
|
||||
var traditionalPairs = []struct{ from, to string }{
|
||||
{"藍", "蓝"}, {"個", "个"}, {"規", "规"}, {"長", "长"}, {"碼", "码"},
|
||||
{"綠", "绿"}, {"紅", "红"}, {"淺", "浅"}, {"裝", "装"}, {"條", "条"},
|
||||
}
|
||||
|
||||
var weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*[-~~至到]\s*(\d+(?:\.\d+)?)\s*(公斤|kg|斤)`)
|
||||
var sizePattern = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])((?:[2-9]xl)|(?:x{2,9}l)|xl|xs|s|m|l|均码|f)(?:码)?(?:$|[^a-z])`)
|
||||
|
||||
var colorAliases = []struct {
|
||||
word string
|
||||
set []string
|
||||
}{
|
||||
{"粉红", []string{"粉"}}, {"浅粉", []string{"粉"}}, {"深粉", []string{"粉"}}, {"粉色", []string{"粉"}},
|
||||
{"米白", []string{"米", "白"}}, {"藏青", []string{"藏青"}}, {"藏蓝", []string{"藏青"}},
|
||||
{"卡其", []string{"卡其"}}, {"咖啡", []string{"咖啡"}},
|
||||
{"黑", []string{"黑"}}, {"白", []string{"白"}}, {"灰", []string{"灰"}}, {"红", []string{"红"}},
|
||||
{"蓝", []string{"蓝"}}, {"绿", []string{"绿"}}, {"黄", []string{"黄"}}, {"紫", []string{"紫"}},
|
||||
{"粉", []string{"粉"}}, {"米", []string{"米"}}, {"青", []string{"青"}},
|
||||
}
|
||||
|
||||
func simplifyExplicit(raw string) string {
|
||||
for _, pair := range traditionalPairs {
|
||||
raw = strings.ReplaceAll(raw, pair.from, pair.to)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func extractSpecFeatures(raw string) specFeatures {
|
||||
normalized := simplifyExplicit(raw)
|
||||
f := specFeatures{colors: map[string]bool{}}
|
||||
if match := weightPattern.FindStringSubmatch(normalized); len(match) > 0 {
|
||||
f.weightFrom, _ = strconv.ParseFloat(match[1], 64)
|
||||
f.weightTo, _ = strconv.ParseFloat(match[2], 64)
|
||||
if strings.EqualFold(match[3], "公斤") || strings.EqualFold(match[3], "kg") {
|
||||
f.weightFrom *= 2
|
||||
f.weightTo *= 2
|
||||
}
|
||||
if f.weightFrom > f.weightTo {
|
||||
f.weightFrom, f.weightTo = f.weightTo, f.weightFrom
|
||||
}
|
||||
f.hasWeight = true
|
||||
}
|
||||
if match := sizePattern.FindStringSubmatch(strings.ToLower(normalized)); len(match) > 0 {
|
||||
f.size = normalizeSize(match[1])
|
||||
}
|
||||
colorText := normalized
|
||||
for _, alias := range colorAliases {
|
||||
if strings.Contains(colorText, alias.word) {
|
||||
for _, value := range alias.set {
|
||||
f.colors[value] = true
|
||||
}
|
||||
colorText = strings.ReplaceAll(colorText, alias.word, strings.Repeat(" ", len([]rune(alias.word))))
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func normalizeSize(raw string) string {
|
||||
if raw == "均码" {
|
||||
return raw
|
||||
}
|
||||
raw = strings.ToUpper(strings.TrimSuffix(raw, "码"))
|
||||
if strings.HasSuffix(raw, "L") && strings.TrimSuffix(raw, "L") != "" {
|
||||
prefix := strings.TrimSuffix(raw, "L")
|
||||
if strings.Trim(prefix, "X") == "" && len(prefix) >= 2 {
|
||||
return fmt.Sprintf("%dXL", len(prefix))
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
type rankedChoice struct {
|
||||
choice PddOptionChoice
|
||||
level matchLevel
|
||||
coverage, iou, midpoint float64
|
||||
index int
|
||||
}
|
||||
|
||||
func rankSpecChoices(raw string, choices []PddOptionChoice, keys, names []string) SpecMatchResult {
|
||||
source := extractSpecFeatures(raw)
|
||||
colorKey, sizeKey, ambiguous, extra := identifyDimensions(choices, keys, names)
|
||||
ranked := make([]rankedChoice, 0, len(choices))
|
||||
for i, choice := range choices {
|
||||
text := choice.Label
|
||||
if colorKey != "" || sizeKey != "" {
|
||||
text = choice.Options[colorKey] + " " + choice.Options[sizeKey]
|
||||
}
|
||||
candidate := extractSpecFeatures(text)
|
||||
level, coverage, iou, midpoint, reason := classifySpec(source, candidate)
|
||||
choice.RecommendationReason = reason
|
||||
choice.MatchLevel = []string{"冲突", "C", "B", "A"}[level]
|
||||
ranked = append(ranked, rankedChoice{choice, level, coverage, iou, midpoint, i})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].level != ranked[j].level {
|
||||
return ranked[i].level > ranked[j].level
|
||||
}
|
||||
if ranked[i].coverage != ranked[j].coverage {
|
||||
return ranked[i].coverage > ranked[j].coverage
|
||||
}
|
||||
if ranked[i].iou != ranked[j].iou {
|
||||
return ranked[i].iou > ranked[j].iou
|
||||
}
|
||||
return ranked[i].midpoint < ranked[j].midpoint
|
||||
})
|
||||
result := SpecMatchResult{Choices: make([]PddOptionChoice, len(ranked))}
|
||||
aCount := 0
|
||||
for i := range ranked {
|
||||
result.Choices[i] = ranked[i].choice
|
||||
if ranked[i].level == matchA {
|
||||
aCount++
|
||||
}
|
||||
}
|
||||
if aCount == 1 {
|
||||
result.SuggestedOptionKey = result.Choices[0].Key
|
||||
result.Choices[0].Recommended = true
|
||||
if !ambiguous && !extra {
|
||||
result.PreselectOptionKey = result.Choices[0].Key
|
||||
}
|
||||
}
|
||||
if aCount > 1 {
|
||||
result.Notice = "有多个 A 级候选,已稳定置顶但不会预选。"
|
||||
}
|
||||
if ambiguous || extra {
|
||||
result.Notice = "存在无法判断的额外规格维度,请人工核对;系统不会预选。"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func identifyDimensions(choices []PddOptionChoice, keys, names []string) (color, size string, ambiguous, extra bool) {
|
||||
colorAliases := map[string]bool{"color": true, "colour": true, "颜色": true, "颜色分类": true, "色系": true}
|
||||
sizeAliases := map[string]bool{"size": true, "尺码": true, "尺寸": true, "大小": true, "码数": true}
|
||||
for i, key := range keys {
|
||||
name := key
|
||||
if i < len(names) {
|
||||
name += " " + names[i]
|
||||
}
|
||||
parts := strings.Fields(strings.ToLower(simplifyExplicit(name)))
|
||||
isColor, isSize := false, false
|
||||
for _, p := range parts {
|
||||
if colorAliases[p] {
|
||||
isColor = true
|
||||
}
|
||||
if sizeAliases[p] {
|
||||
isSize = true
|
||||
}
|
||||
}
|
||||
if isColor {
|
||||
if color != "" && color != key {
|
||||
ambiguous = true
|
||||
}
|
||||
color = key
|
||||
}
|
||||
if isSize {
|
||||
if size != "" && size != key {
|
||||
ambiguous = true
|
||||
}
|
||||
size = key
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key == color || key == size {
|
||||
continue
|
||||
}
|
||||
values := map[string]bool{}
|
||||
for _, c := range choices {
|
||||
values[c.Options[key]] = true
|
||||
}
|
||||
if len(values) > 1 {
|
||||
extra = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func classifySpec(a, b specFeatures) (matchLevel, float64, float64, float64, string) {
|
||||
colorKnown := len(a.colors) > 0 && len(b.colors) > 0
|
||||
colorHit := false
|
||||
for c := range a.colors {
|
||||
if b.colors[c] {
|
||||
colorHit = true
|
||||
}
|
||||
}
|
||||
if colorKnown && !colorHit {
|
||||
return matchConflict, 0, 0, math.MaxFloat64, "主色明确冲突,请勿选择。"
|
||||
}
|
||||
sizeEqual := a.size != "" && a.size == b.size
|
||||
coverage, iou, mid := 0.0, 0.0, math.MaxFloat64
|
||||
if a.hasWeight && b.hasWeight {
|
||||
inter := math.Max(0, math.Min(a.weightTo, b.weightTo)-math.Max(a.weightFrom, b.weightFrom))
|
||||
target := a.weightTo - a.weightFrom
|
||||
union := math.Max(a.weightTo, b.weightTo) - math.Min(a.weightFrom, b.weightFrom)
|
||||
if target == 0 {
|
||||
if a.weightFrom >= b.weightFrom && a.weightFrom <= b.weightTo {
|
||||
coverage = 1
|
||||
}
|
||||
} else {
|
||||
coverage = inter / target
|
||||
}
|
||||
if union > 0 {
|
||||
iou = inter / union
|
||||
}
|
||||
mid = math.Abs((a.weightFrom + a.weightTo - b.weightFrom - b.weightTo) / 2)
|
||||
if sizeEqual && inter == 0 {
|
||||
return matchConflict, coverage, iou, mid, "尺码码位一致,但体重区间完全不重叠。"
|
||||
}
|
||||
}
|
||||
contained := a.hasWeight && b.hasWeight && a.weightFrom >= b.weightFrom && a.weightTo <= b.weightTo
|
||||
widthOK := false
|
||||
if contained {
|
||||
width := b.weightTo - b.weightFrom
|
||||
target := a.weightTo - a.weightFrom
|
||||
widthOK = width <= math.Max(target*maxCandidateWidthRatio, minCandidateWidthAllowanceJin) && mid <= maxMidpointDistanceJin
|
||||
}
|
||||
if sizeEqual && contained && widthOK && colorHit {
|
||||
return matchA, coverage, iou, mid, fmt.Sprintf("尺码码位一致(%s);目标体重区间被候选完整覆盖;主色一致。", a.size)
|
||||
}
|
||||
if sizeEqual && coverage > 0 && (!colorKnown || colorHit) {
|
||||
return matchB, coverage, iou, mid, "尺码码位一致,体重区间部分重叠;请人工核对。"
|
||||
}
|
||||
return matchC, coverage, iou, mid, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。"
|
||||
}
|
||||
Reference in New Issue
Block a user