package copymission import ( "strings" libkg "haixun-backend/internal/library/knowledge" libmatrix "haixun-backend/internal/library/matrix" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" ) type MissionContext struct { Label string SeedQuery string Brief string ResearchMap missionentity.ResearchMap } type PersonaContext struct { DisplayName string Persona string } func PlanInput( mission MissionContext, seed string, l1Labels []string, supplemental bool, strategy libkg.ExpandStrategy, ) libkg.PlanInput { return libkg.PlanInput{ Seed: seed, TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary), ProductBrief: strings.TrimSpace(mission.Brief), Pillars: mission.ResearchMap.Pillars, Questions: mission.ResearchMap.Questions, L1Labels: l1Labels, Supplemental: supplemental, Strategy: strategy, } } func SynthInput(mission MissionContext, persona PersonaContext, sources []libkg.BraveSource) libkg.SynthInput { label := strings.TrimSpace(mission.Label) if label == "" { label = strings.TrimSpace(mission.SeedQuery) } return libkg.SynthInput{ BrandDisplayName: label, TopicName: label, Seed: strings.TrimSpace(mission.SeedQuery), ProductBrief: strings.TrimSpace(mission.Brief), TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary), Persona: strings.TrimSpace(persona.Persona), ResearchPillars: mission.ResearchMap.Pillars, ResearchQuestions: mission.ResearchMap.Questions, Sources: sources, } } func PatrolTagInput(mission MissionContext) libkg.PatrolTagInput { return libkg.PatrolTagInput{ Questions: mission.ResearchMap.Questions, Pillars: mission.ResearchMap.Pillars, } } func FormatNodeKnowledge(node libkg.Node) string { label := strings.TrimSpace(node.Label) if label == "" { return "" } detail := strings.TrimSpace(node.PlacementValue) if detail == "" { detail = strings.TrimSpace(node.Relation) } if detail != "" { return label + ":" + detail } return label } // FormatNodeKnowledgeForMatrix includes label, detail, patrol tags, and evidence for copy generation. func FormatNodeKnowledgeForMatrix(node libkg.Node) string { label := strings.TrimSpace(node.Label) if label == "" { return "" } var parts []string if detail := strings.TrimSpace(node.PlacementValue); detail != "" { parts = append(parts, detail) } else if detail := strings.TrimSpace(node.Relation); detail != "" { parts = append(parts, detail) } tags := append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...) if len(tags) > 0 { parts = append(parts, "標籤:"+strings.Join(tags, "、")) } if len(node.Evidence) > 0 { if snippet := strings.TrimSpace(node.Evidence[0].Snippet); snippet != "" { parts = append(parts, "參考:"+snippet) } } if len(parts) == 0 { return label } return label + "|" + strings.Join(parts, ";") } func KnowledgeItemsFromNodes(nodes []libkg.Node) (all []string, selected []string) { seen := map[string]struct{}{} for _, node := range nodes { line := FormatNodeKnowledge(node) if line == "" { continue } key := strings.ToLower(line) if _, ok := seen[key]; ok { if node.SelectedForScan { selected = mergeLine(selected, line) } continue } seen[key] = struct{}{} all = append(all, line) if node.SelectedForScan { selected = append(selected, line) } } return all, selected } func ResearchItemsFromSources(sources []libkg.BraveSource) []missionentity.ResearchItem { items := make([]missionentity.ResearchItem, 0, len(sources)) for _, src := range sources { if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" { continue } items = append(items, missionentity.ResearchItem{ Title: src.Title, URL: src.URL, Snippet: src.Snippet, Query: src.Query, }) } return items } func ApplyDefaultNodeSelection(nodes []libkg.Node) { ApplyDefaultNodeSelectionPreserving(nodes, nil) } func ApplyDefaultNodeSelectionPreserving(nodes []libkg.Node, preserve map[string]bool) { for i := range nodes { if preserve != nil { if sel, ok := preserve[nodes[i].ID]; ok { nodes[i].SelectedForScan = sel continue } } kind := strings.TrimSpace(nodes[i].NodeKind) nodes[i].SelectedForScan = kind == "pain" || kind == "symptom" || kind == "cause" || nodes[i].ProductFitScore >= 70 } } func mergeLine(list []string, line string) []string { for _, item := range list { if item == line { return list } } return append(list, line) } func mergeKnowledgeLines(base, extra []string) []string { out := append([]string(nil), base...) seen := map[string]struct{}{} for _, item := range out { seen[strings.ToLower(strings.TrimSpace(item))] = struct{}{} } for _, item := range extra { item = strings.TrimSpace(item) if item == "" { continue } key := strings.ToLower(item) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, item) } return out } func knowledgeItemsFromNodesForMatrix(nodes []libkg.Node) (all []string, selected []string) { seen := map[string]struct{}{} for _, node := range nodes { line := FormatNodeKnowledgeForMatrix(node) if line == "" { continue } key := strings.ToLower(line) if _, ok := seen[key]; ok { if node.SelectedForScan { selected = mergeLine(selected, line) } continue } seen[key] = struct{}{} all = append(all, line) if node.SelectedForScan { selected = append(selected, line) } } return all, selected } func knowledgeItemsFromSelectedTags(nodes []libkg.Node, tags []string) []string { normalized := make([]string, 0, len(tags)) seen := map[string]struct{}{} for _, tag := range tags { tag = strings.TrimSpace(tag) if tag == "" { continue } key := strings.ToLower(tag) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} normalized = append(normalized, key) } if len(normalized) == 0 { return nil } out := make([]string, 0) lineSeen := map[string]struct{}{} for _, node := range nodes { if !nodeMatchesTags(node, normalized) { continue } line := FormatNodeKnowledgeForMatrix(node) if line == "" { continue } if _, ok := lineSeen[line]; ok { continue } lineSeen[line] = struct{}{} out = append(out, line) } return out } func nodeMatchesTags(node libkg.Node, tags []string) bool { candidates := []string{node.Label, node.PlacementValue, node.Relation} candidates = append(candidates, node.DerivedTags.Relevance...) candidates = append(candidates, node.DerivedTags.Recency...) for _, raw := range candidates { lower := strings.ToLower(strings.TrimSpace(raw)) if lower == "" { continue } for _, tag := range tags { if strings.Contains(lower, tag) { return true } } } return false } // MatrixKnowledgeItems uses graph-selected nodes as the source of truth and only merges // manual-only selected_knowledge_items that are not graph-derived duplicates. func MatrixKnowledgeItems(selected, _ []string, graphNodes []libkg.Node, _ []string) []string { _, fromGraph := knowledgeItemsFromNodesForMatrix(graphNodes) graphKeys := map[string]struct{}{} for _, node := range graphNodes { line := FormatNodeKnowledge(node) if line == "" { continue } graphKeys[strings.ToLower(line)] = struct{}{} } manualSelected := make([]string, 0, len(selected)) for _, item := range selected { item = strings.TrimSpace(item) if item == "" { continue } if _, fromGraph := graphKeys[strings.ToLower(item)]; fromGraph { continue } manualSelected = append(manualSelected, item) } return libmatrix.MergeKnowledgeItems(fromGraph, manualSelected) } func manualResearchKnowledge(prev missionentity.ResearchMap, nodes []libkg.Node) (all []string, selected []string) { graphKeys := map[string]struct{}{} for _, node := range nodes { line := FormatNodeKnowledge(node) if line == "" { continue } graphKeys[strings.ToLower(line)] = struct{}{} } selectedKeys := map[string]struct{}{} for _, item := range prev.SelectedKnowledgeItems { item = strings.TrimSpace(item) if item == "" { continue } selectedKeys[strings.ToLower(item)] = struct{}{} } for _, item := range prev.KnowledgeItems { item = strings.TrimSpace(item) if item == "" { continue } lower := strings.ToLower(item) if _, fromGraph := graphKeys[lower]; fromGraph { continue } all = append(all, item) if _, ok := selectedKeys[lower]; ok { selected = append(selected, item) } } return all, selected } func graphKnowledgeDeselected(prev missionentity.ResearchMap) map[string]struct{} { selectedKeys := map[string]struct{}{} for _, item := range prev.SelectedKnowledgeItems { item = strings.TrimSpace(item) if item == "" { continue } selectedKeys[strings.ToLower(item)] = struct{}{} } deselected := map[string]struct{}{} for _, item := range prev.KnowledgeItems { item = strings.TrimSpace(item) if item == "" { continue } if _, ok := selectedKeys[strings.ToLower(item)]; !ok { deselected[strings.ToLower(item)] = struct{}{} } } return deselected } func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Node, sources []libkg.BraveSource) missionentity.ResearchMap { allItems, selectedItems := KnowledgeItemsFromNodes(nodes) deselected := graphKnowledgeDeselected(prev) if len(deselected) > 0 { filtered := selectedItems[:0] for _, line := range selectedItems { if _, skip := deselected[strings.ToLower(line)]; skip { continue } filtered = append(filtered, line) } selectedItems = filtered } manualAll, manualSelected := manualResearchKnowledge(prev, nodes) allItems = mergeKnowledgeLines(allItems, manualAll) selectedItems = mergeKnowledgeLines(selectedItems, manualSelected) return missionentity.ResearchMap{ AudienceSummary: prev.AudienceSummary, ContentGoal: prev.ContentGoal, Questions: append([]string(nil), prev.Questions...), Pillars: append([]string(nil), prev.Pillars...), Exclusions: append([]string(nil), prev.Exclusions...), SuggestedTags: append([]missionentity.SuggestedTag(nil), prev.SuggestedTags...), SimilarAccounts: append([]missionentity.SimilarAccount(nil), prev.SimilarAccounts...), AudienceSamples: append([]missionentity.AudienceSample(nil), prev.AudienceSamples...), BenchmarkNotes: prev.BenchmarkNotes, KnowledgeItems: allItems, SelectedKnowledgeItems: selectedItems, ResearchItems: ResearchItemsFromSources(sources), } }