package knowledge import ( "encoding/json" "fmt" "strings" "sync" libprompt "haixun-backend/internal/library/prompt" ) type queryConfig struct { MaxPlanQueries int `json:"max_plan_queries"` HybridMaxPlanQueries int `json:"hybrid_max_plan_queries"` MaxSupplemental int `json:"max_supplemental_queries"` HybridMaxSupplemental int `json:"hybrid_max_supplemental_queries"` ResultsPerQuery int `json:"results_per_query"` MinSourcesBeforeStop int `json:"min_sources_before_stop"` MaxSourcesCap int `json:"max_sources_cap"` BraveCollectConcurrency int `json:"brave_collect_concurrency"` MaxPatrolKeywordQueries int `json:"max_patrol_keyword_queries"` MaxQuestionQueries int `json:"max_question_queries"` MaxPillarQueries int `json:"max_pillar_queries"` MaxPlanBaseQueries int `json:"max_plan_base_queries"` MaxPeripheralQueries int `json:"max_peripheral_queries"` MaxL1Labels int `json:"max_l1_labels"` MinPainTagCandidates int `json:"min_pain_tag_candidates"` MinTotalTagCandidates int `json:"min_total_tag_candidates"` PlanBase []string `json:"plan_base"` PlanPeripheral []string `json:"plan_peripheral"` PlanAudience string `json:"plan_audience"` PlanL1Cause string `json:"plan_l1_cause"` PlanL1Pain string `json:"plan_l1_pain"` PlanPillar string `json:"plan_pillar"` PlanQuestion string `json:"plan_question"` Supplemental []string `json:"supplemental"` SupplementalL1 string `json:"supplemental_l1"` SupplementalPillar string `json:"supplemental_pillar"` RecencySuffix string `json:"recency_suffix"` RecencyHelpMarkers string `json:"recency_help_markers"` } var ( queryCfgOnce sync.Once queryCfg queryConfig queryCfgErr error ) func loadQueryConfig() (queryConfig, error) { queryCfgOnce.Do(func() { raw, err := libprompt.KnowledgeGraphQueryConfig() if err != nil { queryCfgErr = err return } payload, err := json.Marshal(raw) if err != nil { queryCfgErr = err return } queryCfgErr = json.Unmarshal(payload, &queryCfg) }) return queryCfg, queryCfgErr } func MaxPlanQueriesPerRound() int { cfg, err := loadQueryConfig() if err != nil || cfg.MaxPlanQueries <= 0 { return 15 } return cfg.MaxPlanQueries } func MaxSupplementalQueries() int { cfg, err := loadQueryConfig() if err != nil || cfg.MaxSupplemental <= 0 { return 5 } return cfg.MaxSupplemental } func MinPainTagCandidates() int { cfg, err := loadQueryConfig() if err != nil || cfg.MinPainTagCandidates <= 0 { return 8 } return cfg.MinPainTagCandidates } type PlanInput struct { Seed string TargetAudience string ProductBrief string Pillars []string Questions []string PatrolKeywords []string L1Labels []string Supplemental bool Strategy ExpandStrategy } func PlanQueries(in PlanInput) []string { cfg, err := loadQueryConfig() if err != nil { return nil } seed := strings.TrimSpace(in.Seed) if seed == "" { return nil } if in.Supplemental { return supplementalQueries(cfg, in) } return planPrimaryQueries(cfg, in) } func queryBudget(cfg queryConfig, strategy ExpandStrategy, supplemental bool) int { if supplemental { if strategy == ExpandStrategyHybrid { if cfg.HybridMaxSupplemental > 0 { return cfg.HybridMaxSupplemental } return 0 } max := cfg.MaxSupplemental if max <= 0 { return 4 } return max } if strategy == ExpandStrategyHybrid { max := cfg.HybridMaxPlanQueries if max <= 0 { return 5 } return max } max := cfg.MaxPlanQueries if max <= 0 { return 10 } return max } func planPrimaryQueries(cfg queryConfig, in PlanInput) []string { seed := strings.TrimSpace(in.Seed) budget := queryBudget(cfg, in.Strategy, false) if budget <= 0 { return nil } seen := map[string]struct{}{} out := make([]string, 0, budget) add := func(q string) bool { q = strings.TrimSpace(q) if q == "" { return false } if _, ok := seen[q]; ok { return false } seen[q] = struct{}{} out = append(out, q) return len(out) >= budget } vars := map[string]string{"seed": seed, "audience": strings.TrimSpace(in.TargetAudience)} patrolLimit := cfg.MaxPatrolKeywordQueries if patrolLimit <= 0 { patrolLimit = 4 } for i, keyword := range in.PatrolKeywords { if i >= patrolLimit { break } if add(keyword) { return out } } questionLimit := cfg.MaxQuestionQueries if questionLimit <= 0 { questionLimit = 3 } for i, question := range in.Questions { if i >= questionLimit { break } question = strings.TrimSpace(question) if question == "" { continue } if tpl := strings.TrimSpace(cfg.PlanQuestion); tpl != "" { if add(renderQueryTemplate(tpl, map[string]string{"question": question})) { return out } } else if add(question) { return out } } pillarLimit := cfg.MaxPillarQueries if pillarLimit <= 0 { pillarLimit = 2 } for i, pillar := range in.Pillars { if i >= pillarLimit { break } pillar = strings.TrimSpace(pillar) if pillar == "" { continue } if tpl := strings.TrimSpace(cfg.PlanPillar); tpl != "" { if add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar})) { return out } } else if add(pillar + " 請問") { return out } } baseLimit := cfg.MaxPlanBaseQueries if baseLimit <= 0 { baseLimit = 3 } for i, tpl := range cfg.PlanBase { if i >= baseLimit { break } if add(renderQueryTemplate(tpl, vars)) { return out } } if vars["audience"] != "" && strings.TrimSpace(cfg.PlanAudience) != "" { if add(renderQueryTemplate(cfg.PlanAudience, vars)) { return out } } peripheralLimit := cfg.MaxPeripheralQueries if in.Strategy == ExpandStrategyHybrid && peripheralLimit > 1 { peripheralLimit = 1 } if peripheralLimit <= 0 { peripheralLimit = 2 } for i, tpl := range cfg.PlanPeripheral { if i >= peripheralLimit { break } if add(renderQueryTemplate(tpl, vars)) { return out } } l1Limit := cfg.MaxL1Labels if l1Limit <= 0 { l1Limit = 2 } for i, label := range in.L1Labels { if i >= l1Limit { break } label = strings.TrimSpace(label) if label == "" || label == seed { continue } l1vars := map[string]string{"seed": seed, "label": label} if add(renderQueryTemplate(cfg.PlanL1Pain, l1vars)) { return out } } return out } func supplementalQueries(cfg queryConfig, in PlanInput) []string { seed := strings.TrimSpace(in.Seed) if seed == "" { return nil } budget := queryBudget(cfg, in.Strategy, true) if budget <= 0 { return nil } seen := map[string]struct{}{} out := make([]string, 0, budget) add := func(q string) { q = strings.TrimSpace(q) if q == "" { return } if _, ok := seen[q]; ok { return } seen[q] = struct{}{} out = append(out, q) } vars := map[string]string{"seed": seed} for _, tpl := range cfg.Supplemental { add(renderQueryTemplate(tpl, vars)) } for _, pillar := range in.Pillars { pillar = strings.TrimSpace(pillar) if pillar == "" { continue } if tpl := strings.TrimSpace(cfg.SupplementalPillar); tpl != "" { add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar})) } if len(out) >= budget { return capQueries(out, budget) } } for _, label := range in.L1Labels { label = strings.TrimSpace(label) if label == "" { continue } add(renderQueryTemplate(cfg.SupplementalL1, map[string]string{"seed": seed, "label": label})) if len(out) >= budget { break } } return capQueries(out, budget) } func PlanBreadthQueries(in PlanInput) []string { in.Supplemental = true return PlanQueries(in) } // PlanBootstrapQueries builds Brave queries that do not depend on a generated research map. func PlanBootstrapQueries(in PlanInput) []string { bootstrap := in bootstrap.Pillars = nil bootstrap.Questions = nil bootstrap.L1Labels = nil bootstrap.Supplemental = false return PlanQueries(bootstrap) } // QueriesExcept returns planned queries that were not already executed. func QueriesExcept(planned, executed []string) []string { done := map[string]struct{}{} for _, q := range executed { q = strings.TrimSpace(q) if q == "" { continue } done[q] = struct{}{} } out := make([]string, 0, len(planned)) for _, q := range planned { q = strings.TrimSpace(q) if q == "" { continue } if _, ok := done[q]; ok { continue } out = append(out, q) } return out } func BuildRecencyQuery(label string) string { cfg, err := loadQueryConfig() if err != nil { return "" } label = strings.TrimSpace(label) if label == "" { return "" } if strings.ContainsAny(label, cfg.RecencyHelpMarkers) { return label } suffix := strings.TrimSpace(cfg.RecencySuffix) if suffix == "" { suffix = "請問" } return fmt.Sprintf("%s %s", label, suffix) } func renderQueryTemplate(tpl string, vars map[string]string) string { out := tpl for key, value := range vars { out = strings.ReplaceAll(out, "{{"+key+"}}", value) } return strings.TrimSpace(out) } func capQueries(items []string, max int) []string { if max <= 0 || len(items) <= max { return items } return items[:max] } func L1LabelsFromNodes(nodes []Node) []string { out := make([]string, 0, len(nodes)) for _, node := range nodes { if node.Layer != 1 { continue } label := strings.TrimSpace(node.Label) if label != "" { out = append(out, label) } } return out }