feat: 悬空双链自动补全(RetryUnresolved,Reconcile 末尾自动调用)+ 3 个 Task3 审查 Minor 修复
1 files added
2 files modified
227 ■■■■■ changed files
internal/graph/builder.go 3 ●●●● patch | view | raw | blame | history
internal/index/reconcile.go 126 ●●●●● patch | view | raw | blame | history
internal/index/unresolved_test.go 98 ●●●●● patch | view | raw | blame | history
internal/graph/builder.go
@@ -108,7 +108,8 @@
        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
        }
    }
internal/index/reconcile.go
@@ -18,6 +18,7 @@
    Modified  int
    Deleted   int
    Unchanged int
    Resolved  int // 悬空链接自动补全成功的条数
}
// Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
@@ -101,10 +102,12 @@
        }
    }
    // 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
}
@@ -156,8 +159,12 @@
        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{
@@ -180,7 +187,7 @@
        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)
@@ -218,7 +225,8 @@
        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
        }
    }
@@ -236,8 +244,110 @@
    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) {
internal/index/unresolved_test.go
New file
@@ -0,0 +1,98 @@
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)
    }
}