2026-06-28 08:28:42 +00:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
2026-06-30 07:09:44 +00:00
|
|
|
|
}
|