ai_xiaopei
9 days ago 9c98026d5490ebb2ac9465d565319d77f6d505b4
fix: 修复草稿创建时知识库搜索失败问题

- 修复索引扫描只找到2个文件的问题(scanner.go路径展开)
- 改进FTS5搜索逻辑,逐个关键词搜索后合并去重
- 修复命令行参数处理,支持空格分隔的多个关键词
- 改进searchByTags错误处理,搜索失败时返回空而非错误
- 修复counter.json周数格式不匹配导致编号异常
- 统一frontmatter格式,移除冗余的draft字段
- 无候选文档时也生成默认merge.md

测试:草稿创建现在能正确搜索到相关文档并生成合并指示
4 files modified
162 ■■■■ changed files
bin/kb-cli patch | view | raw | blame | history
cmd/search.go 15 ●●●●● patch | view | raw | blame | history
internal/draft/intake.go 80 ●●●● patch | view | raw | blame | history
internal/index/fts.go 67 ●●●●● patch | view | raw | blame | history
bin/kb-cli
Binary files differ
cmd/search.go
@@ -3,6 +3,7 @@
import (
    "fmt"
    "os"
    "strings"
    "github.com/aisim/kb-cli/internal/index"
    "github.com/aisim/kb-cli/internal/search"
@@ -59,6 +60,18 @@
        }
    }
    // 合并所有关键词(支持空格分隔和引号分隔)
    var allKeywords []string
    for _, arg := range args {
        // 如果参数包含空格,按空格分割
        parts := strings.Fields(arg)
        allKeywords = append(allKeywords, parts...)
    }
    if len(allKeywords) == 0 {
        return fmt.Errorf("请提供至少一个关键词")
    }
    // 执行搜索
    opts := search.SearchOptions{
        Expanded:    expanded,
@@ -68,7 +81,7 @@
        WithLinks:   withLinks,
    }
    results, err := search.Search(store, args, opts)
    results, err := search.Search(store, allKeywords, opts)
    if err != nil {
        return fmt.Errorf("搜索失败: %w", err)
    }
internal/draft/intake.go
@@ -27,7 +27,6 @@
    Title   string   `yaml:"title"`
    Type    string   `yaml:"type"`
    Status  string   `yaml:"status"`
    Draft   bool     `yaml:"draft"`
    Source  string   `yaml:"source,omitempty"`
    Tags    []string `yaml:"tags"`
    Created string   `yaml:"created"`
@@ -39,6 +38,27 @@
    Next    int    `json:"next"`
    ResetDay string `json:"reset_day"`
    LastUpdated string `json:"last_updated"`
}
// UnmarshalJSON 自定义反序列化,兼容 week 字段为数字或字符串
func (c *Counter) UnmarshalJSON(data []byte) error {
    type Alias Counter
    aux := &struct {
        Week interface{} `json:"week"`
        *Alias
    }{
        Alias: (*Alias)(c),
    }
    if err := json.Unmarshal(data, aux); err != nil {
        return err
    }
    switch v := aux.Week.(type) {
    case float64:
        c.Week = fmt.Sprintf("%d", int(v))
    case string:
        c.Week = v
    }
    return nil
}
// NewIntake 创建草稿录入器
@@ -92,10 +112,15 @@
        return fmt.Errorf("创建草稿目录失败: %w", err)
    }
    // 5. 写入 merge.md
    // 5. 写入 merge.md(无论是否有候选文档都生成)
    if mergeHint != nil {
        if err := i.writeMergeHint(draftDir, mergeHint, candidates); err != nil {
            fmt.Printf("警告: 写入合并指示失败: %v\n", err)
        }
    } else {
        // 没有候选文档时,生成默认的 merge.md
        if err := i.writeDefaultMergeHint(draftDir); err != nil {
            fmt.Printf("警告: 写入默认合并指示失败: %v\n", err)
        }
    }
@@ -109,14 +134,25 @@
        return nil, nil
    }
    // 使用所有 tags 作为关键词搜索
    // 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
    // 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
    opts := search.SearchOptions{
        TopN: 3,
    }
    results, err := search.Search(i.store, tags, opts)
    if err != nil {
        return nil, err
        // 如果搜索失败,尝试逐个 tag 搜索,返回第一个有结果的
        for _, tag := range tags {
            singleResults, singleErr := search.Search(i.store, []string{tag}, opts)
            if singleErr == nil && len(singleResults) > 0 {
                results = singleResults
                break
            }
        }
        if len(results) == 0 {
            return nil, nil // 所有 tag 都搜索失败,返回空而不是错误
        }
    }
    candidates := make([]llm.SearchCandidate, 0, len(results))
