package project import ( "fmt" "sort" "strings" "cyberstrike-ai/internal/database" ) var factIndexEdgeTypeOrder = []string{ "discovered_on", "leads_to", "enables", "depends_on", "exploits", "contains", "part_of", "supports", } func filterIndexEdges(edges []*database.ProjectFactEdge) []*database.ProjectFactEdge { if len(edges) == 0 { return nil } out := make([]*database.ProjectFactEdge, 0, len(edges)) for _, e := range edges { if e == nil { continue } if strings.EqualFold(strings.TrimSpace(e.Confidence), "deprecated") { continue } edgeType := strings.ToLower(strings.TrimSpace(e.EdgeType)) if _, ok := database.ValidProjectFactEdgeTypes[edgeType]; !ok { continue } out = append(out, e) } return out } func edgeConfidenceSuffix(confidence string) string { c := strings.ToLower(strings.TrimSpace(confidence)) if c == "" || c == "confirmed" { return "" } return " (" + c + ")" } func formatRelationHintPart(e *database.ProjectFactEdge) string { return fmt.Sprintf("%s←%s%s", e.EdgeType, e.SourceFactKey, edgeConfidenceSuffix(e.Confidence)) } func formatOutgoingHintPart(e *database.ProjectFactEdge) string { return fmt.Sprintf("%s→%s%s", e.EdgeType, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence)) } func formatIncomingHintPart(e *database.ProjectFactEdge) string { return formatRelationHintPart(e) } func joinEdgeHintParts(edges []*database.ProjectFactEdge, formatter func(*database.ProjectFactEdge) string) string { parts := make([]string, 0, len(edges)) for _, e := range edges { parts = append(parts, formatter(e)) } return strings.Join(parts, ", ") } // FormatOutgoingLinksHint 黑板索引用出边摘要(全部有效边类型,不截断)。 func FormatOutgoingLinksHint(edges []*database.ProjectFactEdge) string { edges = filterIndexEdges(edges) if len(edges) == 0 { return "" } return " {出边: " + joinEdgeHintParts(edges, formatOutgoingHintPart) + "}" } // FormatIncomingLinksHint 黑板索引用入边摘要(全部有效边类型,不截断)。 func FormatIncomingLinksHint(edges []*database.ProjectFactEdge) string { edges = filterIndexEdges(edges) if len(edges) == 0 { return "" } return " {入边: " + joinEdgeHintParts(edges, formatIncomingHintPart) + "}" } // FormatFactIndexLinksHint 黑板索引行内关系边(from → 当前 fact,与 upsert links 一致)。 func FormatFactIndexLinksHint(_ string, incoming []*database.ProjectFactEdge) string { in := filterIndexEdges(incoming) if len(in) == 0 { return "" } return " {关系边: " + joinEdgeHintParts(in, formatRelationHintPart) + "}" } func indexEdgeGroupMaps(edges []*database.ProjectFactEdge) (outgoing, incoming map[string][]*database.ProjectFactEdge) { outgoing = map[string][]*database.ProjectFactEdge{} incoming = map[string][]*database.ProjectFactEdge{} for _, e := range filterIndexEdges(edges) { outgoing[e.SourceFactKey] = append(outgoing[e.SourceFactKey], e) incoming[e.TargetFactKey] = append(incoming[e.TargetFactKey], e) } return outgoing, incoming } func relationOverviewLine(e *database.ProjectFactEdge) string { return fmt.Sprintf("- %s → %s%s · %s", e.SourceFactKey, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence), e.EdgeType) } func indexEdgeSortKey(e *database.ProjectFactEdge) (int, int, string) { confRank := 0 if strings.EqualFold(strings.TrimSpace(e.Confidence), "tentative") { confRank = 1 } typeRank := len(factIndexEdgeTypeOrder) + 1 for i, t := range factIndexEdgeTypeOrder { if strings.EqualFold(e.EdgeType, t) { typeRank = i break } } return confRank, typeRank, e.SourceFactKey + ">" + e.TargetFactKey + ">" + e.EdgeType } func sortIndexOverviewEdges(edges []*database.ProjectFactEdge) { sort.SliceStable(edges, func(i, j int) bool { ci, ti, ki := indexEdgeSortKey(edges[i]) cj, tj, kj := indexEdgeSortKey(edges[j]) if ci != cj { return ci < cj } if ti != tj { return ti < tj } return ki < kj }) } // BuildFactPathOverviewSection 生成事实关系速览(全部有效边类型,不含 body)。 func BuildFactPathOverviewSection(edges []*database.ProjectFactEdge, indexedKeys map[string]struct{}, maxRunes int) string { if maxRunes <= 0 { return "" } candidates := filterIndexEdges(edges) if len(candidates) == 0 { return "" } filtered := make([]*database.ProjectFactEdge, 0, len(candidates)) for _, e := range candidates { if len(indexedKeys) > 0 { if _, ok := indexedKeys[e.SourceFactKey]; !ok { continue } if _, ok := indexedKeys[e.TargetFactKey]; !ok { continue } } filtered = append(filtered, e) } if len(filtered) == 0 { return "" } sortIndexOverviewEdges(filtered) header := "### 攻击路径(事实关系)\n" header += "source → target · type(与攻击路径图/库中方向一致;写入时在目标 fact 的 links 用 from 声明来源)\n" var b strings.Builder b.WriteString(header) used := len([]rune(header)) omitted := 0 for _, e := range filtered { line := relationOverviewLine(e) + "\n" lineRunes := len([]rune(line)) if used+lineRunes > maxRunes { omitted++ continue } b.WriteString(line) used += lineRunes } if omitted > 0 { extra := fmt.Sprintf("(另有 %d 条关系边未列入,请 get_project_fact 查看完整关系。)\n", omitted) if used+len([]rune(extra)) <= maxRunes { b.WriteString(extra) } } if used <= len([]rune(header)) { return "" } return b.String() } func factIndexSortPriority(f *database.ProjectFact) int { if f == nil { return 0 } score := 0 if f.Pinned { score += 1000 } c := strings.ToLower(strings.TrimSpace(f.Category)) switch c { case FactCategoryTarget: score += 400 case FactCategoryFinding, FactCategoryChain: score += 300 case FactCategoryExploit, FactCategoryPOC: score += 250 case "auth", "infra", "business": score += 200 case "note": score += 50 default: key := strings.ToLower(strings.TrimSpace(f.FactKey)) if strings.HasPrefix(key, "target/") { score += 400 } else if strings.HasPrefix(key, "finding/") || strings.HasPrefix(key, "chain/") { score += 300 } } if strings.EqualFold(strings.TrimSpace(f.Confidence), "confirmed") { score += 80 } return score } func sortFactsForIndex(facts []*database.ProjectFact) { sort.SliceStable(facts, func(i, j int) bool { pi, pj := factIndexSortPriority(facts[i]), factIndexSortPriority(facts[j]) if pi != pj { return pi > pj } return facts[i].UpdatedAt.After(facts[j].UpdatedAt) }) }