289 lines
7.0 KiB
Go
289 lines
7.0 KiB
Go
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
|
||
} |