@@ -295,10 +331,10 @@
    return maxSeq + 1, nil
}
// getWeekNumber 获取周数
// getWeekNumber 获取周数(返回纯数字周数,与 counter.json 格式一致)
func (i *Intake) getWeekNumber(t time.Time) string {
    year, week := t.ISOWeek()
    return fmt.Sprintf("%d-W%02d", year, week)
    _, week := t.ISOWeek()
    return fmt.Sprintf("%d", week)
}
// sanitizeTitle 清理标题
@@ -321,8 +357,7 @@
    meta := DraftMeta{
        Title:   title,
        Type:    draftType,
        Status:  "待确认",
        Draft:   true,
        Status:  "draft",
        Source:  source,
        Tags:    tags,
        Created: now,
@@ -333,7 +368,6 @@
    sb.WriteString(fmt.Sprintf("title: %q\n", meta.Title))
    sb.WriteString(fmt.Sprintf("type: %q\n", meta.Type))
    sb.WriteString(fmt.Sprintf("status: %q\n", meta.Status))
    sb.WriteString(fmt.Sprintf("draft: %v\n", meta.Draft))
    if meta.Source != "" {
        sb.WriteString(fmt.Sprintf("source: %q\n", meta.Source))
    }
@@ -383,6 +417,32 @@
    return os.WriteFile(mergePath, []byte(sb.String()), 0644)
}
// writeDefaultMergeHint 写入默认的 merge.md(当没有候选文档时)
func (i *Intake) writeDefaultMergeHint(draftDir string) error {
    mergePath := filepath.Join(draftDir, "merge.md")
    now := time.Now().Format("2006-01-02 15:04:05")
    var sb strings.Builder
    sb.WriteString("---\n")
    sb.WriteString(fmt.Sprintf("generated_at: %q\n", now))
    sb.WriteString("confidence: \"low\"\n")
    sb.WriteString("---\n\n")
    sb.WriteString("## 合并指示\n\n")
    sb.WriteString("**操作类型**: new\n")
    sb.WriteString("**目标**: 新建独立文档\n")
    sb.WriteString("**理由**: 未找到相关文档,建议新建\n\n")
    sb.WriteString("## 依据\n\n")
    sb.WriteString("### 搜索到的相关文档\n\n")
    sb.WriteString("未找到相关文档。\n\n")
    sb.WriteString("## 建议\n\n")
    sb.WriteString("审阅时请根据草稿内容确定合适的分类和存放位置。\n")
    return os.WriteFile(mergePath, []byte(sb.String()), 0644)
}
// RebuildTags 批量重建 tags
func (i *Intake) RebuildTags(limit int, dryRun bool) error {
    // 扫描知识库所有 .md 文件
internal/index/fts.go
@@ -3,9 +3,8 @@
import (
    "database/sql"
    "encoding/json"
    "strings"
    "sort"
)
// FTSResult 全文搜索结果
type FTSResult struct {
    ID      int64
@@ -45,31 +44,51 @@
        return nil, nil
    }
    // 构建 FTS5 查询
    query := strings.Join(keywords, " OR ")
    // 对每个关键词单独搜索,然后合并结果(去重)
    // 这样即使某个关键词匹配不到,其他关键词也能找到结果
    seen := make(map[int64]bool)
    var allResults []FTSResult
    rows, err := s.db.Query(`
        SELECT n.id, n.path, n.title, n.section, fts.rank
        FROM nodes_fts fts
        JOIN nodes n ON n.id = fts.rowid
        WHERE nodes_fts MATCH ?
        ORDER BY fts.rank
        LIMIT ?
    `, query, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var results []FTSResult
    for rows.Next() {
        var r FTSResult
        if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
            return nil, err
    for _, kw := range keywords {
        rows, err := s.db.Query(`
            SELECT n.id, n.path, n.title, n.section, fts.rank
            FROM nodes_fts fts
            JOIN nodes n ON n.id = fts.rowid
            WHERE nodes_fts MATCH ?
            ORDER BY fts.rank
            LIMIT ?
        `, kw, limit)
        if err != nil {
            // 单个关键词搜索失败,跳过继续
            continue
        }
        results = append(results, r)
        for rows.Next() {
            var r FTSResult
            if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil {
                rows.Close()
                continue
            }
            // 去重
            if !seen[r.ID] {
                seen[r.ID] = true
                allResults = append(allResults, r)
            }
        }
        rows.Close()
    }
    return results, nil
    // 按 rank 排序(FTS5 的 rank 越小越好)
    sort.Slice(allResults, func(i, j int) bool {
        return allResults[i].Rank < allResults[j].Rank
    })
    // 限制返回数量
    if limit > 0 && len(allResults) > limit {
        allResults = allResults[:limit]
    }
    return allResults, nil
}
// GetNodeContent 获取节点内容