package knowledge import ( "strings" "unicode" ) // IntentProfile is a weighted lexical intent model for product + research map. // It generalizes relevance beyond hardcoded category rules (e.g. 洗衣精 vs 洗衣機). type IntentProfile struct { TokenWeights map[string]float64 BroadTokens map[string]struct{} Phrases []weightedPhrase } type weightedPhrase struct { Text string Weight float64 } const ( intentWeightProduct = 3.0 intentWeightFeature = 2.8 intentWeightMatchTag = 2.5 intentWeightQuestion = 2.2 intentWeightPillar = 2.0 intentWeightAudience = 1.6 intentWeightPatrol = 1.2 intentWeightSeed = 1.0 tangentialBroadMin = 38 tangentialIntentMax = 30 ) var productFormHints = []string{ "洗衣精", "洗衣粉", "洗衣劑", "洗衣液", "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", } // BuildIntentProfile materializes product + research-map text into a reusable relevance model. func BuildIntentProfile(in PatrolTagInput) IntentProfile { profile := IntentProfile{ TokenWeights: map[string]float64{}, BroadTokens: map[string]struct{}{}, } addPhrase := func(text string, weight float64) { text = strings.TrimSpace(text) if text == "" || weight <= 0 { return } profile.Phrases = append(profile.Phrases, weightedPhrase{Text: text, Weight: weight}) profile.addTokens(text, weight) } addPhrase(in.ProductName, intentWeightProduct) addPhrase(in.ProductFeatures, intentWeightFeature) for _, tag := range in.MatchTags { addPhrase(tag, intentWeightMatchTag) } for _, q := range in.Questions { addPhrase(q, intentWeightQuestion) } for _, pillar := range in.Pillars { addPhrase(pillar, intentWeightPillar) } addPhrase(in.AudienceSummary, intentWeightAudience) addPhrase(in.TargetAudience, intentWeightAudience) for _, kw := range in.PatrolKeywords { addPhrase(kw, intentWeightPatrol) } if hint := productCategoryHint(in.ProductName, in.ProductFeatures); hint != "" { profile.markBroad(hint) } for _, hint := range productFormHints { blob := strings.ToLower(in.ProductName + " " + in.ProductFeatures + " " + strings.Join(in.MatchTags, " ")) if strings.Contains(blob, hint) { profile.markBroad(hint) } } return profile } func (p *IntentProfile) addTokens(text string, weight float64) { for _, token := range intentTokenize(text) { if existing, ok := p.TokenWeights[token]; !ok || weight > existing { p.TokenWeights[token] = weight } } } func (p *IntentProfile) markBroad(token string) { token = strings.TrimSpace(strings.ToLower(token)) if token == "" { return } p.BroadTokens[token] = struct{}{} for _, part := range intentTokenize(token) { p.BroadTokens[part] = struct{}{} } } func (p IntentProfile) totalWeight() float64 { sum := 0.0 for _, w := range p.TokenWeights { sum += w } if sum <= 0 { for _, phrase := range p.Phrases { sum += phrase.Weight } } return sum } // NodeIntentText combines node fields used for semantic fit. func NodeIntentText(node Node) string { return strings.TrimSpace(strings.Join([]string{ node.Label, node.Relation, node.PlacementValue, strings.Join(node.PatrolRelevance, " "), strings.Join(node.PatrolRecency, " "), }, " ")) } // ScoreIntentSimilarity returns 0-100 weighted token overlap against the intent profile. func ScoreIntentSimilarity(text string, profile IntentProfile) int { text = strings.ToLower(strings.TrimSpace(text)) if text == "" || len(profile.TokenWeights) == 0 { return 0 } matched := 0.0 for token, weight := range profile.TokenWeights { if strings.Contains(text, token) { matched += weight } } total := profile.totalWeight() if total <= 0 { return 0 } score := int((matched / total) * 100) if score > 100 { return 100 } return score } // ScoreBroadSimilarity measures overlap with category-level tokens only. func ScoreBroadSimilarity(text string, profile IntentProfile) int { text = strings.ToLower(strings.TrimSpace(text)) if text == "" || len(profile.BroadTokens) == 0 { return 0 } hits := 0 for token := range profile.BroadTokens { if strings.Contains(text, token) { hits++ continue } runes := []rune(token) if len(runes) >= 2 && strings.Contains(text, string(runes[:2])) { hits++ } } if hits == 0 { return 0 } denom := len(profile.BroadTokens) if denom > 6 { denom = 6 } score := (hits * 100) / denom if score > 100 { return 100 } return score } // IsTangentialToIntent detects same broad category but weak product-intent alignment. // Example: 洗衣機 vs 抗敏無香洗衣精 — shares 洗衣, lacks 無香/敏感/化療 intent. func IsTangentialToIntent(text string, profile IntentProfile) bool { text = strings.ToLower(strings.TrimSpace(text)) if text == "" || len(profile.TokenWeights) == 0 { return false } intentScore := ScoreIntentSimilarity(text, profile) if intentScore >= tangentialIntentMax+10 { return false } if hasCategoryStemDrift(text, profile) { return true } broadScore := ScoreBroadSimilarity(text, profile) if broadScore >= tangentialBroadMin { return intentScore <= tangentialIntentMax } return false } func hasCategoryStemDrift(text string, profile IntentProfile) bool { for broad := range profile.BroadTokens { runes := []rune(broad) if len(runes) < 2 { continue } stem := string(runes[:2]) if !strings.Contains(text, stem) { continue } if strings.Contains(text, broad) { continue } for _, suffix := range []string{"機", "槽", "烘", "劑", "粉", "精", "乳", "露", "液", "皂"} { alt := stem + suffix if alt == broad || !strings.Contains(text, alt) { continue } if !profile.matchesIntentToken(alt) { return true } } } return false } func (p IntentProfile) matchesIntentToken(token string) bool { if _, ok := p.TokenWeights[token]; ok { return true } for intentToken := range p.TokenWeights { if strings.Contains(token, intentToken) || strings.Contains(intentToken, token) { return true } } return false } func intentTokenize(text string) []string { text = strings.ToLower(strings.TrimSpace(text)) if text == "" { return nil } repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ", "?", " ", "?", " ", "!", " ", "!", " ") text = repl.Replace(text) seen := map[string]struct{}{} var out []string add := func(token string) { token = strings.Trim(token, `"'「」『』::`) if len([]rune(token)) < 2 { return } if _, ok := seen[token]; ok { return } seen[token] = struct{}{} out = append(out, token) } for _, part := range strings.Fields(text) { add(part) } if len(out) == 0 { for _, chunk := range intentSplitRunes(text) { add(chunk) } } return out } func intentSplitRunes(s string) []string { var out []string var buf []rune flush := func() { if len(buf) >= 2 { out = append(out, string(buf)) } buf = buf[:0] } for _, r := range s { if unicode.Is(unicode.Han, r) { buf = append(buf, r) continue } flush() } flush() return out }