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