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" } 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 }