thread-master/backend/internal/library/knowledge/semantic_fit.go

290 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}