| New file |
| | |
| | | # kb-cli tag/entity 边扩展实施计划 |
| | | |
| | | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| | | |
| | | **Goal:** 让 tag/entity 虚拟节点落库为真实节点,边数从 138 提升到 2500+,graph related 的共同标签关联真正可用,且不破坏现有 search 行为。 |
| | | |
| | | **Architecture:** graph builder 把 tag/entity 虚拟节点改为带命名空间合成 path(`tag:<名>`/`entity:<名>`)的真实节点;nodes 表增加 node_type 列区分 file/tag/entity;所有面向用户的查询(FTS/关键词/related/gc)过滤 node_type='file'。 |
| | | |
| | | **Tech Stack:** Go + Cobra + SQLite (mattn/go-sqlite3) + FTS5 |
| | | |
| | | **Spec:** `docs/superpowers/specs/2026-08-21-kb-cli-tag-entity-edges-design.md` |
| | | |
| | | ## Global Constraints |
| | | |
| | | - 仓库:`/home/aisim-p/workspace/kb-cli`,module `github.com/aisim/kb-cli` |
| | | - 编译二进制到 `~/go/bin/kb-cli`:`go build -o /home/aisim-p/go/bin/kb-cli .` |
| | | - 每个 task 完成必须 `go build ./... && go test ./...` 通过后才 commit |
| | | - search 行为不得变化:FTS 表、FindNodesByKeyword、FindRelatedNodes 结果中不得出现 tag/entity 节点 |
| | | - 不修改 wikilink 匹配逻辑(matchesWikilink 保持精确匹配) |
| | | - 知识库 vault 路径:`/home/aisim-p/aisim/note/001/笔记001`(E2E 验证用) |
| | | |
| | | --- |
| | | |
| | | ### Task 1: graph 层——虚拟节点落库 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/graph/model.go`(Node 增加 NodeType 字段) |
| | | - Modify: `internal/graph/builder.go`(BuildGraph 生成 tag/entity 虚拟节点) |
| | | - Test: `internal/graph/builder_test.go`(更新断言) |
| | | |
| | | **Interfaces:** |
| | | - Produces: `graph.Node.NodeType string`(`""` 或 `"file"`/`"tag"`/`"entity"`);`g.Nodes` 包含全部真实节点(文件 + tag + entity),`g.Edges` 全部边可写入(无未映射节点) |
| | | |
| | | - [ ] **Step 1: 更新 builder_test.go 增加失败测试** |
| | | |
| | | 在 `TestBuildGraph` 中修改断言:文件节点 2 个 + tag 节点 2 个("充装"、"智能枪","充装"去重)+ entity 节点 1 个("YSP-35.5")= 5 个节点;tag 边 3 条、entity 边 1 条(不变): |
| | | |
| | | ```go |
| | | g := BuildGraph(files) |
| | | |
| | | // 2 个文件节点 + 2 个 tag 虚拟节点(充装、智能枪)+ 1 个 entity 虚拟节点 |
| | | if len(g.Nodes) != 5 { |
| | | t.Errorf("node count = %d, want 5", len(g.Nodes)) |
| | | } |
| | | |
| | | // 验证虚拟节点 path 命名空间与 NodeType |
| | | tagCount, entityCount, fileCount := 0, 0, 0 |
| | | for _, n := range g.Nodes { |
| | | switch n.NodeType { |
| | | case "tag": |
| | | tagCount++ |
| | | if !strings.HasPrefix(n.Path, "tag:") { |
| | | t.Errorf("tag node path = %q, want prefix tag:", n.Path) |
| | | } |
| | | case "entity": |
| | | entityCount++ |
| | | if !strings.HasPrefix(n.Path, "entity:") { |
| | | t.Errorf("entity node path = %q, want prefix entity:", n.Path) |
| | | } |
| | | case "file": |
| | | fileCount++ |
| | | default: |
| | | t.Errorf("unexpected node_type %q for %s", n.NodeType, n.Path) |
| | | } |
| | | } |
| | | if tagCount != 2 || entityCount != 1 || fileCount != 2 { |
| | | t.Errorf("file/tag/entity = %d/%d/%d, want 2/2/1", fileCount, tagCount, entityCount) |
| | | } |
| | | ``` |
| | | |
| | | 同时保留原有边数断言(tag 边 3、entity 边 1)。import 增加 `"strings"`。 |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | Run: `cd /home/aisim-p/workspace/kb-cli && go test ./internal/graph/ -run TestBuildGraph -v` |
| | | Expected: 编译失败(Node 无 NodeType 字段) |
| | | |
| | | - [ ] **Step 3: 实现——model.go 增加 NodeType** |
| | | |
| | | `internal/graph/model.go` 的 Node 结构体增加字段: |
| | | |
| | | ```go |
| | | type Node struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | NodeType string `json:"node_type"` // "file" | "tag" | "entity" |
| | | Tags []string `json:"tags"` |
| | | Entities []string `json:"entities"` |
| | | Wikilinks []string `json:"wikilinks"` |
| | | Content string `json:"content"` |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 实现——builder.go 虚拟节点入 Nodes** |
| | | |
| | | `internal/graph/builder.go` 的 `getOrCreateVirtualNode` 改为把虚拟节点追加进 `g.Nodes`: |
| | | |
| | | ```go |
| | | getOrCreateVirtualNode := func(label, nodeType string) int64 { |
| | | if id, ok := labelToID[label]; ok { |
| | | return id |
| | | } |
| | | id := nextVirtualID |
| | | nextVirtualID++ |
| | | labelToID[label] = id |
| | | g.Nodes = append(g.Nodes, &Node{ |
| | | ID: id, |
| | | Path: label, // label 已含 "tag:"/"entity:" 前缀 |
| | | Title: strings.TrimPrefix(strings.TrimPrefix(label, "tag:"), "entity:"), |
| | | NodeType: nodeType, |
| | | }) |
| | | return id |
| | | } |
| | | ``` |
| | | |
| | | 两处调用改为: |
| | | |
| | | ```go |
| | | // tag 边 |
| | | for _, tag := range f.Tags { |
| | | virtualID := getOrCreateVirtualNode("tag:"+tag, "tag") |
| | | g.Edges = append(g.Edges, &Edge{FromNode: node.ID, ToNode: virtualID, Relation: "tag", Label: tag}) |
| | | } |
| | | // entity 边 |
| | | for _, entity := range f.Entities { |
| | | virtualID := getOrCreateVirtualNode("entity:"+entity, "entity") |
| | | g.Edges = append(g.Edges, &Edge{FromNode: node.ID, ToNode: virtualID, Relation: "entity", Label: entity}) |
| | | } |
| | | ``` |
| | | |
| | | 文件节点创建处增加 `NodeType: "file"`。 |
| | | |
| | | - [ ] **Step 5: 运行测试确认通过** |
| | | |
| | | Run: `go test ./internal/graph/ -v` |
| | | Expected: PASS |
| | | |
| | | - [ ] **Step 6: 编译 + 全量测试 + commit** |
| | | |
| | | ```bash |
| | | cd /home/aisim-p/workspace/kb-cli |
| | | go build ./... && go test ./... |
| | | git add internal/graph/ |
| | | git commit -m "feat(graph): tag/entity 虚拟节点落库为真实节点(合成 path 命名空间)" |
| | | ``` |
| | | |
| | | 注意:此 commit 后 `kb-cli index build` 会因 InsertNode 未写 node_type 列而行为不变(列还不存在),但 `go test ./internal/index/` 必须仍通过(sqlite_test 的 InsertEdge 用 ToNode=1000000 无外键约束,不受影响)。 |
| | | |
| | | --- |
| | | |
| | | ### Task 2: index 层——schema 迁移 + 查询过滤 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/index/sqlite.go`(schema、迁移、InsertNode、GetAllNodes、FindNodesByKeyword、FindRelatedNodes) |
| | | - Test: `internal/index/sqlite_test.go`(新增测试) |
| | | |
| | | **Interfaces:** |
| | | - Consumes: `graph.Node.NodeType`(Task 1) |
| | | - Produces: nodes 表 `node_type` 列(默认 'file');所有文件查询过滤 `node_type='file'`(GetAllNodes/FindNodesByKeyword/FindRelatedNodes) |
| | | |
| | | - [ ] **Step 1: 写失败测试(新增到 sqlite_test.go)** |
| | | |
| | | ```go |
| | | func TestNodeTypeSeparation(t *testing.T) { |
| | | tmpDir := t.TempDir() |
| | | store, err := Open(filepath.Join(tmpDir, "test.db")) |
| | | if err != nil { |
| | | t.Fatalf("Open failed: %v", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 插入文件节点 + tag 节点 |
| | | fileID, err := store.InsertNode(&graph.Node{ |
| | | Path: "FAQ/001-测试.md", Title: "测试文档", Section: "FAQ", NodeType: "file", |
| | | Tags: []string{"电磁阀"}, |
| | | }) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode file failed: %v", err) |
| | | } |
| | | tagID, err := store.InsertNode(&graph.Node{ |
| | | Path: "tag:电磁阀", Title: "电磁阀", NodeType: "tag", |
| | | }) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode tag failed: %v", err) |
| | | } |
| | | |
| | | // tag 节点不进 FTS |
| | | if err := store.CreateFTS(); err != nil { |
| | | t.Fatalf("CreateFTS failed: %v", err) |
| | | } |
| | | if err := store.PopulateFTS(); err != nil { |
| | | t.Fatalf("PopulateFTS failed: %v", err) |
| | | } |
| | | res, err := store.FTSSearch([]string{"测试"}, 10) |
| | | if err != nil { |
| | | t.Fatalf("FTSSearch failed: %v", err) |
| | | } |
| | | for _, r := range res { |
| | | if r.ID == tagID { |
| | | t.Error("FTS result contains tag node") |
| | | } |
| | | } |
| | | |
| | | // FindNodesByKeyword 不返回 tag 节点 |
| | | nodes, err := store.FindNodesByKeyword("电磁阀") |
| | | if err != nil { |
| | | t.Fatalf("FindNodesByKeyword failed: %v", err) |
| | | } |
| | | for _, n := range nodes { |
| | | if n.ID == tagID { |
| | | t.Error("FindNodesByKeyword returned tag node") |
| | | } |
| | | } |
| | | |
| | | // GetAllNodes 只返回 file 节点 |
| | | all, err := store.GetAllNodes() |
| | | if err != nil { |
| | | t.Fatalf("GetAllNodes failed: %v", err) |
| | | } |
| | | for _, n := range all { |
| | | if n.ID == tagID { |
| | | t.Error("GetAllNodes returned tag node") |
| | | } |
| | | } |
| | | _ = fileID |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | Run: `go test ./internal/index/ -run TestNodeTypeSeparation -v` |
| | | Expected: FAIL(tag 节点被查询返回 / GetAllNodes 包含 tag) |
| | | |
| | | - [ ] **Step 3: 实现 schema + 迁移** |
| | | |
| | | `initTables` 的 nodes 表定义增加列: |
| | | |
| | | ```sql |
| | | CREATE TABLE IF NOT EXISTS nodes ( |
| | | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | path TEXT NOT NULL UNIQUE, |
| | | node_type TEXT NOT NULL DEFAULT 'file', |
| | | title TEXT, |
| | | section TEXT, |
| | | tags TEXT, |
| | | entities TEXT, |
| | | wikilinks TEXT, |
| | | content_fts TEXT, |
| | | created_at TEXT DEFAULT (datetime('now')), |
| | | updated_at TEXT DEFAULT (datetime('now')) |
| | | ); |
| | | ``` |
| | | |
| | | `initTables` 末尾增加旧库迁移(幂等): |
| | | |
| | | ```go |
| | | // 旧库迁移:补 node_type 列 |
| | | var colExists bool |
| | | err := s.db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'node_type'").Scan(&colExists) |
| | | if err == nil && !colExists { |
| | | if _, err := s.db.Exec("ALTER TABLE nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'file'"); err != nil { |
| | | return fmt.Errorf("迁移 node_type 列失败: %w", err) |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 实现——InsertNode 写入 node_type** |
| | | |
| | | ```go |
| | | result, err := s.db.Exec(` |
| | | INSERT INTO nodes (path, node_type, title, section, tags, entities, wikilinks, content_fts) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| | | `, n.Path, nodeTypeOf(n), n.Title, n.Section, string(tagsJSON), string(entitiesJSON), |
| | | string(wikilinksJSON), n.Content) |
| | | ``` |
| | | |
| | | 新增辅助函数: |
| | | |
| | | ```go |
| | | func nodeTypeOf(n *graph.Node) string { |
| | | if n.NodeType == "" { |
| | | return "file" |
| | | } |
| | | return n.NodeType |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 5: 实现——查询过滤** |
| | | |
| | | 1. `GetAllNodes`:SQL 改为 `SELECT id, path FROM nodes WHERE node_type = 'file'`(NodeInfo 结构不变,仍只返回 id/path)。 |
| | | 2. `FindNodesByKeyword`:WHERE 增加 `AND node_type = 'file'`。 |
| | | 3. `FindRelatedNodes`:种子查询 WHERE 增加 `AND node_type = 'file'`;"通过标签查找"/"通过实体查找"两段 SQL 各增加 `AND node_type = 'file'`。 |
| | | |
| | | - [ ] **Step 6: 运行测试确认通过** |
| | | |
| | | Run: `go test ./internal/index/ -v` |
| | | Expected: 全部 PASS(含原有 TestStore、TestGetNodeLinks) |
| | | |
| | | - [ ] **Step 7: 编译 + 全量测试 + commit** |
| | | |
| | | ```bash |
| | | cd /home/aisim-p/workspace/kb-cli |
| | | go build ./... && go test ./... |
| | | git add internal/index/ |
| | | git commit -m "feat(index): nodes 表增加 node_type 列,查询过滤虚拟节点" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ### Task 3: FTS 过滤 + E2E 验证 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/index/fts.go`(PopulateFTS 过滤) |
| | | - Test: `internal/index/sqlite_test.go`(Task 2 的 TestNodeTypeSeparation 已覆盖 FTS 断言) |
| | | |
| | | **Interfaces:** |
| | | - Consumes: nodes.node_type(Task 2) |
| | | - Produces: nodes_fts 仅含 file 节点;真实 vault 索引边数 ≥ 2000 |
| | | |
| | | - [ ] **Step 1: 实现 PopulateFTS 过滤** |
| | | |
| | | `internal/index/fts.go`: |
| | | |
| | | ```go |
| | | func (s *Store) PopulateFTS() error { |
| | | _, err := s.db.Exec(` |
| | | INSERT INTO nodes_fts(rowid, title, content, tags, entities) |
| | | SELECT id, title, content_fts, tags, entities FROM nodes |
| | | WHERE node_type = 'file' |
| | | `) |
| | | return err |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 全量测试** |
| | | |
| | | Run: `cd /home/aisim-p/workspace/kb-cli && go build ./... && go test ./...` |
| | | Expected: 全部 PASS |
| | | |
| | | - [ ] **Step 3: 编译二进制到 ~/go/bin/kb-cli** |
| | | |
| | | ```bash |
| | | go build -o /home/aisim-p/go/bin/kb-cli . |
| | | ``` |
| | | |
| | | - [ ] **Step 4: E2E——重建真实 vault 索引并验证** |
| | | |
| | | ```bash |
| | | source ~/.hermes/.env && export KB_VAULT |
| | | kb-cli index build |
| | | kb-cli index status |
| | | kb-cli graph stats |
| | | ``` |
| | | |
| | | 验证点: |
| | | - 边数 ≥ 2000(tag + entity + wikilink) |
| | | - 关系分布出现 tag/entity/wikilink 三类 |
| | | - `kb-cli search "电磁阀" --top 3` 结果中不出现 `tag:`/`entity:` 路径的节点 |
| | | - `kb-cli graph query "电磁阀"` 能列出 tag 关联 |
| | | |
| | | - [ ] **Step 5: E2E——graph related 共同标签关联验证** |
| | | |
| | | ```bash |
| | | kb-cli graph related "电磁阀" --top 5 |
| | | ``` |
| | | |
| | | Expected: 返回共享 tag 的文档(关联度 > 0),且不含 tag 节点本身。 |
| | | |
| | | - [ ] **Step 6: commit** |
| | | |
| | | ```bash |
| | | cd /home/aisim-p/workspace/kb-cli |
| | | git add internal/index/fts.go |
| | | git commit -m "feat(fts): FTS 表仅收录 file 节点" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 验收标准(全部满足才算完成) |
| | | |
| | | 1. `go test ./...` 全绿 |
| | | 2. 真实 vault `kb-cli index build` 后边数 ≥ 2000 |
| | | 3. `kb-cli search` 结果与改动前一致(不出现 tag/entity 节点) |
| | | 4. `kb-cli graph related` 能通过共同 tag 找到相关文档 |
| | | 5. 二进制已部署到 `~/go/bin/kb-cli` |