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:
parent
4714efaab9
commit
3872294045
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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{}{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue