ax_rd
5 hours ago a5c01a3f115399b55f43d639c8278b8d68c6211c
feat: wikilink 边可信度标注(exact/fuzzy)+ 悬空链接入 unresolved_links 表
5 files modified
189 ■■■■ changed files
cmd/rebuild.go 22 ●●●● patch | view | raw | blame | history
internal/graph/builder.go 104 ●●●●● patch | view | raw | blame | history
internal/graph/builder_test.go 37 ●●●●● patch | view | raw | blame | history
internal/graph/model.go 13 ●●●● patch | view | raw | blame | history
internal/index/sqlite.go 13 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go
@@ -26,7 +26,7 @@
    fmt.Fprintf(os.Stderr, "扫描到 %d 个文件\n", len(files))
    // 构建图
    g := graph.BuildGraph(files)
    g, unresolved := graph.BuildGraph(files)
    // 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射
    idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID
@@ -52,10 +52,11 @@
            continue
        }
        edge := &graph.Edge{
            FromNode: fromID,
            ToNode:   toID,
            Relation: e.Relation,
            Label:    e.Label,
            FromNode:   fromID,
            ToNode:     toID,
            Relation:   e.Relation,
            Label:      e.Label,
            Provenance: e.Provenance,
        }
        if err := store.InsertEdge(edge); err != nil {
            return fmt.Errorf("插入边失败: %w", err)
@@ -63,6 +64,17 @@
        actualEdgeCount++
    }
    // 写入悬空链接(BuildGraph 内部 ID 映射为 SQLite ID)
    for _, u := range unresolved {
        fromID, ok := idMap[u.FromNode]
        if !ok {
            continue
        }
        if err := store.InsertUnresolved(fromID, u.LinkText, u.NameTail); err != nil {
            return fmt.Errorf("写入悬空链接失败 [%s]: %w", u.LinkText, err)
        }
    }
    // 创建并填充 FTS5 索引
    if err := store.CreateFTS(); err != nil {
        return fmt.Errorf("创建 FTS 索引失败: %w", err)
internal/graph/builder.go
@@ -7,9 +7,10 @@
    "github.com/aisim/kb-cli/internal/vault"
)
// BuildGraph 从文件元数据构建知识图谱
func BuildGraph(files []*vault.FileMeta) *Graph {
// BuildGraph 从文件元数据构建知识图谱,返回图和未解析的悬空链接列表
func BuildGraph(files []*vault.FileMeta) (*Graph, []*UnresolvedLink) {
    g := &Graph{}
    unresolved := make([]*UnresolvedLink, 0)
    // 标签/实体 -> 虚拟节点 ID 映射
    labelToID := make(map[string]int64)
@@ -42,10 +43,11 @@
        for _, tag := range f.Tags {
            virtualID := getOrCreateVirtualNode("tag:" + tag)
            g.Edges = append(g.Edges, &Edge{
                FromNode: node.ID,
                ToNode:   virtualID,
                Relation: "tag",
                Label:    tag,
                FromNode:   node.ID,
                ToNode:     virtualID,
                Relation:   "tag",
                Label:      tag,
                Provenance: "tag",
            })
        }
@@ -53,53 +55,73 @@
        for _, entity := range f.Entities {
            virtualID := getOrCreateVirtualNode("entity:" + entity)
            g.Edges = append(g.Edges, &Edge{
                FromNode: node.ID,
                ToNode:   virtualID,
                Relation: "entity",
                Label:    entity,
                FromNode:   node.ID,
                ToNode:     virtualID,
                Relation:   "entity",
                Label:      entity,
                Provenance: "entity",
            })
        }
    }
    // 创建 wikilink 边(文件间链接)
    pathToID := make(map[string]int64)
    for _, n := range g.Nodes {
        pathToID[n.Path] = n.ID
    }
    // 创建 wikilink 边(文件间链接),未匹配的记入悬空链接列表
    for _, n := range g.Nodes {
        for _, link := range n.Wikilinks {
            // 尝试匹配目标文件(模糊匹配:链接文本可能只是标题的一部分)
            for _, target := range g.Nodes {
                if matchesWikilink(target, link) {
                    g.Edges = append(g.Edges, &Edge{
                        FromNode: n.ID,
                        ToNode:   target.ID,
                        Relation: "wikilink",
                        Label:    link,
                    })
                    break
                }
            target, prov := ResolveWikilink(g.Nodes, link)
            if target == nil {
                unresolved = append(unresolved, &UnresolvedLink{
                    FromNode: n.ID, LinkText: link, NameTail: NameTail(link),
                })
                continue
            }
            g.Edges = append(g.Edges, &Edge{
                FromNode:   n.ID,
                ToNode:     target.ID,
                Relation:   "wikilink",
                Label:      link,
                Provenance: prov,
            })
        }
    }
    return g
    return g, unresolved
}
// matchesWikilink 检查文件是否匹配 wikilink
func matchesWikilink(node *Node, link string) bool {
    // 精确匹配标题
    if node.Title == link {
        return true
// ResolveWikilink 解析 wikilink 目标,返回 (目标节点, provenance)。
// exact = 标题/文件名精确;fuzzy = 标题包含。纯内存版,规则与 index 包 DB 版一致。
func ResolveWikilink(nodes []*Node, link string) (*Node, string) {
    // 去锚点:[[标题|别名]] 取标题部分
    if idx := strings.Index(link, "|"); idx >= 0 {
        link = link[:idx]
    }
    // 匹配文件名(不含扩展名和编号前缀)
    // 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md"
    base := filepath.Base(node.Path)
    base = strings.TrimSuffix(base, ".md")
    // 去掉编号前缀(如 "002-")
    if idx := strings.Index(base, "-"); idx >= 0 {
        base = base[idx+1:]
    var fuzzy *Node
    for _, node := range nodes {
        if node.Title == link {
            return node, "exact"
        }
        // 匹配文件名(去 .md 和编号前缀),如 [[充装规格配置]] 匹配 "知识/002-充装规格配置.md"
        base := filepath.Base(node.Path)
        base = strings.TrimSuffix(base, ".md")
        if dash := strings.Index(base, "-"); dash >= 0 {
            base = base[dash+1:]
        }
        if base == link {
            return node, "exact"
        }
        if fuzzy == nil && strings.Contains(node.Title, link) {
            fuzzy = node
        }
    }
    return base == link || node.Title == link
    if fuzzy != nil {
        return fuzzy, "fuzzy"
    }
    return nil, ""
}
// NameTail 取 link 尾部用于悬空链接重试匹配(去锚点修饰)
func NameTail(link string) string {
    if idx := strings.Index(link, "|"); idx >= 0 {
        link = link[:idx]
    }
    return link
}
internal/graph/builder_test.go
@@ -6,6 +6,41 @@
    "github.com/aisim/kb-cli/internal/vault"
)
func TestBuildGraphProvenance(t *testing.T) {
    files := []*vault.FileMeta{
        {Path: "FAQ/001-补气失败.md", Title: "补气失败", Wikilinks: []string{"红绿闪", "不存在的链接"}},
        {Path: "FAQ/002-红绿闪.md", Title: "红绿闪"},
        {Path: "知识/003-称重原理详解.md", Title: "称重原理详解"}, // "称重原理" 是 "称重原理详解" 的子串 → fuzzy
    }
    files[0].Wikilinks = append(files[0].Wikilinks, "称重原理")
    g, unresolved := BuildGraph(files)
    // exact: 标题精确匹配
    var exact, fuzzy int
    for _, e := range g.Edges {
        if e.Relation != "wikilink" {
            continue
        }
        switch e.Provenance {
        case "exact":
            exact++
        case "fuzzy":
            fuzzy++
        }
    }
    if exact != 1 || fuzzy != 1 {
        t.Errorf("provenance 分布: exact=%d fuzzy=%d (want 1/1)", exact, fuzzy)
    }
    // 悬空链接
    if len(unresolved) != 1 || unresolved[0].LinkText != "不存在的链接" {
        t.Errorf("unresolved: %+v", unresolved)
    }
    if unresolved[0].NameTail != "不存在的链接" {
        t.Errorf("name_tail: %s", unresolved[0].NameTail)
    }
}
func TestBuildGraph(t *testing.T) {
    files := []*vault.FileMeta{
        {
@@ -28,7 +63,7 @@
        },
    }
    g := BuildGraph(files)
    g, _ := BuildGraph(files)
    if len(g.Nodes) != 2 {
        t.Errorf("node count = %d, want 2", len(g.Nodes))
internal/graph/model.go
@@ -21,9 +21,16 @@
type Edge struct {
    FromNode   int64  `json:"from_node"`
    ToNode     int64  `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
    Relation   string `json:"relation"` // "tag" | "entity" | "wikilink"
    Label      string `json:"label"`    // 具体值
    Provenance string `json:"provenance"`
    Relation   string `json:"relation"`   // "tag" | "entity" | "wikilink"
    Label      string `json:"label"`      // 具体值
    Provenance string `json:"provenance"` // "exact" | "fuzzy" | "tag" | "entity"
}
// UnresolvedLink 悬空 wikilink(未匹配到目标节点)
type UnresolvedLink struct {
    FromNode int64 // BuildGraph 内部节点 ID
    LinkText string
    NameTail string
}
// Graph 知识图谱
internal/index/sqlite.go
@@ -93,7 +93,7 @@
// ClearData 清空数据(重建前调用)
func (s *Store) ClearData() error {
    _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
    _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;")
    return err
}
@@ -126,6 +126,17 @@
    return err
}
// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
    _, err := s.db.Exec(`
        INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
        SELECT ?, ?, ?, 'pending'
        WHERE NOT EXISTS (
            SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
        fromNode, linkText, nameTail, fromNode, linkText)
    return err
}
// FileStat 索引中的文件指纹
type FileStat struct {
    Path        string