From a5c01a3f115399b55f43d639c8278b8d68c6211c Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 11:26:53 +0800
Subject: [PATCH] feat: wikilink 边可信度标注(exact/fuzzy)+ 悬空链接入 unresolved_links 表
---
internal/graph/builder.go | 104 +++++++++++++++----------
internal/graph/model.go | 13 ++
internal/graph/builder_test.go | 37 +++++++++
cmd/rebuild.go | 22 ++++-
internal/index/sqlite.go | 13 +++
5 files changed, 138 insertions(+), 51 deletions(-)
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index a93794f..47684dd 100644
--- a/cmd/rebuild.go
+++ b/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)
diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index 428535b..d3fbd3e 100644
--- a/internal/graph/builder.go
+++ b/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
}
diff --git a/internal/graph/builder_test.go b/internal/graph/builder_test.go
index 8b66be5..afd95de 100644
--- a/internal/graph/builder_test.go
+++ b/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))
diff --git a/internal/graph/model.go b/internal/graph/model.go
index 485cc6e..06a70c2 100644
--- a/internal/graph/model.go
+++ b/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 知识图谱
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index 06a02b3..ea306fb 100644
--- a/internal/index/sqlite.go
+++ b/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
--
Gitblit v1.10.0