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

265 lines
6.1 KiB
Go
Raw Permalink Normal View History

2026-06-26 08:37:04 +00:00
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
}