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

388 lines
10 KiB
Go
Raw Permalink Normal View History

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),
}
}