diff --git a/go/internal/api/routes/graph.go b/go/internal/api/routes/graph.go index 13539da..731fc30 100644 --- a/go/internal/api/routes/graph.go +++ b/go/internal/api/routes/graph.go @@ -69,6 +69,7 @@ func (ga *GraphAPI) Query(w http.ResponseWriter, r *http.Request) { // 1. 单实体 BFS: {"entity": "...", "max_hops": 2} // 2. 双向 BFS: {"source": "...", "target": "...", "max_hops": 3} // 3. 关系过滤 BFS: {"entity": "...", "max_hops": 2, "relation_filter": ["related_to", "uses"]}(E1.5) +// 返回新增 grouped_by_relation 字段,按关系类型分组,便于阅读 func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) { var req struct { Entity string `json:"entity"` @@ -114,12 +115,53 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) { respondError(w, 500, "navigate failed: "+err.Error()) return } + + // 新增:按 relation 分组,更直观 + grouped := make(map[string][]map[string]interface{}) + for _, p := range paths { + rel := p["relation"].(string) + if rel == "" { + rel = "_unknown" + } + grouped[rel] = append(grouped[rel], map[string]interface{}{ + "from": stripPrefix(p["from"].(string)), + "to": stripPrefix(p["to"].(string)), + "weight": p["weight"], + "hop": p["hop"], + }) + } + + // 新增:相关实体建议(从 path 提取去重的 to 节点,跳过 ep_ 开头的) + suggestions := []string{} + seen := make(map[string]bool) + for _, p := range paths { + to := stripPrefix(p["to"].(string)) + if !seen[to] && !strings.HasPrefix(p["to"].(string), "ep_") { + seen[to] = true + suggestions = append(suggestions, to) + } + } + if len(suggestions) > 20 { + suggestions = suggestions[:20] + } + respond(w, 200, map[string]interface{}{ - "paths": paths, "entity": req.Entity, "count": len(paths), - "relation_filter": req.RelationFilter, + "paths": paths, + "grouped_by_relation": grouped, + "entity": req.Entity, + "normalized_entity": entity, + "count": len(paths), + "relation_count": len(grouped), + "suggestions": suggestions, + "relation_filter": req.RelationFilter, }) } +// stripPrefix 去掉节点 ID 的 n_ 前缀,用于可读展示 +func stripPrefix(id string) string { + return strings.TrimPrefix(id, "n_") +} + // normalizeEntity 规整实体名:去特殊字符 + n_前缀,保留中文和 Unicode 字符 func normalizeEntity(entity string) string { if strings.HasPrefix(entity, "n_") { diff --git a/go/internal/api/server.go b/go/internal/api/server.go index 0880ce7..7d2bdd5 100644 --- a/go/internal/api/server.go +++ b/go/internal/api/server.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "runtime" + "sort" "strconv" "strings" "time" @@ -416,6 +417,131 @@ func NewServer() http.Handler { }) }) + // 自然语言图谱查询 /api/v1/graph/nl_query + // 输入: {"query": "织忆和牧尘是什么关系"} + // 返回: 自然语言关系描述 + mux.HandleFunc("/api/v1/graph/nl_query", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var req struct { + Query string `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + query := strings.TrimSpace(req.Query) + if query == "" { + http.Error(w, "query is required", http.StatusBadRequest) + return + } + + // 1. 从 query 中提取可能的实体名(去掉问号、语气词) + cleanQuery := strings.TrimSuffix(strings.TrimSuffix(query, "?"), "?") + cleanQuery = strings.TrimPrefix(cleanQuery, "请问") + cleanQuery = strings.TrimPrefix(cleanQuery, "请说") + cleanQuery = strings.TrimSpace(cleanQuery) + + // 2. 尝试识别实体对("A和B的关系"、"A与B") + var entityA, entityB string + // 模式1: "A和B是什么关系" / "A和B有什么关系" / "A和B的关系" + hasAnd := strings.Contains(cleanQuery, "和") || strings.Contains(cleanQuery, "与") + if hasAnd && (strings.Contains(cleanQuery, "关系") || strings.Contains(cleanQuery, "关系")) { + delim := "和" + if strings.Contains(cleanQuery, "与") { + delim = "与" + } + parts := strings.Split(cleanQuery, delim) + if len(parts) == 2 { + entityA = strings.TrimSpace(parts[0]) + rawB := strings.TrimSpace(parts[1]) + // 去掉句末的问句模式得到 entityB + entityB = strings.TrimSuffix(rawB, "是什么关系") + entityB = strings.TrimSuffix(entityB, "有什么关系") + entityB = strings.TrimSuffix(entityB, "的") + entityB = strings.TrimSuffix(entityB, "是什么") + entityB = strings.TrimSpace(entityB) + // 如果去掉问句后为空(如"织忆和牧尘" -> entityB=""),则原始 query 可能是"A和B" + if entityB == "" { + entityB = rawB + } + } + } + + var result map[string]interface{} + if entityA != "" && entityB != "" { + // 双向查询:A → B 的关系 + paths, _ := graphStore.Navigate(normalizeEntity(entityA), 2, "", nil) + relB, _ := graphStore.Navigate(normalizeEntity(entityB), 2, "", nil) + // 找 A → B 的直接边 + var directPath map[string]interface{} + for _, p := range paths { + if strings.TrimPrefix(p["to"].(string), "n_") == normalizeEntity(entityB) { + directPath = p + break + } + } + result = map[string]interface{}{ + "query": query, + "entity_a": entityA, + "entity_b": entityB, + "direct_path": directPath, + "paths_from_a": len(paths), + "paths_from_b": len(relB), + "summary": fmt.Sprintf("%s 与 %s 存在 %d 条关联路径", entityA, entityB, len(paths)), + } + } else { + // 单实体查询:找 entity 的关系网 + entity := cleanQuery + paths, _ := graphStore.Navigate(normalizeEntity(entity), 2, "", nil) + // 按 relation 分组 + relCounts := make(map[string]int) + for _, p := range paths { + rel := p["relation"].(string) + if rel == "" { + rel = "unknown" + } + relCounts[rel]++ + } + // 生成自然语言描述 + var rels []string + for rel, cnt := range relCounts { + rels = append(rels, fmt.Sprintf("%s(%d条)", rel, cnt)) + } + sort.Strings(rels) + result = map[string]interface{}{ + "query": query, + "entity": entity, + "total_paths": len(paths), + "relation_summary": rels, + "summary": fmt.Sprintf("%s 共有 %d 条关联,分布在 %d 种关系类型", entity, len(paths), len(relCounts)), + } + } + respondJSON(w, 200, result) + }) + + // 图谱脏数据清理 /api/v1/graph/cleanup + mux.HandleFunc("/api/v1/graph/cleanup", 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 + count, ids, err := graphStore.CleanupNoiseNodes(dryRun) + if err != nil { + http.Error(w, "cleanup failed: "+err.Error(), http.StatusInternalServerError) + return + } + respondJSON(w, 200, map[string]interface{}{ + "dry_run": dryRun, + "removed": count, + "node_ids": ids, + "message": fmt.Sprintf("Found %d noise nodes", count), + }) + }) + // 静态文件服务(知识图谱可视化 HTML) staticDir := os.Getenv("STATIC_DIR") if staticDir == "" { diff --git a/go/internal/governance/graph_file.go b/go/internal/governance/graph_file.go index b1e7c4f..8198e16 100644 --- a/go/internal/governance/graph_file.go +++ b/go/internal/governance/graph_file.go @@ -435,6 +435,13 @@ func (fg *FileGraph) Prune(minWeight float64) { fg.save() } +func (fg *FileGraph) CleanupNoiseNodes(dryRun bool) (int, []string, error) { + fg.mu.Lock() + defer fg.mu.Unlock() + // FileGraph 不需要脏数据清理(已迁移到 SQLite) + return 0, nil, nil +} + // ─── 图谱扩展 ──────────────────────────────────────────── func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult { diff --git a/go/internal/governance/graph_mem.go b/go/internal/governance/graph_mem.go index 1f20620..fdb4083 100644 --- a/go/internal/governance/graph_mem.go +++ b/go/internal/governance/graph_mem.go @@ -322,6 +322,13 @@ func (g *InMemoryGraph) Prune(minWeight float64) { } } +func (g *InMemoryGraph) CleanupNoiseNodes(dryRun bool) (int, []string, error) { + g.mu.Lock() + defer g.mu.Unlock() + // InMemoryGraph 不需要脏数据清理(测试用) + return 0, nil, nil +} + // Query 按实体和关系查询 func (g *InMemoryGraph) Query(entity, relation, namespace string) []map[string]interface{} { g.mu.RLock() diff --git a/go/internal/governance/graph_sqlite.go b/go/internal/governance/graph_sqlite.go index 037c626..ca5e148 100644 --- a/go/internal/governance/graph_sqlite.go +++ b/go/internal/governance/graph_sqlite.go @@ -549,6 +549,75 @@ func (gs *SQLiteGraphStore) Prune(minWeight float64) { execSQL(gs.db, `DELETE FROM graph_nodes WHERE id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`) } +// CleanupNoiseNodes 删除名称含编码噪音的节点(如 "n_fts=517," "n_fts(sqlite)+") +// dryRun=true 时只检查不删除,返回预检结果 +func (gs *SQLiteGraphStore) CleanupNoiseNodes(dryRun bool) (int, []string, error) { + gs.mu.Lock() + defer gs.mu.Unlock() + + // 噪音模式:节点名含 SQL 残片、编码错误符号 + noisePatterns := []string{ + "fts=", + "fts(", + "fts(", + ")", + "(sqlite", + "__", + } + + // 检查节点名是否含噪音 + findNoise := func(name string) bool { + for _, pat := range noisePatterns { + if strings.Contains(name, pat) { + return true + } + } + // 括号不匹配检测 + open := 0 + for _, ch := range name { + if ch == '(' || ch == '(' { + open++ + } else if ch == ')' || ch == ')' { + open-- + } + } + if open != 0 { + return true // 括号不匹配 + } + // 节点名含逗号/等号残片(如 "n_fts=517,") + if strings.HasSuffix(name, ",") || strings.HasSuffix(name, "=") { + return true + } + // 含 %23 %3D 等 URL 编码残留 + if strings.Contains(name, "%") { + return true + } + return false + } + + // 查询所有节点,找出噪音节点 + 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) + if findNoise(name) { + 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/governance/graph_store.go b/go/internal/governance/graph_store.go index 9989b2f..3427f46 100644 --- a/go/internal/governance/graph_store.go +++ b/go/internal/governance/graph_store.go @@ -40,4 +40,8 @@ type GraphStore interface { // 导出完整图谱(供可视化),limit≤0 时不限制 GetGraph(namespace string, limit int) (nodes []map[string]interface{}, edges []map[string]interface{}) + + // 清理图谱脏数据:删除名称含编码噪音的节点(如 fts=、括号不匹配等) + // 返回被删除的节点数和节点 ID 列表 + CleanupNoiseNodes(dryRun bool) (int, []string, error) }