389 lines
10 KiB
Go
389 lines
10 KiB
Go
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),
|
||
}
|
||
}
|