From 1196f409d86bc61e7596eb274840244a62ce84ba Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Wed, 29 Jul 2026 14:58:19 +0800
Subject: [PATCH] feat: config priority over env vars, export LoadConfig, add review feature

---
 internal/draft/intake.go |  465 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 450 insertions(+), 15 deletions(-)

diff --git a/internal/draft/intake.go b/internal/draft/intake.go
index f28d56f..0f4c36f 100644
--- a/internal/draft/intake.go
+++ b/internal/draft/intake.go
@@ -22,12 +22,22 @@
 	store     *index.Store
 }
 
+// DraftInfo 草稿信息(用于 list 输出)
+type DraftInfo struct {
+	Filepath     string `json:"filepath"`
+	Filename     string `json:"filename"`
+	Title        string `json:"title,omitempty"`
+	Status       string `json:"status,omitempty"`
+	Type         string `json:"type,omitempty"`
+	Created      string `json:"created,omitempty"`
+	HasMergeHint bool   `json:"has_merge_hint"`
+}
+
 // DraftMeta 草稿元数据
 type DraftMeta struct {
 	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 +49,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 创建草稿录入器
@@ -79,7 +110,8 @@
 	var mergeHint *llm.MergeHint
 	if len(candidates) > 0 {
 		fmt.Println("正在生成合并指示...")
-		mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates)
+		dirStructure := i.scanDirectoryStructure()
+		mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates, dirStructure)
 		if err != nil {
 			fmt.Printf("警告: 生成合并指示失败: %v\n", err)
 			mergeHint = nil
@@ -92,10 +124,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,22 +146,40 @@
 		return nil, nil
 	}
 
-	// 使用所有 tags 作为关键词搜索
+	// 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
+	// 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
 	opts := search.SearchOptions{
-		TopN: 3,
+		TopN:        3,
+		WithContent: true, // 需要内容给 LLM 评估
 	}
 
 	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))
 	for _, r := range results {
+		// 截取前5000字给 LLM 评估
+		content := r.Content
+		if len(content) > 5000 {
+			content = content[:5000] + "\n...(内容已截断)"
+		}
 		candidates = append(candidates, llm.SearchCandidate{
-			Title: r.Title,
-			Path:  r.Path,
-			Score: float64(r.Score),
+			Title:   r.Title,
+			Path:    r.Path,
+			Score:   float64(r.Score),
+			Content: content,
 		})
 	}
 
@@ -295,10 +350,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 +376,7 @@
 	meta := DraftMeta{
 		Title:   title,
 		Type:    draftType,
-		Status:  "待确认",
-		Draft:   true,
+		Status:  "draft",
 		Source:  source,
 		Tags:    tags,
 		Created: now,
@@ -333,7 +387,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))
 	}
@@ -381,6 +434,60 @@
 	}
 
 	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)
+}
+
+// scanDirectoryStructure 扫描知识库目录结构,返回格式化的目录树字符串
+func (i *Intake) scanDirectoryStructure() string {
+	// 知识库根目录下的主要分类目录
+	topDirs := []string{
+		"电子秤平台",
+		"运营管理平台",
+		"第三方平台",
+		"共有硬件",
+		"通用",
+	}
+
+	var sb strings.Builder
+	for _, topDir := range topDirs {
+		dirPath := filepath.Join(i.vaultPath, topDir)
+		entries, err := os.ReadDir(dirPath)
+		if err != nil {
+			continue
+		}
+		for _, entry := range entries {
+			if entry.IsDir() {
+				sb.WriteString(fmt.Sprintf("%s/%s/\n", topDir, entry.Name()))
+			}
+		}
+	}
+
+	return sb.String()
 }
 
 // RebuildTags 批量重建 tags
@@ -507,3 +614,331 @@
 
 	return os.WriteFile(path, []byte(newContent), 0644)
 }
