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
}