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 }