From 9c98026d5490ebb2ac9465d565319d77f6d505b4 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 13:21:50 +0800
Subject: [PATCH] fix: 修复草稿创建时知识库搜索失败问题

---
 bin/kb-cli               |    0 
 cmd/search.go            |   15 ++++
 internal/index/fts.go    |   67 ++++++++++++++--------
 internal/draft/intake.go |   80 +++++++++++++++++++++++---
 4 files changed, 127 insertions(+), 35 deletions(-)

diff --git a/bin/kb-cli b/bin/kb-cli
index 2c32481..48b7037 100755
--- a/bin/kb-cli
+++ b/bin/kb-cli
Binary files differ
diff --git a/cmd/search.go b/cmd/search.go
index 51d1496..88c0065 100644
--- a/cmd/search.go
+++ b/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)
 	}
diff --git a/internal/draft/intake.go b/internal/draft/intake.go
index f28d56f..49b4545 100644
--- a/internal/draft/intake.go
+++ b/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 文件
diff --git a/internal/index/fts.go b/internal/index/fts.go
index 5e67985..c06b250 100644
--- a/internal/index/fts.go
+++ b/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 获取节点内容

--
Gitblit v1.9.1