fix(graph-cleanup): CleanupNoiseNodes NULL-id panic + 新增 scoped 卫安/A03/A04 测试污染清理端点 (t_1dfaaa4a)

- 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 的官方通道
This commit is contained in:
小唯 2026-09-08 09:02:35 +08:00
parent 4714efaab9
commit 3872294045
3 changed files with 124 additions and 2 deletions

View File

@ -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 == "" {

View File

@ -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)
// 防 panicid 为 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{}{}

View File

@ -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)