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