265 lines
6.1 KiB
Go
265 lines
6.1 KiB
Go
|
|
package knowledge
|
||
|
|
|
||
|
|
import (
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
const MaxTopPatrolTags = 8
|
||
|
|
|
||
|
|
type PatrolTagCandidate struct {
|
||
|
|
Tag string
|
||
|
|
Score int
|
||
|
|
Reason string
|
||
|
|
Intent string
|
||
|
|
}
|
||
|
|
|
||
|
|
func CollectPatrolTagsFromGraph(in PatrolTagInput, nodes []Node) []string {
|
||
|
|
return SelectTopPatrolTags(BuildPatrolCandidates(in, nodes), MaxTopPatrolTags)
|
||
|
|
}
|
||
|
|
|
||
|
|
func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate {
|
||
|
|
out := []PatrolTagCandidate{}
|
||
|
|
for i, tag := range SanitizePatrolKeywordList(in.PatrolKeywords) {
|
||
|
|
addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
for i, q := range in.Questions {
|
||
|
|
addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
for i, pillar := range in.Pillars {
|
||
|
|
addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
for i, matchTag := range in.MatchTags {
|
||
|
|
addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
if in.ProductName != "" {
|
||
|
|
addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
for _, node := range nodes {
|
||
|
|
if !IsPainNode(node) && node.Layer > 1 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
nodeScore := scoreNode(node)
|
||
|
|
for _, tag := range node.DerivedTags.Relevance {
|
||
|
|
addPatrolCandidate(&out, tag, nodeScore, nodePatrolReason(node), patrolIntentRelevance)
|
||
|
|
}
|
||
|
|
for _, tag := range node.DerivedTags.Recency {
|
||
|
|
addPatrolCandidate(&out, tag, nodeScore-3, nodePatrolReason(node), patrolIntentRecency)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func SelectTopPatrolTags(candidates []PatrolTagCandidate, limit int) []string {
|
||
|
|
if limit <= 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
picked := selectTopPatrolCandidates(candidates, limit)
|
||
|
|
out := make([]string, 0, len(picked))
|
||
|
|
for _, item := range picked {
|
||
|
|
out = append(out, item.Tag)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func selectBestDerivedTags(candidates []PatrolTagCandidate, relLimit, recLimit int) DerivedTags {
|
||
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||
|
|
if candidates[i].Score == candidates[j].Score {
|
||
|
|
return candidates[i].Tag < candidates[j].Tag
|
||
|
|
}
|
||
|
|
return candidates[i].Score > candidates[j].Score
|
||
|
|
})
|
||
|
|
seenRelKey := map[string]struct{}{}
|
||
|
|
seenRecTag := map[string]struct{}{}
|
||
|
|
relevance := []string{}
|
||
|
|
recency := []string{}
|
||
|
|
for _, item := range candidates {
|
||
|
|
if item.Intent == patrolIntentRecency {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
key := patrolTagDedupeKey(item.Tag)
|
||
|
|
if _, ok := seenRelKey[key]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seenRelKey[key] = struct{}{}
|
||
|
|
relevance = append(relevance, item.Tag)
|
||
|
|
if len(relevance) >= relLimit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, item := range candidates {
|
||
|
|
if item.Intent != patrolIntentRecency {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seenRecTag[item.Tag]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seenRecTag[item.Tag] = struct{}{}
|
||
|
|
recency = append(recency, item.Tag)
|
||
|
|
if len(recency) >= recLimit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(recency) < recLimit && len(relevance) > 0 {
|
||
|
|
for _, rel := range relevance {
|
||
|
|
alt := patrolRecencyFallback(rel)
|
||
|
|
if alt == "" || containsString(relevance, alt) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seenRecTag[alt]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
recency = append(recency, alt)
|
||
|
|
if len(recency) >= recLimit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return DerivedTags{
|
||
|
|
Relevance: capTags(uniqueTags(relevance), relLimit),
|
||
|
|
Recency: capTags(uniqueTags(recency), recLimit),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func selectTopPatrolCandidates(candidates []PatrolTagCandidate, limit int) []PatrolTagCandidate {
|
||
|
|
if limit <= 0 || len(candidates) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||
|
|
if candidates[i].Score == candidates[j].Score {
|
||
|
|
return candidates[i].Tag < candidates[j].Tag
|
||
|
|
}
|
||
|
|
return candidates[i].Score > candidates[j].Score
|
||
|
|
})
|
||
|
|
seenTag := map[string]struct{}{}
|
||
|
|
seenKey := map[string]struct{}{}
|
||
|
|
out := make([]PatrolTagCandidate, 0, limit)
|
||
|
|
for _, item := range candidates {
|
||
|
|
tag := strings.TrimSpace(item.Tag)
|
||
|
|
if tag == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
key := patrolTagDedupeKey(tag)
|
||
|
|
if _, ok := seenTag[tag]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seenKey[key]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seenTag[tag] = struct{}{}
|
||
|
|
seenKey[key] = struct{}{}
|
||
|
|
out = append(out, PatrolTagCandidate{
|
||
|
|
Tag: tag,
|
||
|
|
Score: item.Score,
|
||
|
|
Reason: item.Reason,
|
||
|
|
Intent: item.Intent,
|
||
|
|
})
|
||
|
|
if len(out) >= limit {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func addPatrolCandidate(out *[]PatrolTagCandidate, tag string, score int, reason, intent string) {
|
||
|
|
tag = strings.TrimSpace(tag)
|
||
|
|
if tag == "" || score <= 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
*out = append(*out, PatrolTagCandidate{
|
||
|
|
Tag: tag,
|
||
|
|
Score: score,
|
||
|
|
Reason: reason,
|
||
|
|
Intent: intent,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreQuestion(index int) int {
|
||
|
|
score := 100 - index*4
|
||
|
|
if score < 70 {
|
||
|
|
return 70
|
||
|
|
}
|
||
|
|
return score
|
||
|
|
}
|
||
|
|
|
||
|
|
func scorePillar(index int) int {
|
||
|
|
score := 74 - index*3
|
||
|
|
if score < 55 {
|
||
|
|
return 55
|
||
|
|
}
|
||
|
|
return score
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreMatchTag(index int) int {
|
||
|
|
score := 90 - index*4
|
||
|
|
if score < 70 {
|
||
|
|
return 70
|
||
|
|
}
|
||
|
|
return score
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreProduct() int {
|
||
|
|
return 58
|
||
|
|
}
|
||
|
|
|
||
|
|
func scoreNode(node Node) int {
|
||
|
|
base := 48
|
||
|
|
switch node.Layer {
|
||
|
|
case 0:
|
||
|
|
base = 82
|
||
|
|
case 1:
|
||
|
|
base = 68
|
||
|
|
case 2:
|
||
|
|
base = 52
|
||
|
|
}
|
||
|
|
if IsPainNode(node) {
|
||
|
|
base += 8
|
||
|
|
}
|
||
|
|
if node.ProductFitScore > 0 {
|
||
|
|
base += node.ProductFitScore / 6
|
||
|
|
}
|
||
|
|
return base
|
||
|
|
}
|
||
|
|
|
||
|
|
func nodePatrolReason(node Node) string {
|
||
|
|
switch node.Layer {
|
||
|
|
case 0:
|
||
|
|
return "核心痛點"
|
||
|
|
case 1:
|
||
|
|
return "高相關延伸"
|
||
|
|
default:
|
||
|
|
return "周邊情境"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func patrolTagDedupeKey(tag string) string {
|
||
|
|
tag = strings.TrimSpace(tag)
|
||
|
|
for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎"} {
|
||
|
|
if strings.HasSuffix(tag, suffix) {
|
||
|
|
tag = strings.TrimSuffix(tag, suffix)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return tag
|
||
|
|
}
|
||
|
|
|
||
|
|
func patrolRecencyFallback(relevanceTag string) string {
|
||
|
|
alt := patrolTagFromSource(relevanceTag, patrolIntentRecency)
|
||
|
|
if alt != "" && alt != relevanceTag {
|
||
|
|
return alt
|
||
|
|
}
|
||
|
|
if strings.Contains(relevanceTag, "請問") {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return patrolTagFromSource(relevanceTag+" 請問", patrolIntentRecency)
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsString(items []string, target string) bool {
|
||
|
|
for _, item := range items {
|
||
|
|
if item == target {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|