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

290 lines
7.0 KiB
Go
Raw Normal View History

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
}