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
/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 .go build ./... && go test ./... 通过后才 commit/home/aisim-p/aisim/note/001/笔记001(E2E 验证用)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 全部边可写入(无未映射节点)
在 TestBuildGraph 中修改断言:文件节点 2 个 + tag 节点 2 个("充装"、"智能枪","充装"去重)+ entity 节点 1 个("YSP-35.5")= 5 个节点;tag 边 3 条、entity 边 1 条(不变):
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"。
Run: cd /home/aisim-p/workspace/kb-cli && go test ./internal/graph/ -run TestBuildGraph -v
Expected: 编译失败(Node 无 NodeType 字段)
internal/graph/model.go 的 Node 结构体增加字段:
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"`
}
internal/graph/builder.go 的 getOrCreateVirtualNode 改为把虚拟节点追加进 g.Nodes:
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
}
两处调用改为:
// 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"。
Run: go test ./internal/graph/ -v
Expected: PASS
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 无外键约束,不受影响)。
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)
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
}
Run: go test ./internal/index/ -run TestNodeTypeSeparation -v
Expected: FAIL(tag 节点被查询返回 / GetAllNodes 包含 tag)
initTables 的 nodes 表定义增加列:
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 末尾增加旧库迁移(幂等):
// 旧库迁移:补 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)
}
}
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)
新增辅助函数:
func nodeTypeOf(n *graph.Node) string {
if n.NodeType == "" {
return "file"
}
return n.NodeType
}
GetAllNodes:SQL 改为 SELECT id, path FROM nodes WHERE node_type = 'file'(NodeInfo 结构不变,仍只返回 id/path)。FindNodesByKeyword:WHERE 增加 AND node_type = 'file'。FindRelatedNodes:种子查询 WHERE 增加 AND node_type = 'file';"通过标签查找"/"通过实体查找"两段 SQL 各增加 AND node_type = 'file'。Run: go test ./internal/index/ -v
Expected: 全部 PASS(含原有 TestStore、TestGetNodeLinks)
cd /home/aisim-p/workspace/kb-cli
go build ./... && go test ./...
git add internal/index/
git commit -m "feat(index): nodes 表增加 node_type 列,查询过滤虚拟节点"
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
internal/index/fts.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
}
Run: cd /home/aisim-p/workspace/kb-cli && go build ./... && go test ./...
Expected: 全部 PASS
go build -o /home/aisim-p/go/bin/kb-cli .
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 关联
kb-cli graph related "电磁阀" --top 5
Expected: 返回共享 tag 的文档(关联度 > 0),且不含 tag 节点本身。
cd /home/aisim-p/workspace/kb-cli
git add internal/index/fts.go
git commit -m "feat(fts): FTS 表仅收录 file 节点"
go test ./... 全绿kb-cli index build 后边数 ≥ 2000kb-cli search 结果与改动前一致(不出现 tag/entity 节点)kb-cli graph related 能通过共同 tag 找到相关文档~/go/bin/kb-cli