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 |  103 +++++++++++++++++++++++++++++++++------------------
 1 files changed, 67 insertions(+), 36 deletions(-)

diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index 9b5ca4e..d3fbd3e 100644
--- a/internal/graph/builder.go
+++ b/internal/graph/builder.go
@@ -1,12 +1,16 @@
 package graph
 
 import (
+	"path/filepath"
+	"strings"
+
 	"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)
@@ -39,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",
 			})
 		}
 
@@ -50,47 +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"
-	return false // 简化版,后续可扩展
+	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
+		}
+	}
+	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
 }

--
Gitblit v1.10.0