+
+// ListDrafts 列出所有活跃草稿
+func (i *Intake) ListDrafts() ([]DraftInfo, error) {
+	reviewBase := filepath.Join(i.vaultPath, "待审阅")
+
+	if _, err := os.Stat(reviewBase); os.IsNotExist(err) {
+		return []DraftInfo{}, nil
+	}
+
+	var drafts []DraftInfo
+
+	err := filepath.Walk(reviewBase, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		// 跳过 archive 目录
+		if info.IsDir() && info.Name() == "archive" {
+			return filepath.SkipDir
+		}
+
+		// 检查是否是目录结构(包含 draft.md)
+		if !info.IsDir() && info.Name() == "draft.md" {
+			draftDir := filepath.Dir(path)
+			mergeFile := filepath.Join(draftDir, "merge.md")
+
+			content, err := os.ReadFile(path)
+			if err != nil {
+				return nil
+			}
+
+			relPath, _ := filepath.Rel(i.vaultPath, path)
+			hasMerge := fileExists(mergeFile)
+
+			draft := DraftInfo{
+				Filepath:     relPath,
+				Filename:     filepath.Base(draftDir),
+				HasMergeHint: hasMerge,
+			}
+
+			// 解析 frontmatter
+			fm := parseFrontmatter(string(content))
+			if title, ok := fm["title"]; ok {
+				draft.Title = title
+			}
+			if status, ok := fm["status"]; ok {
+				draft.Status = status
+			}
+			if draftType, ok := fm["type"]; ok {
+				draft.Type = draftType
+			}
+			if created, ok := fm["created"]; ok {
+				draft.Created = created
+			}
+
+			drafts = append(drafts, draft)
+		}
+
+		// 兼容旧结构(单文件 .md)
+		if !info.IsDir() && filepath.Ext(info.Name()) == ".md" && info.Name() != "draft.md" && info.Name() != "merge.md" && info.Name() != "索引.json" {
+			// 检查是否是数字开头
+			name := info.Name()
+			if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
+				content, err := os.ReadFile(path)
+				if err != nil {
+					return nil
+				}
+
+				relPath, _ := filepath.Rel(i.vaultPath, path)
+
+				draft := DraftInfo{
+					Filepath:     relPath,
+					Filename:     name,
+					HasMergeHint: false,
+				}
+
+				// 解析 frontmatter
+				fm := parseFrontmatter(string(content))
+				if title, ok := fm["title"]; ok {
+					draft.Title = title
+				}
+				if status, ok := fm["status"]; ok {
+					draft.Status = status
+				}
+				if draftType, ok := fm["type"]; ok {
+					draft.Type = draftType
+				}
+				if created, ok := fm["created"]; ok {
+					draft.Created = created
+				}
+
+				drafts = append(drafts, draft)
+			}
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	return drafts, nil
+}
+
+// ConvertOldDrafts 转换老格式草稿到新格式
+func (i *Intake) ConvertOldDrafts(target string) (int, error) {
+	reviewDir := filepath.Join(i.vaultPath, "待审阅")
+
+	var dirs []string
+	if target != "" {
+		dirs = append(dirs, filepath.Join(reviewDir, target))
+	} else {
+		entries, err := os.ReadDir(reviewDir)
+		if err != nil {
+			return 0, err
+		}
+
+		for _, entry := range entries {
+			if entry.IsDir() && entry.Name() != "archive" {
+				dirs = append(dirs, filepath.Join(reviewDir, entry.Name()))
+			}
+		}
+	}
+
+	total := 0
+	for _, dir := range dirs {
+		if _, err := os.Stat(dir); os.IsNotExist(err) {
+			continue
+		}
+
+		entries, err := os.ReadDir(dir)
+		if err != nil {
+			continue
+		}
+
+		var oldFiles []string
+		for _, entry := range entries {
+			if !entry.IsDir() && filepath.Ext(entry.Name()) == ".md" {
+				// 检查是否是数字开头的单文件
+				name := entry.Name()
+				if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) {
+					oldFiles = append(oldFiles, filepath.Join(dir, name))
+				}
+			}
+		}
+
+		if len(oldFiles) == 0 {
+			continue
+		}
+
+		fmt.Printf("\n🔍 %s: 找到 %d 个老格式文件\n\n", filepath.Base(dir), len(oldFiles))
+
+		for _, oldFile := range oldFiles {
+			if convertOneDraft(oldFile) {
+				total++
+			}
+		}
+	}
+
+	return total, nil
+}
+
+// convertOneDraft 转换单个老格式草稿
+func convertOneDraft(oldFile string) bool {
+	fmt.Printf("📄 转换: %s\n", filepath.Base(oldFile))
+
+	content, err := os.ReadFile(oldFile)
+	if err != nil {
+		fmt.Printf("  ⚠️  读取失败: %v\n", err)
+		return false
+	}
+
+	// 解析文件名
+	name := filepath.Base(oldFile)
+	ext := filepath.Ext(name)
+	nameWithoutExt := strings.TrimSuffix(name, ext)
+
+	// 提取编号、日期、标题
+	parts := strings.SplitN(nameWithoutExt, "-", 3)
+	if len(parts) < 3 {
+		fmt.Printf("  ⚠️  文件名格式不匹配: %s\n", name)
+		return false
+	}
+
+	num := parts[0]
+	dateStr := parts[1]
+	title := parts[2]
+
+	// 创建新目录
+	newDirName := fmt.Sprintf("%s-%s-%s", num, dateStr, title)
+	newDir := filepath.Join(filepath.Dir(oldFile), newDirName)
+
+	if err := os.MkdirAll(newDir, 0755); err != nil {
+		fmt.Printf("  ⚠️  创建目录失败: %v\n", err)
+		return false
+	}
+
+	// 解析 frontmatter
+	fm := parseFrontmatter(string(content))
+
+	// 构建标准化 frontmatter
+	var sb strings.Builder
+	sb.WriteString("---\n")
+	if title, ok := fm["title"]; ok {
+		sb.WriteString(fmt.Sprintf("title: %q\n", title))
+	} else {
+		sb.WriteString(fmt.Sprintf("title: %q\n", strings.ReplaceAll(title, "_", "/")))
+	}
+	sb.WriteString("status: \"draft\"\n")
+	if draftType, ok := fm["type"]; ok {
+		sb.WriteString(fmt.Sprintf("type: %q\n", draftType))
+	} else {
+		sb.WriteString("type: \"TAPD\"\n")
+	}
+	if source, ok := fm["source"]; ok {
+		sb.WriteString(fmt.Sprintf("source: %q\n", source))
+	} else {
+		sb.WriteString(fmt.Sprintf("source: \"TAPD-%s\"\n", dateStr))
+	}
+	if tags, ok := fm["tags"]; ok {
+		// 如果 tags 已经是数组格式,直接使用
+		if strings.HasPrefix(tags, "[") {
+			sb.WriteString(fmt.Sprintf("tags: %s\n", tags))
+		} else {
+			sb.WriteString(fmt.Sprintf("tags: [%s]\n", tags))
+		}
+	} else {
+		sb.WriteString("tags: []\n")
+	}
+	if created, ok := fm["created"]; ok {
+		sb.WriteString(fmt.Sprintf("created: %q\n", created))
+	} else if created, ok := fm["date"]; ok {
+		sb.WriteString(fmt.Sprintf("created: %q\n", created))
+	} else {
+		// 从日期字符串构造
+		if len(dateStr) == 8 {
+			created := fmt.Sprintf("%s-%s-%s", dateStr[:4], dateStr[4:6], dateStr[6:8])
+			sb.WriteString(fmt.Sprintf("created: %q\n", created))
+		}
+	}
+	sb.WriteString("---\n\n")
+
+	// 提取正文(去掉原 frontmatter)
+	body := extractBody(string(content))
+	sb.WriteString(body)
+
+	// 写入 draft.md
+	draftFile := filepath.Join(newDir, "draft.md")
+	if err := os.WriteFile(draftFile, []byte(sb.String()), 0644); err != nil {
+		fmt.Printf("  ⚠️  写入 draft.md 失败: %v\n", err)
+		return false
+	}
+	fmt.Printf("  ✅ 创建: draft.md\n")
+
+	// 删除原文件
+	if err := os.Remove(oldFile); err != nil {
+		fmt.Printf("  ⚠️  删除原文件失败: %v\n", err)
+	} else {
+		fmt.Printf("  🗑️  删除: %s\n", name)
+	}
+
+	return true
+}
+
+// extractBody 提取正文(去掉 frontmatter)
+func extractBody(content string) string {
+	if !strings.HasPrefix(content, "---") {
+		return content
+	}
+
+	endIdx := strings.Index(content[3:], "\n---")
+	if endIdx == -1 {
+		return content
+	}
+
+	return strings.TrimSpace(content[endIdx+7:])
+}
+
+// parseFrontmatter 解析 frontmatter
+func parseFrontmatter(content string) map[string]string {
+	result := make(map[string]string)
+
+	if !strings.HasPrefix(content, "---") {
+		return result
+	}
+
+	endIdx := strings.Index(content[3:], "\n---")
+	if endIdx == -1 {
+		return result
+	}
+
+	fmText := content[3 : endIdx+3]
+	lines := strings.Split(fmText, "\n")
+
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || !strings.Contains(line, ":") {
+			continue
+		}
+
+		parts := strings.SplitN(line, ":", 2)
+		if len(parts) != 2 {
+			continue
+		}
+
+		key := strings.TrimSpace(parts[0])
+		value := strings.TrimSpace(parts[1])
+
+		// 去除引号
+		value = strings.Trim(value, "\"'")
+
+		result[key] = value
+	}
+
+	return result
+}
+
+// isDigit 检查字符是否为数字
+func isDigit(c byte) bool {
+	return c >= '0' && c <= '9'
+}
+
+// fileExists 检查文件是否存在
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	return err == nil
+}

--
Gitblit v1.9.1