From cb72759b568baf34281bd15f821ac9834c17bbb9 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 14:24:29 +0800
Subject: [PATCH] merge: 合并 origin/master schema v2 重构(RWR/增量对账/explore),保留 tag/entity 实验于 experiment 分支 + 本地 expandPath 修复与 draft list 过滤
---
internal/graph/builder.go | 118 +++++++++++++++++++++++++++++++++-------------------------
1 files changed, 67 insertions(+), 51 deletions(-)
diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index ce5f0ea..9c89e7a 100644
--- a/internal/graph/builder.go
+++ b/internal/graph/builder.go
@@ -7,27 +7,22 @@
"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)
nextVirtualID := int64(1000000) // 虚拟节点从 1000000 开始
- getOrCreateVirtualNode := func(label, nodeType string) int64 {
+ getOrCreateVirtualNode := func(label 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
}
@@ -37,7 +32,6 @@
Path: f.Path,
Title: f.Title,
Section: f.Section,
- NodeType: "file",
Tags: f.Tags,
Entities: f.Entities,
Wikilinks: f.Wikilinks,
@@ -47,66 +41,88 @@
// 创建 tag 边
for _, tag := range f.Tags {
- virtualID := getOrCreateVirtualNode("tag:"+tag, "tag")
+ 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",
})
}
// 创建 entity 边
for _, entity := range f.Entities {
- virtualID := getOrCreateVirtualNode("entity:"+entity, "entity")
+ 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"
+ }
+ // 空 link 跳过 fuzzy(Contains 对空串恒真)
+ if fuzzy == nil && link != "" && 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
}
--
Gitblit v1.10.0