feat: 悬空双链自动补全(RetryUnresolved,Reconcile 末尾自动调用)+ 3 个 Task3 审查 Minor 修复
1 files added
2 files modified
| | |
| | | if base == link { |
| | | return node, "exact" |
| | | } |
| | | if fuzzy == nil && strings.Contains(node.Title, link) { |
| | | // 空 link 跳过 fuzzy(Contains 对空串恒真) |
| | | if fuzzy == nil && link != "" && strings.Contains(node.Title, link) { |
| | | fuzzy = node |
| | | } |
| | | } |
| | |
| | | Modified int |
| | | Deleted int |
| | | Unchanged int |
| | | Resolved int // 悬空链接自动补全成功的条数 |
| | | } |
| | | |
| | | // Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库 |
| | |
| | | } |
| | | } |
| | | |
| | | // 5. 悬空链接重试(新增/修改节点可能让 failed 链接变可解析) |
| | | if res.Added+res.Modified > 0 { |
| | | store.RetryUnresolved() // 见 Task 4,此处先以空实现占位编译通过 |
| | | // 5. 悬空链接重试:新节点入库后,历史悬空链接可能变可解析 |
| | | resolved, err := store.RetryUnresolved() |
| | | if err != nil { |
| | | return nil, fmt.Errorf("悬空链接重试失败: %w", err) |
| | | } |
| | | res.Resolved = resolved |
| | | return res, nil |
| | | } |
| | | |
| | |
| | | targetID, prov, ok := s.resolveWikilink(link) |
| | | if !ok { |
| | | tail := nameTail(link) |
| | | // 幂等守卫:同 from_node+link_text 不重复插(unresolved_links 无 UNIQUE 约束,用 WHERE NOT EXISTS) |
| | | s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status) |
| | | VALUES (?, ?, ?, 'pending') ON CONFLICT DO NOTHING`, nodeID, link, tail) |
| | | SELECT ?, ?, ?, 'pending' |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`, |
| | | nodeID, link, tail, nodeID, link) |
| | | continue |
| | | } |
| | | if err := s.InsertEdge(&graph.Edge{ |
| | |
| | | s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID) |
| | | virtualID = maxID + 1 |
| | | } |
| | | return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label}) |
| | | return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label, Provenance: relation}) |
| | | } |
| | | |
| | | // resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok) |
| | |
| | | if base == link || nTitle == link { |
| | | return nid, "exact", true |
| | | } |
| | | if fuzzyID == 0 && strings.Contains(nTitle, link) { |
| | | // 空 link 跳过 fuzzy(Contains 对空串恒真会误建边) |
| | | if fuzzyID == 0 && link != "" && strings.Contains(nTitle, link) { |
| | | fuzzyID = nid |
| | | } |
| | | } |
| | |
| | | return link |
| | | } |
| | | |
| | | // RetryUnresolved 重试解析悬空链接(Task 4 补全完整实现) |
| | | func (s *Store) RetryUnresolved() {} |
| | | // RetryUnresolved 重试解析悬空链接:用当前全部节点的标题/文件名去匹配 unresolved_links 的 name_tail。 |
| | | // 命中则建边(provenance 按匹配严格度)、删行。返回成功解析条数。 |
| | | func (s *Store) RetryUnresolved() (int, error) { |
| | | rows, err := s.db.Query(`SELECT id, from_node, link_text, name_tail FROM unresolved_links`) |
| | | if err != nil { |
| | | return 0, err |
| | | } |
| | | type pending struct { |
| | | id int64 |
| | | fromNode int64 |
| | | linkText string |
| | | tail string |
| | | } |
| | | var pendings []pending |
| | | for rows.Next() { |
| | | var p pending |
| | | if err := rows.Scan(&p.id, &p.fromNode, &p.linkText, &p.tail); err != nil { |
| | | rows.Close() |
| | | return 0, err |
| | | } |
| | | pendings = append(pendings, p) |
| | | } |
| | | rows.Close() |
| | | if len(pendings) == 0 { |
| | | return 0, nil |
| | | } |
| | | |
| | | // 建匹配索引:标题/文件名(去编号) → nodeID,精确匹配优先 |
| | | type matchInfo struct { |
| | | id int64 |
| | | prov string |
| | | } |
| | | exactMap := make(map[string]matchInfo) |
| | | var fuzzyRows []struct { |
| | | id int64 |
| | | title string |
| | | } |
| | | nrows, err := s.db.Query(`SELECT id, title, path FROM nodes`) |
| | | if err != nil { |
| | | return 0, err |
| | | } |
| | | for nrows.Next() { |
| | | var id int64 |
| | | var title, path string |
| | | if err := nrows.Scan(&id, &title, &path); err != nil { |
| | | nrows.Close() |
| | | return 0, err |
| | | } |
| | | if _, ok := exactMap[title]; !ok { |
| | | exactMap[title] = matchInfo{id, "exact"} |
| | | } |
| | | base := filepath.Base(path) |
| | | base = strings.TrimSuffix(base, ".md") |
| | | if dash := strings.Index(base, "-"); dash >= 0 { |
| | | base = base[dash+1:] |
| | | } |
| | | if _, ok := exactMap[base]; !ok { |
| | | exactMap[base] = matchInfo{id, "exact"} |
| | | } |
| | | fuzzyRows = append(fuzzyRows, struct { |
| | | id int64 |
| | | title string |
| | | }{id, title}) |
| | | } |
| | | nrows.Close() |
| | | |
| | | resolved := 0 |
| | | for _, p := range pendings { |
| | | if info, ok := exactMap[p.tail]; ok { |
| | | // 精确命中:建边 + 删悬空行 |
| | | if err := s.InsertEdge(&graph.Edge{ |
| | | FromNode: p.fromNode, ToNode: info.id, |
| | | Relation: "wikilink", Label: p.linkText, Provenance: info.prov, |
| | | }); err != nil { |
| | | return resolved, err |
| | | } |
| | | if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil { |
| | | return resolved, err |
| | | } |
| | | resolved++ |
| | | continue |
| | | } |
| | | // fuzzy:标题包含(空 tail 跳过,避免 Contains 恒真) |
| | | if p.tail == "" { |
| | | continue |
| | | } |
| | | for _, fr := range fuzzyRows { |
| | | if strings.Contains(fr.title, p.tail) { |
| | | if err := s.InsertEdge(&graph.Edge{ |
| | | FromNode: p.fromNode, ToNode: fr.id, |
| | | Relation: "wikilink", Label: p.linkText, Provenance: "fuzzy", |
| | | }); err != nil { |
| | | return resolved, err |
| | | } |
| | | if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil { |
| | | return resolved, err |
| | | } |
| | | resolved++ |
| | | break |
| | | } |
| | | } |
| | | } |
| | | return resolved, nil |
| | | } |
| | | |
| | | // QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异 |
| | | func QuickCheck(store *Store, vaultPath string) (bool, error) { |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "os" |
| | | "path/filepath" |
| | | "testing" |
| | | ) |
| | | |
| | | func TestRetryUnresolved(t *testing.T) { |
| | | dir := t.TempDir() |
| | | os.MkdirAll(filepath.Join(dir, "FAQ"), 0755) |
| | | os.WriteFile(filepath.Join(dir, "FAQ", "001-旧文档.md"), |
| | | []byte("---\ntitle: 旧文档\n---\n提到 [[补气失败]]\n"), 0644) |
| | | |
| | | store, _ := Open(filepath.Join(t.TempDir(), "kb.db")) |
| | | defer store.Close() |
| | | |
| | | Reconcile(store, dir) |
| | | |
| | | // 验证悬空链接入表 |
| | | var n int |
| | | store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n) |
| | | if n != 1 { |
| | | t.Fatalf("悬空链接未入表: %d", n) |
| | | } |
| | | |
| | | // 新文档入库:标题正好是 "补气失败" |
| | | os.WriteFile(filepath.Join(dir, "FAQ", "002-补气失败.md"), |
| | | []byte("---\ntitle: 补气失败\n---\n补气失败排查\n"), 0644) |
| | | |
| | | res, err := Reconcile(store, dir) |
| | | if err != nil { |
| | | t.Fatal(err) |
| | | } |
| | | if res.Added != 1 { |
| | | t.Fatalf("新增: %+v", res) |
| | | } |
| | | |
| | | // 悬空链接应被解析:行删除 + 边建立 |
| | | store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='补气失败'`).Scan(&n) |
| | | if n != 0 { |
| | | t.Errorf("悬空行未清除: %d", n) |
| | | } |
| | | store.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&n) |
| | | if n != 1 { |
| | | t.Errorf("wikilink 边未建立: %d", n) |
| | | } |
| | | var prov string |
| | | store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='wikilink' AND label='补气失败'`).Scan(&prov) |
| | | if prov != "exact" { |
| | | t.Errorf("补全边应为 exact: %s", prov) |
| | | } |
| | | } |
| | | |
| | | func TestResolveWikilinkEmptyLink(t *testing.T) { |
| | | dir := t.TempDir() |
| | | os.MkdirAll(filepath.Join(dir, "FAQ"), 0755) |
| | | os.WriteFile(filepath.Join(dir, "FAQ", "001-文档.md"), |
| | | []byte("---\ntitle: 文档\n---\n提到 [[|别名]]\n"), 0644) |
| | | |
| | | store, _ := Open(filepath.Join(t.TempDir(), "kb.db")) |
| | | defer store.Close() |
| | | Reconcile(store, dir) |
| | | |
| | | // 空 link 不应 fuzzy 误建边(Contains 空串恒真) |
| | | var n int |
| | | store.db.QueryRow(`SELECT COUNT(*) FROM edges WHERE relation='wikilink' AND label='|别名'`).Scan(&n) |
| | | if n != 0 { |
| | | t.Errorf("空 link 不应建 wikilink 边: %d", n) |
| | | } |
| | | // 悬空行应入表且只入一行 |
| | | store.db.QueryRow(`SELECT COUNT(*) FROM unresolved_links WHERE link_text='|别名'`).Scan(&n) |
| | | if n != 1 { |
| | | t.Errorf("悬空行应为 1: %d", n) |
| | | } |
| | | } |
| | | |
| | | func TestTagEntityEdgeProvenance(t *testing.T) { |
| | | dir := t.TempDir() |
| | | os.MkdirAll(filepath.Join(dir, "FAQ"), 0755) |
| | | os.WriteFile(filepath.Join(dir, "FAQ", "001-文档.md"), |
| | | []byte("---\ntitle: 文档\ntags: [t1]\nentities: [张三]\n---\n内容\n"), 0644) |
| | | |
| | | store, _ := Open(filepath.Join(t.TempDir(), "kb.db")) |
| | | defer store.Close() |
| | | Reconcile(store, dir) |
| | | |
| | | // tag/entity 边的 provenance 应为 relation 本身(Minor 1 修复) |
| | | var prov string |
| | | store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='tag' AND label='t1'`).Scan(&prov) |
| | | if prov != "tag" { |
| | | t.Errorf("tag 边 provenance: %q", prov) |
| | | } |
| | | store.db.QueryRow(`SELECT provenance FROM edges WHERE relation='entity' AND label='张三'`).Scan(&prov) |
| | | if prov != "entity" { |
| | | t.Errorf("entity 边 provenance: %q", prov) |
| | | } |
| | | } |