1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
| package graph
|
| import (
| "path/filepath"
| "strings"
|
| "github.com/aisim/kb-cli/internal/vault"
| )
|
| // 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 string) int64 {
| if id, ok := labelToID[label]; ok {
| return id
| }
| id := nextVirtualID
| nextVirtualID++
| labelToID[label] = id
| return id
| }
|
| for i, f := range files {
| node := &Node{
| ID: int64(i + 1),
| Path: f.Path,
| Title: f.Title,
| Section: f.Section,
| Tags: f.Tags,
| Entities: f.Entities,
| Wikilinks: f.Wikilinks,
| Content: f.Content,
| }
| g.Nodes = append(g.Nodes, node)
|
| // 创建 tag 边
| for _, tag := range f.Tags {
| virtualID := getOrCreateVirtualNode("tag:" + tag)
| g.Edges = append(g.Edges, &Edge{
| FromNode: node.ID,
| ToNode: virtualID,
| Relation: "tag",
| Label: tag,
| Provenance: "tag",
| })
| }
|
| // 创建 entity 边
| for _, entity := range f.Entities {
| virtualID := getOrCreateVirtualNode("entity:" + entity)
| g.Edges = append(g.Edges, &Edge{
| FromNode: node.ID,
| ToNode: virtualID,
| Relation: "entity",
| Label: entity,
| Provenance: "entity",
| })
| }
| }
|
| // 创建 wikilink 边(文件间链接),未匹配的记入悬空链接列表
| for _, n := range g.Nodes {
| for _, link := range n.Wikilinks {
| 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, unresolved
| }
|
| // 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]
| }
| 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
| }
| }
| 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
| }
|
|