ai_xiaopei
2026-08-21 efd6a8d9902e79df852012bf4a3fa94e1f8889cb
feat(graph): tag/entity 虚拟节点落库为真实节点(合成 path 命名空间)
3 files modified
44 ■■■■ changed files
internal/graph/builder.go 13 ●●●● patch | view | raw | blame | history
internal/graph/builder_test.go 30 ●●●●● patch | view | raw | blame | history
internal/graph/model.go 1 ●●●● patch | view | raw | blame | history
internal/graph/builder.go
@@ -15,13 +15,19 @@
    labelToID := make(map[string]int64)
    nextVirtualID := int64(1000000) // 虚拟节点从 1000000 开始
    getOrCreateVirtualNode := func(label string) int64 {
    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
    }
@@ -31,6 +37,7 @@
            Path:      f.Path,
            Title:     f.Title,
            Section:   f.Section,
            NodeType:  "file",
            Tags:      f.Tags,
            Entities:  f.Entities,
            Wikilinks: f.Wikilinks,
@@ -40,7 +47,7 @@
        // 创建 tag 边
        for _, tag := range f.Tags {
            virtualID := getOrCreateVirtualNode("tag:" + tag)
            virtualID := getOrCreateVirtualNode("tag:"+tag, "tag")
            g.Edges = append(g.Edges, &Edge{
                FromNode: node.ID,
                ToNode:   virtualID,
@@ -51,7 +58,7 @@
        // 创建 entity 边
        for _, entity := range f.Entities {
            virtualID := getOrCreateVirtualNode("entity:" + entity)
            virtualID := getOrCreateVirtualNode("entity:"+entity, "entity")
            g.Edges = append(g.Edges, &Edge{
                FromNode: node.ID,
                ToNode:   virtualID,
internal/graph/builder_test.go
@@ -1,6 +1,7 @@
package graph
import (
    "strings"
    "testing"
    "github.com/aisim/kb-cli/internal/vault"
@@ -30,8 +31,33 @@
    g := BuildGraph(files)
    if len(g.Nodes) != 2 {
        t.Errorf("node count = %d, want 2", len(g.Nodes))
    // 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)
    }
    // 应该有 3 条边:2条 tag + 1条 entity
internal/graph/model.go
@@ -6,6 +6,7 @@
    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"`