diff --git a/internal/agent/default_single_system_prompt.go b/internal/agent/default_single_system_prompt.go index 0ccdd352..9f7e1f3c 100644 --- a/internal/agent/default_single_system_prompt.go +++ b/internal/agent/default_single_system_prompt.go @@ -1,7 +1,7 @@ package agent import ( - "cyberstrike-ai/internal/project" + "cyberstrike-ai/internal/projectprompt" ) // DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。 @@ -107,7 +107,7 @@ func DefaultSingleAgentSystemPrompt() string { - 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。 - 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。 -` + project.FactRecordingBlackboardSection(false) + ` +` + projectprompt.FactRecordingBlackboardSection(false) + ` ## 技能库(Skills)与知识库 diff --git a/internal/database/database.go b/internal/database/database.go index 4be5b95e..89246101 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -353,6 +353,22 @@ func (db *DB) initTables() error { UNIQUE(project_id, fact_key) );` + // 项目事实关系边(黑板 DAG) + createProjectFactEdgesTable := ` + CREATE TABLE IF NOT EXISTS project_fact_edges ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + source_fact_key TEXT NOT NULL, + target_fact_key TEXT NOT NULL, + edge_type TEXT NOT NULL, + confidence TEXT NOT NULL DEFAULT 'tentative', + source_conversation_id TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(project_id, source_fact_key, target_fact_key, edge_type) + );` + // 创建漏洞表 createVulnerabilitiesTable := ` CREATE TABLE IF NOT EXISTS vulnerabilities ( @@ -591,6 +607,9 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id); CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence); CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id); + CREATE INDEX IF NOT EXISTS idx_project_fact_edges_project ON project_fact_edges(project_id); + CREATE INDEX IF NOT EXISTS idx_project_fact_edges_source ON project_fact_edges(project_id, source_fact_key); + CREATE INDEX IF NOT EXISTS idx_project_fact_edges_target ON project_fact_edges(project_id, target_fact_key); CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id); CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id); @@ -672,6 +691,10 @@ func (db *DB) initTables() error { return fmt.Errorf("创建project_facts表失败: %w", err) } + if _, err := db.Exec(createProjectFactEdgesTable); err != nil { + return fmt.Errorf("创建project_fact_edges表失败: %w", err) + } + if _, err := db.Exec(createVulnerabilitiesTable); err != nil { return fmt.Errorf("创建vulnerabilities表失败: %w", err) } diff --git a/internal/database/project.go b/internal/database/project.go index 448958d4..d0524be8 100644 --- a/internal/database/project.go +++ b/internal/database/project.go @@ -389,7 +389,7 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) { return f, nil } -// DeprecateProjectFact 将事实标记为 deprecated。 +// DeprecateProjectFact 将事实标记为 deprecated(关联边同步 deprecated)。 func (db *DB) DeprecateProjectFact(projectID, factKey string) error { res, err := db.Exec( `UPDATE project_facts SET confidence = 'deprecated', updated_at = ? WHERE project_id = ? AND fact_key = ?`, @@ -402,7 +402,7 @@ func (db *DB) DeprecateProjectFact(projectID, factKey string) error { if n == 0 { return fmt.Errorf("事实不存在") } - return nil + return db.DeprecateProjectFactEdgesForKey(projectID, factKey) } // RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。 @@ -430,9 +430,16 @@ func (db *DB) RestoreProjectFact(projectID, factKey, confidence string) error { return err } -// DeleteProjectFact 删除事实。 +// DeleteProjectFact 删除事实(级联删除相关边)。 func (db *DB) DeleteProjectFact(id string) error { - _, err := db.Exec(`DELETE FROM project_facts WHERE id = ?`, id) + f, err := db.GetProjectFact(id) + if err != nil { + return err + } + if err := db.DeleteProjectFactEdgesForKey(f.ProjectID, f.FactKey); err != nil { + return err + } + _, err = db.Exec(`DELETE FROM project_facts WHERE id = ?`, id) return err } diff --git a/internal/database/project_fact_edges.go b/internal/database/project_fact_edges.go new file mode 100644 index 00000000..9b2342c0 --- /dev/null +++ b/internal/database/project_fact_edges.go @@ -0,0 +1,410 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// ValidProjectFactEdgeTypes 项目事实图允许的边类型。 +var ValidProjectFactEdgeTypes = map[string]struct{}{ + "depends_on": {}, + "leads_to": {}, + "enables": {}, + "exploits": {}, + "discovered_on": {}, + "contains": {}, + "part_of": {}, + "supports": {}, +} + +// ProjectFactEdge 项目事实关系边(source → target)。 +type ProjectFactEdge struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + SourceFactKey string `json:"source_fact_key"` + TargetFactKey string `json:"target_fact_key"` + EdgeType string `json:"edge_type"` + Confidence string `json:"confidence"` // confirmed | tentative | deprecated + SourceConversationID string `json:"source_conversation_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ProjectFactEdgeInput 写入边时的输入(出边:source → To)。 +type ProjectFactEdgeInput struct { + To string `json:"to"` + Type string `json:"type"` + Confidence string `json:"confidence,omitempty"` +} + +// ProjectFactEdgeFromInput 写入入边时的输入(From → 当前事实)。 +type ProjectFactEdgeFromInput struct { + From string `json:"from"` + Type string `json:"type"` + Confidence string `json:"confidence,omitempty"` +} + +// ProjectFactGraphNode 图 API 节点。 +type ProjectFactGraphNode struct { + ID string `json:"id"` + FactKey string `json:"fact_key"` + Category string `json:"category"` + Label string `json:"label"` // 图节点短标签(截断) + Summary string `json:"summary"` // 完整摘要(侧栏等详情用) + Confidence string `json:"confidence"` + Type string `json:"type"` + Pinned bool `json:"pinned"` +} + +// ProjectFactGraphEdge 图 API 边。 +type ProjectFactGraphEdge struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` + Type string `json:"type"` + Confidence string `json:"confidence"` +} + +// ProjectFactGraph 项目事实图。 +type ProjectFactGraph struct { + Nodes []ProjectFactGraphNode `json:"nodes"` + Edges []ProjectFactGraphEdge `json:"edges"` +} + +// ValidateProjectFactEdgeType 校验边类型。 +func ValidateProjectFactEdgeType(edgeType string) error { + edgeType = strings.TrimSpace(strings.ToLower(edgeType)) + if edgeType == "" { + return fmt.Errorf("edge type 不能为空") + } + if _, ok := ValidProjectFactEdgeTypes[edgeType]; !ok { + return fmt.Errorf("无效的 edge type: %s", edgeType) + } + return nil +} + +func normalizeEdgeConfidence(confidence string) string { + confidence = strings.TrimSpace(strings.ToLower(confidence)) + switch confidence { + case "confirmed", "deprecated": + return confidence + default: + return "tentative" + } +} + +// ListProjectFactEdgesByProject 列出项目全部边。 +func (db *DB) ListProjectFactEdgesByProject(projectID string) ([]*ProjectFactEdge, error) { + rows, err := db.Query( + `SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + COALESCE(source_conversation_id,''), created_at, updated_at + FROM project_fact_edges + WHERE project_id = ? + ORDER BY created_at ASC, rowid ASC`, + projectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProjectFactEdges(rows) +} + +// ListOutgoingProjectFactEdges 列出某事实的全部出边。 +func (db *DB) ListOutgoingProjectFactEdges(projectID, sourceFactKey string) ([]*ProjectFactEdge, error) { + rows, err := db.Query( + `SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + COALESCE(source_conversation_id,''), created_at, updated_at + FROM project_fact_edges + WHERE project_id = ? AND source_fact_key = ? + ORDER BY created_at ASC, rowid ASC`, + projectID, sourceFactKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProjectFactEdges(rows) +} + +// ListIncomingProjectFactEdges 列出某事实的全部入边。 +func (db *DB) ListIncomingProjectFactEdges(projectID, targetFactKey string) ([]*ProjectFactEdge, error) { + rows, err := db.Query( + `SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + COALESCE(source_conversation_id,''), created_at, updated_at + FROM project_fact_edges + WHERE project_id = ? AND target_fact_key = ? + ORDER BY created_at ASC, rowid ASC`, + projectID, targetFactKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProjectFactEdges(rows) +} + +// ReplaceOutgoingProjectFactEdges 替换某事实的全部出边(links 省略时不调用)。 +func (db *DB) ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID string, inputs []ProjectFactEdgeInput) error { + sourceFactKey = strings.TrimSpace(sourceFactKey) + if sourceFactKey == "" { + return fmt.Errorf("source_fact_key 不能为空") + } + if _, err := db.Exec( + `DELETE FROM project_fact_edges WHERE project_id = ? AND source_fact_key = ?`, + projectID, sourceFactKey, + ); err != nil { + return fmt.Errorf("清除旧边失败: %w", err) + } + for _, in := range inputs { + target := strings.TrimSpace(in.To) + if target == "" { + continue + } + if err := ValidateFactKey(target); err != nil { + return fmt.Errorf("target fact_key 无效 (%s): %w", target, err) + } + if target == sourceFactKey { + return fmt.Errorf("边不能指向自身: %s", sourceFactKey) + } + if err := ValidateProjectFactEdgeType(in.Type); err != nil { + return err + } + edge := &ProjectFactEdge{ + ID: uuid.New().String(), + ProjectID: projectID, + SourceFactKey: sourceFactKey, + TargetFactKey: target, + EdgeType: strings.ToLower(strings.TrimSpace(in.Type)), + Confidence: normalizeEdgeConfidence(in.Confidence), + SourceConversationID: sourceConversationID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := db.insertProjectFactEdge(edge); err != nil { + return err + } + } + return nil +} + +// ReplaceIncomingProjectFactEdges 替换某事实的全部入边(From 为来源 fact_key)。 +func (db *DB) ReplaceIncomingProjectFactEdges(projectID, targetFactKey string, inputs []ProjectFactEdgeFromInput) error { + targetFactKey = strings.TrimSpace(targetFactKey) + if targetFactKey == "" { + return fmt.Errorf("target_fact_key 不能为空") + } + if _, err := db.Exec( + `DELETE FROM project_fact_edges WHERE project_id = ? AND target_fact_key = ?`, + projectID, targetFactKey, + ); err != nil { + return fmt.Errorf("清除旧入边失败: %w", err) + } + for _, in := range inputs { + source := strings.TrimSpace(in.From) + if source == "" { + continue + } + if err := ValidateFactKey(source); err != nil { + return fmt.Errorf("source fact_key 无效 (%s): %w", source, err) + } + if source == targetFactKey { + return fmt.Errorf("边不能指向自身: %s", targetFactKey) + } + if err := ValidateProjectFactEdgeType(in.Type); err != nil { + return err + } + sourceConversationID := "" + if srcFact, err := db.GetProjectFactByKey(projectID, source); err == nil && srcFact != nil { + sourceConversationID = srcFact.SourceConversationID + } + edge := &ProjectFactEdge{ + ID: uuid.New().String(), + ProjectID: projectID, + SourceFactKey: source, + TargetFactKey: targetFactKey, + EdgeType: strings.ToLower(strings.TrimSpace(in.Type)), + Confidence: normalizeEdgeConfidence(in.Confidence), + SourceConversationID: sourceConversationID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := db.insertProjectFactEdge(edge); err != nil { + return err + } + } + return nil +} + +// GetProjectFactEdge 按 ID 获取边。 +func (db *DB) GetProjectFactEdge(edgeID string) (*ProjectFactEdge, error) { + var e ProjectFactEdge + var createdAt, updatedAt string + err := db.QueryRow( + `SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + COALESCE(source_conversation_id,''), created_at, updated_at + FROM project_fact_edges WHERE id = ?`, edgeID, + ).Scan(&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence, + &e.SourceConversationID, &createdAt, &updatedAt) + if err != nil { + return nil, fmt.Errorf("边不存在") + } + e.CreatedAt = parseDBTime(createdAt) + e.UpdatedAt = parseDBTime(updatedAt) + return &e, nil +} + +// AddProjectFactEdge 新增单条边(已存在则更新 confidence)。 +func (db *DB) AddProjectFactEdge(projectID string, in ProjectFactEdgeInput, sourceFactKey, sourceConversationID string) (*ProjectFactEdge, error) { + sourceFactKey = strings.TrimSpace(sourceFactKey) + target := strings.TrimSpace(in.To) + if sourceFactKey == "" || target == "" { + return nil, fmt.Errorf("source 与 target 必填") + } + if sourceFactKey == target { + return nil, fmt.Errorf("边不能指向自身") + } + if err := ValidateProjectFactEdgeType(in.Type); err != nil { + return nil, err + } + if err := ValidateFactKey(target); err != nil { + return nil, err + } + now := time.Now() + e := &ProjectFactEdge{ + ID: uuid.New().String(), + ProjectID: projectID, + SourceFactKey: sourceFactKey, + TargetFactKey: target, + EdgeType: strings.ToLower(strings.TrimSpace(in.Type)), + Confidence: normalizeEdgeConfidence(in.Confidence), + SourceConversationID: sourceConversationID, + CreatedAt: now, + UpdatedAt: now, + } + _, err := db.Exec( + `INSERT INTO project_fact_edges ( + id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + source_conversation_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, source_fact_key, target_fact_key, edge_type) + DO UPDATE SET confidence = excluded.confidence, updated_at = excluded.updated_at`, + e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence, + nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("添加边失败: %w", err) + } + // 返回最新 + rows, err := db.Query( + `SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + COALESCE(source_conversation_id,''), created_at, updated_at + FROM project_fact_edges + WHERE project_id = ? AND source_fact_key = ? AND target_fact_key = ? AND edge_type = ?`, + projectID, sourceFactKey, target, e.EdgeType, + ) + if err != nil { + return e, nil + } + defer rows.Close() + list, err := scanProjectFactEdges(rows) + if err != nil || len(list) == 0 { + return e, nil + } + return list[0], nil +} + +// DeleteProjectFactEdge 删除单条边。 +func (db *DB) DeleteProjectFactEdge(edgeID string) error { + res, err := db.Exec(`DELETE FROM project_fact_edges WHERE id = ?`, edgeID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("边不存在") + } + return nil +} + +func (db *DB) insertProjectFactEdge(e *ProjectFactEdge) error { + _, err := db.Exec( + `INSERT INTO project_fact_edges ( + id, project_id, source_fact_key, target_fact_key, edge_type, confidence, + source_conversation_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence, + nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("写入边失败: %w", err) + } + return nil +} + +// RenameProjectFactKeyEdges 事实 key 变更时同步边上的引用。 +func (db *DB) RenameProjectFactKeyEdges(projectID, oldKey, newKey string) error { + oldKey = strings.TrimSpace(oldKey) + newKey = strings.TrimSpace(newKey) + if oldKey == "" || newKey == "" || oldKey == newKey { + return nil + } + now := time.Now() + if _, err := db.Exec( + `UPDATE project_fact_edges SET source_fact_key = ?, updated_at = ? + WHERE project_id = ? AND source_fact_key = ?`, + newKey, now, projectID, oldKey, + ); err != nil { + return err + } + _, err := db.Exec( + `UPDATE project_fact_edges SET target_fact_key = ?, updated_at = ? + WHERE project_id = ? AND target_fact_key = ?`, + newKey, now, projectID, oldKey, + ) + return err +} + +// DeleteProjectFactEdgesForKey 删除与某 fact_key 相关的全部边。 +func (db *DB) DeleteProjectFactEdgesForKey(projectID, factKey string) error { + _, err := db.Exec( + `DELETE FROM project_fact_edges + WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)`, + projectID, factKey, factKey, + ) + return err +} + +// DeprecateProjectFactEdgesForKey 将关联边标记为 deprecated。 +func (db *DB) DeprecateProjectFactEdgesForKey(projectID, factKey string) error { + now := time.Now() + _, err := db.Exec( + `UPDATE project_fact_edges SET confidence = 'deprecated', updated_at = ? + WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?) + AND confidence != 'deprecated'`, + now, projectID, factKey, factKey, + ) + return err +} + +func scanProjectFactEdges(rows *sql.Rows) ([]*ProjectFactEdge, error) { + var out []*ProjectFactEdge + for rows.Next() { + var e ProjectFactEdge + var createdAt, updatedAt string + if err := rows.Scan( + &e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence, + &e.SourceConversationID, &createdAt, &updatedAt, + ); err != nil { + return nil, err + } + e.CreatedAt = parseDBTime(createdAt) + e.UpdatedAt = parseDBTime(updatedAt) + out = append(out, &e) + } + return out, rows.Err() +}