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,74 @@
      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"
      }
      // 空 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
}