From 387229404564e03ace3d3b97c5ba71ea36b31962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=94=AF?= Date: Tue, 8 Sep 2026 09:02:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(graph-cleanup):=20CleanupNoiseNodes=20NULL-?= =?UTF-8?q?id=20panic=20+=20=E6=96=B0=E5=A2=9E=20scoped=20=E5=8D=AB?= =?UTF-8?q?=E5=AE=89/A03/A04=20=E6=B5=8B=E8=AF=95=E6=B1=A1=E6=9F=93?= =?UTF-8?q?=E6=B8=85=E7=90=86=E7=AB=AF=E7=82=B9=20(t=5F1dfaaa4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph_sqlite.go CleanupNoiseNodes: row[id] 为 NULL(daemon-distill pattern 模板行)时直接 .(string) panic → HTTP 连接被关(HTTP=000);改为 nil 安全跳过 - 新增 CleanupScopedNodes(dryRun, namespaces, nameContains):namespace+名称子串精确圈定, 清理 2026-06 多 agent 测试污染(openclaw-main/a06-main/hermes-main 的 A03/A04/卫安 节点) - graph_cache.go: cachedGraphStore 透传(inner 可选接口断言,非 SQLite 后端静默 0) - server.go: GET/POST /api/v1/graph/cleanup/scoped?dry_run=&namespace=&name= 实际执行后 InvalidateAll 保证 graph_cache 一致性 —— 禁外部 SQL 的官方通道 --- go/internal/api/server.go | 45 +++++++++++++++++ go/internal/governance/graph_sqlite.go | 70 +++++++++++++++++++++++++- go/internal/storage/graph_cache.go | 11 ++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/go/internal/api/server.go b/go/internal/api/server.go index aba0b9c..dcfd4da 100644 --- a/go/internal/api/server.go +++ b/go/internal/api/server.go @@ -584,6 +584,51 @@ func NewServer() http.Handler { }) }) + // 图谱多 agent 测试污染清理(namespace + 名称子串圈定,不碰全表编码规则) + // 2026-09-08 卫安/A03/A04 371 节点:openclaw-main/a06-main/hermes-main 中测试 agent 产生的概念/事实节点 + // GET/POST /api/v1/graph/cleanup/scoped?dry_run=true&namespace=a,b,c&name=a03,a04,卫安 + mux.HandleFunc("/api/v1/graph/cleanup/scoped", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" && r.Method != "GET" { + http.Error(w, "GET or POST only", http.StatusMethodNotAllowed) + return + } + dryRun := r.URL.Query().Get("dry_run") != "false" // 默认为 dryRun + nsParam := r.URL.Query().Get("namespace") + nameParam := r.URL.Query().Get("name") + if nsParam == "" || nameParam == "" { + http.Error(w, "namespace and name (comma separated) are required", http.StatusBadRequest) + return + } + namespaces := strings.Split(nsParam, ",") + nameContains := strings.Split(nameParam, ",") + + type scopedCleaner interface { + CleanupScopedNodes(dryRun bool, namespaces, nameContains []string) (int, []string, error) + } + cleaner, ok := graphStore.(scopedCleaner) + if !ok { + http.Error(w, "scoped cleanup not supported by current graph backend", http.StatusNotImplemented) + return + } + count, ids, err := cleaner.CleanupScopedNodes(dryRun, namespaces, nameContains) + if err != nil { + http.Error(w, "cleanup failed: "+err.Error(), http.StatusInternalServerError) + return + } + // 实际执行后失效缓存(dry_run=true 时不执行,无需失效) + if !dryRun && cachedGraphStoreRef != nil && count > 0 { + cachedGraphStoreRef.InvalidateAll() + } + respondJSON(w, 200, map[string]interface{}{ + "dry_run": dryRun, + "removed": count, + "node_ids": ids, + "namespaces": namespaces, + "name_contains": nameContains, + "message": fmt.Sprintf("Found %d scoped noise nodes", count), + }) + }) + // 静态文件服务(知识图谱可视化 HTML) staticDir := os.Getenv("STATIC_DIR") if staticDir == "" { diff --git a/go/internal/governance/graph_sqlite.go b/go/internal/governance/graph_sqlite.go index 4d44609..4aea855 100644 --- a/go/internal/governance/graph_sqlite.go +++ b/go/internal/governance/graph_sqlite.go @@ -604,8 +604,13 @@ func (gs *SQLiteGraphStore) CleanupNoiseNodes(dryRun bool) (int, []string, error rows := queryRows(gs.db, "SELECT id, name FROM graph_nodes") var noiseIDs []string for _, row := range rows { - id := row["id"].(string) - name := row["name"].(string) + // 防 panic:id 为 NULL(如 daemon-distill 的 pattern 模板行,id 无值)时 + // row["id"] 是 nil interface,直接 .(string) 会 panic → 跳过(无 id 也无法删除) + id, _ := row["id"].(string) + if id == "" { + continue + } + name, _ := row["name"].(string) if findNoise(name) { noiseIDs = append(noiseIDs, id) } @@ -623,6 +628,67 @@ func (gs *SQLiteGraphStore) CleanupNoiseNodes(dryRun bool) (int, []string, error return len(noiseIDs), noiseIDs, nil } +// CleanupScopedNodes 删除指定 namespace 中名称含指定子串的噪音节点。 +// 与 CleanupNoiseNodes(编码噪音:fts=/括号不匹配等)不同,这里按 namespace + 名称子串精确圈定, +// 用于清理多 agent 测试污染(如 openclaw-main/a06-main/hermes-main 中 2026-06 测试 agent +// A03/A04/卫安 产生的概念/事实节点),避免全表编码规则误删真实节点。 +// namespace/nameContains 为空列表表示不限制(谨慎使用);nameContains 大小写不敏感。 +// dryRun=true 时只检查不删除,返回预检结果。 +func (gs *SQLiteGraphStore) CleanupScopedNodes(dryRun bool, namespaces, nameContains []string) (int, []string, error) { + gs.mu.Lock() + defer gs.mu.Unlock() + + nsSet := make(map[string]struct{}, len(namespaces)) + for _, ns := range namespaces { + if ns != "" { + nsSet[ns] = struct{}{} + } + } + patterns := make([]string, 0, len(nameContains)) + for _, p := range nameContains { + if p != "" { + patterns = append(patterns, strings.ToLower(p)) + } + } + + rows := queryRows(gs.db, "SELECT id, name, namespace FROM graph_nodes") + var noiseIDs []string + for _, row := range rows { + id, _ := row["id"].(string) + if id == "" { + continue // NULL id 行(pattern 模板)无法按 id 删除,跳过 + } + name, _ := row["name"].(string) + ns, _ := row["namespace"].(string) + if len(nsSet) > 0 { + if _, ok := nsSet[ns]; !ok { + continue + } + } + lower := strings.ToLower(name) + matched := false + for _, p := range patterns { + if strings.Contains(lower, p) { + matched = true + break + } + } + if matched { + noiseIDs = append(noiseIDs, id) + } + } + + if dryRun || len(noiseIDs) == 0 { + return len(noiseIDs), noiseIDs, nil + } + + for _, nid := range noiseIDs { + execSQL(gs.db, fmt.Sprintf("DELETE FROM graph_edges WHERE source = '%s' OR target = '%s'", escape(nid), escape(nid))) + execSQL(gs.db, fmt.Sprintf("DELETE FROM graph_nodes WHERE id = '%s'", escape(nid))) + } + return len(noiseIDs), noiseIDs, nil +} + func (gs *SQLiteGraphStore) GetGraph(namespace string, limit int) ([]map[string]interface{}, []map[string]interface{}) { nodes := []map[string]interface{}{} edges := []map[string]interface{}{} diff --git a/go/internal/storage/graph_cache.go b/go/internal/storage/graph_cache.go index d8d1e36..0edbb96 100644 --- a/go/internal/storage/graph_cache.go +++ b/go/internal/storage/graph_cache.go @@ -161,6 +161,17 @@ func (c *cachedGraphStore) CleanupNoiseNodes(dryRun bool) (int, []string, error) return c.inner.CleanupNoiseNodes(dryRun) } +// CleanupScopedNodes 透传(inner 不支持该能力时静默返回 0;生产后端 SQLiteGraphStore 已实现) +func (c *cachedGraphStore) CleanupScopedNodes(dryRun bool, namespaces, nameContains []string) (int, []string, error) { + type scoped interface { + CleanupScopedNodes(dryRun bool, namespaces, nameContains []string) (int, []string, error) + } + if s, ok := c.inner.(scoped); ok { + return s.CleanupScopedNodes(dryRun, namespaces, nameContains) + } + return 0, nil, nil +} + // P0: FallbackTextSearch 透传到内层 func (c *cachedGraphStore) FallbackTextSearch(query, namespace string, limit int) []map[string]interface{} { return c.inner.FallbackTextSearch(query, namespace, limit)