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

389 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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