feat: wikilink 边可信度标注(exact/fuzzy)+ 悬空链接入 unresolved_links 表
| | |
| | | fmt.Fprintf(os.Stderr, "扫描到 %d 个文件\n", len(files)) |
| | | |
| | | // 构建图 |
| | | g := graph.BuildGraph(files) |
| | | g, unresolved := graph.BuildGraph(files) |
| | | |
| | | // 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射 |
| | | idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID |
| | |
| | | ToNode: toID, |
| | | Relation: e.Relation, |
| | | Label: e.Label, |
| | | Provenance: e.Provenance, |
| | | } |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | return fmt.Errorf("插入边失败: %w", err) |
| | |
| | | actualEdgeCount++ |
| | | } |
| | | |
| | | // 写入悬空链接(BuildGraph 内部 ID 映射为 SQLite ID) |
| | | for _, u := range unresolved { |
| | | fromID, ok := idMap[u.FromNode] |
| | | if !ok { |
| | | continue |
| | | } |
| | | if err := store.InsertUnresolved(fromID, u.LinkText, u.NameTail); err != nil { |
| | | return fmt.Errorf("写入悬空链接失败 [%s]: %w", u.LinkText, err) |
| | | } |
| | | } |
| | | |
| | | // 创建并填充 FTS5 索引 |
| | | if err := store.CreateFTS(); err != nil { |
| | | return fmt.Errorf("创建 FTS 索引失败: %w", err) |
| | |
| | | "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) |
| | |
| | | ToNode: virtualID, |
| | | Relation: "tag", |
| | | Label: tag, |
| | | Provenance: "tag", |
| | | }) |
| | | } |
| | | |
| | |
| | | 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) { |
| | | 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, |
| | | }) |
| | | break |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return g |
| | | return g, unresolved |
| | | } |
| | | |
| | | // matchesWikilink 检查文件是否匹配 wikilink |
| | | func matchesWikilink(node *Node, link string) bool { |
| | | // 精确匹配标题 |
| | | // 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 true |
| | | return node, "exact" |
| | | } |
| | | // 匹配文件名(不含扩展名和编号前缀) |
| | | // 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md" |
| | | // 匹配文件名(去 .md 和编号前缀),如 [[充装规格配置]] 匹配 "知识/002-充装规格配置.md" |
| | | base := filepath.Base(node.Path) |
| | | base = strings.TrimSuffix(base, ".md") |
| | | // 去掉编号前缀(如 "002-") |
| | | if idx := strings.Index(base, "-"); idx >= 0 { |
| | | base = base[idx+1:] |
| | | if dash := strings.Index(base, "-"); dash >= 0 { |
| | | base = base[dash+1:] |
| | | } |
| | | return base == link || node.Title == link |
| | | 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 |
| | | } |
| | |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | | func TestBuildGraphProvenance(t *testing.T) { |
| | | files := []*vault.FileMeta{ |
| | | {Path: "FAQ/001-补气失败.md", Title: "补气失败", Wikilinks: []string{"红绿闪", "不存在的链接"}}, |
| | | {Path: "FAQ/002-红绿闪.md", Title: "红绿闪"}, |
| | | {Path: "知识/003-称重原理详解.md", Title: "称重原理详解"}, // "称重原理" 是 "称重原理详解" 的子串 → fuzzy |
| | | } |
| | | files[0].Wikilinks = append(files[0].Wikilinks, "称重原理") |
| | | |
| | | g, unresolved := BuildGraph(files) |
| | | |
| | | // exact: 标题精确匹配 |
| | | var exact, fuzzy int |
| | | for _, e := range g.Edges { |
| | | if e.Relation != "wikilink" { |
| | | continue |
| | | } |
| | | switch e.Provenance { |
| | | case "exact": |
| | | exact++ |
| | | case "fuzzy": |
| | | fuzzy++ |
| | | } |
| | | } |
| | | if exact != 1 || fuzzy != 1 { |
| | | t.Errorf("provenance 分布: exact=%d fuzzy=%d (want 1/1)", exact, fuzzy) |
| | | } |
| | | // 悬空链接 |
| | | if len(unresolved) != 1 || unresolved[0].LinkText != "不存在的链接" { |
| | | t.Errorf("unresolved: %+v", unresolved) |
| | | } |
| | | if unresolved[0].NameTail != "不存在的链接" { |
| | | t.Errorf("name_tail: %s", unresolved[0].NameTail) |
| | | } |
| | | } |
| | | |
| | | func TestBuildGraph(t *testing.T) { |
| | | files := []*vault.FileMeta{ |
| | | { |
| | |
| | | }, |
| | | } |
| | | |
| | | g := BuildGraph(files) |
| | | g, _ := BuildGraph(files) |
| | | |
| | | if len(g.Nodes) != 2 { |
| | | t.Errorf("node count = %d, want 2", len(g.Nodes)) |
| | |
| | | ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID |
| | | Relation string `json:"relation"` // "tag" | "entity" | "wikilink" |
| | | Label string `json:"label"` // 具体值 |
| | | Provenance string `json:"provenance"` |
| | | Provenance string `json:"provenance"` // "exact" | "fuzzy" | "tag" | "entity" |
| | | } |
| | | |
| | | // UnresolvedLink 悬空 wikilink(未匹配到目标节点) |
| | | type UnresolvedLink struct { |
| | | FromNode int64 // BuildGraph 内部节点 ID |
| | | LinkText string |
| | | NameTail string |
| | | } |
| | | |
| | | // Graph 知识图谱 |
| | |
| | | |
| | | // ClearData 清空数据(重建前调用) |
| | | func (s *Store) ClearData() error { |
| | | _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;") |
| | | _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;") |
| | | return err |
| | | } |
| | | |
| | |
| | | return err |
| | | } |
| | | |
| | | // InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插) |
| | | func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error { |
| | | _, err := s.db.Exec(` |
| | | INSERT INTO unresolved_links (from_node, link_text, name_tail, status) |
| | | SELECT ?, ?, ?, 'pending' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`, |
| | | fromNode, linkText, nameTail, fromNode, linkText) |
| | | return err |
| | | } |
| | | |
| | | // FileStat 索引中的文件指纹 |
| | | type FileStat struct { |
| | | Path string |