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

---
 cmd/draft.go               |  113 ++++++
 bin/kb-cli                 |    0 
 cmd/root.go                |   18 
 cmd/review.go              |  113 ++++++
 internal/draft/intake.go   |  385 ++++++++++++++++++++++
 internal/review/preview.go |  292 +++++++++++++++++
 cmd/rebuild.go             |    4 
 internal/llm/client.go     |   67 +++
 8 files changed, 964 insertions(+), 28 deletions(-)

diff --git a/bin/kb-cli b/bin/kb-cli
index 6ca85f2..21b2b8c 100755
--- a/bin/kb-cli
+++ b/bin/kb-cli
Binary files differ
diff --git a/cmd/draft.go b/cmd/draft.go
index f16da92..4341371 100644
--- a/cmd/draft.go
+++ b/cmd/draft.go
@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
 
@@ -12,15 +13,35 @@
 var draftCmd = &cobra.Command{
 	Use:   "draft",
 	Short: "草稿管理",
-	Long:  `kb-cli draft create # 创建草稿(AI 生成 tags + 合并指示)`,
+	Long: `# draft - 草稿管理
+kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿(AI 提取 tags + 生成合并指示)
+kb-cli draft list [--json] # 列出待审阅区的所有活跃草稿
+kb-cli draft convert [目录名] # 转换老格式单文件草稿到新格式(NNN-标题/draft.md + merge.md)`,
 }
 
 var draftCreateCmd = &cobra.Command{
 	Use:   "create",
 	Short: "创建草稿",
-	Long:  `# draft create - 创建草稿
+	Long: `# draft create - 创建草稿
 kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿,自动提取 tags 并生成合并指示`,
 	RunE: runDraftCreate,
+}
+
+var draftListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "列出所有活跃草稿",
+	Long: `# draft list - 列出所有活跃草稿
+kb-cli draft list [--json] # 列出待审阅区的所有草稿(目录结构 + 老格式单文件)`,
+	RunE: runDraftList,
+}
+
+var draftConvertCmd = &cobra.Command{
+	Use:   "convert [目录名]",
+	Short: "转换老格式草稿到新格式",
+	Long: `# draft convert - 转换老格式草稿到新格式
+kb-cli draft convert # 扫描所有待审阅子目录,转换老格式单文件
+kb-cli draft convert TAPD提取 # 只转换指定子目录`,
+	RunE: runDraftConvert,
 }
 
 var tagsCmd = &cobra.Command{
@@ -38,13 +59,15 @@
 }
 
 var (
-	draftType       string
-	draftTitle      string
+	draftType        string
+	draftTitle       string
 	draftContentFile string
-	draftSource     string
-	draftForce      bool
-	tagsLimit       int
-	tagsDryRun      bool
+	draftSource      string
+	draftForce       bool
+	draftJSON        bool
+	tagsLimit        int
+	tagsDryRun       bool
+	convertTarget    string
 )
 
 func init() {
@@ -58,7 +81,14 @@
 	draftCreateCmd.MarkFlagRequired("title")
 	draftCreateCmd.MarkFlagRequired("content-file")
 
+	// draft list 命令
+	draftListCmd.Flags().BoolVar(&draftJSON, "json", false, "JSON 格式输出")
+
+	// draft convert 命令(无额外参数,target 从 args 读取)
+
 	draftCmd.AddCommand(draftCreateCmd)
+	draftCmd.AddCommand(draftListCmd)
+	draftCmd.AddCommand(draftConvertCmd)
 
 	// tags rebuild 命令
 	tagsRebuildCmd.Flags().IntVar(&tagsLimit, "limit", 0, "限制处理文档数量(0=全部)")
@@ -117,3 +147,70 @@
 
 	return nil
 }
+
+// runDraftList 列出所有活跃草稿
+func runDraftList(cmd *cobra.Command, args []string) error {
+	// 创建草稿录入器
+	intake := draft.NewIntake(vaultPath, nil)
+
+	// 列出草稿
+	drafts, err := intake.ListDrafts()
+	if err != nil {
+		return fmt.Errorf("列出草稿失败: %w", err)
+	}
+
+	if draftJSON {
+		// JSON 输出
+		output := struct {
+			Drafts []draft.DraftInfo `json:"drafts"`
+			Total  int               `json:"total"`
+		}{
+			Drafts: drafts,
+			Total:  len(drafts),
+		}
+		encoder := json.NewEncoder(os.Stdout)
+		encoder.SetIndent("", "  ")
+		return encoder.Encode(output)
+	}
+
+	// 表格输出
+	if len(drafts) == 0 {
+		fmt.Println("没有活跃草稿")
+		return nil
+	}
+
+	fmt.Printf("找到 %d 个活跃草稿:\n\n", len(drafts))
+	for _, d := range drafts {
+		mergeStatus := "❌"
+		if d.HasMergeHint {
+			mergeStatus = "✅"
+		}
+		fmt.Printf("• %s\n", d.Title)
+		fmt.Printf("  路径: %s\n", d.Filepath)
+		fmt.Printf("  类型: %s | 状态: %s | 合并指示: %s\n", d.Type, d.Status, mergeStatus)
+		fmt.Printf("  创建: %s\n\n", d.Created)
+	}
+
+	return nil
+}
+
+// runDraftConvert 转换老格式草稿到新格式
+func runDraftConvert(cmd *cobra.Command, args []string) error {
+	// 可选参数:指定子目录
+	target := ""
+	if len(args) > 0 {
+		target = args[0]
+	}
+
+	// 创建草稿录入器
+	intake := draft.NewIntake(vaultPath, nil)
+
+	// 转换草稿
+	converted, err := intake.ConvertOldDrafts(target)
+	if err != nil {
+		return fmt.Errorf("转换草稿失败: %w", err)
+	}
+
+	fmt.Printf("\n✅ 成功转换 %d 个草稿\n", converted)
+	return nil
+}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index 841c876..dfff961 100644
--- a/cmd/rebuild.go
+++ b/cmd/rebuild.go
@@ -39,6 +39,7 @@
 	}
 
 	// 写入边,将 BuildGraph ID 转换为 SQLite ID
+	actualEdgeCount := 0
 	for _, e := range g.Edges {
 		fromID, ok := idMap[e.FromNode]
 		if !ok {
@@ -59,6 +60,7 @@
 		if err := store.InsertEdge(edge); err != nil {
 			return fmt.Errorf("插入边失败: %w", err)
 		}
+		actualEdgeCount++
 	}
 
 	// 创建并填充 FTS5 索引
@@ -79,6 +81,6 @@
 		return fmt.Errorf("记录构建时间失败: %w", err)
 	}
 
-	fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), len(g.Edges))
+	fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), actualEdgeCount)
 	return nil
 }
diff --git a/cmd/review.go b/cmd/review.go
new file mode 100644
index 0000000..a84025b
--- /dev/null
+++ b/cmd/review.go
@@ -0,0 +1,113 @@
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/aisim/kb-cli/internal/llm"
+	"github.com/aisim/kb-cli/internal/review"
+	"github.com/spf13/cobra"
+)
+
+var reviewCmd = &cobra.Command{
+	Use:   "review",
+	Short: "审阅预览",
+	Long: `# review - 审阅预览
+kb-cli review <草稿目录> [--json] # 读取草稿和合并指示,使用 LLM 生成审阅预览`,
+	Args: cobra.ExactArgs(1),
+	RunE: runReview,
+}
+
+var reviewJSON bool
+
+func init() {
+	reviewCmd.Flags().BoolVar(&reviewJSON, "json", false, "JSON 格式输出")
+	rootCmd.AddCommand(reviewCmd)
+}
+
+func runReview(cmd *cobra.Command, args []string) error {
+	draftDir := args[0]
+
+	// 读取草稿和合并指示
+	preview, err := review.ReadDraftAndMerge(vaultPath, draftDir)
+	if err != nil {
+		return fmt.Errorf("读取草稿失败: %w", err)
+	}
+
+	// 如果有目标FAQ,用 LLM 生成3句话概要
+	if preview.TargetFAQ != nil && preview.TargetFAQ.Title != "" {
+		targetPath := preview.TargetFAQ.Path
+		if !filepath.IsAbs(targetPath) {
+			targetPath = filepath.Join(vaultPath, targetPath)
+		}
+
+		targetContent, err := os.ReadFile(targetPath)
+		if err == nil && len(targetContent) > 0 {
+			llmClient := llm.NewClient()
+			summary, err := llmClient.GenerateReviewPreview(preview.TargetFAQ.Title, string(targetContent))
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "警告: LLM 生成概要失败: %v\n", err)
+			} else {
+				preview.TargetFAQ.Summary = summary
+			}
+		}
+	}
+
+	// 输出
+	if reviewJSON {
+		output, err := review.FormatPreviewJSON(preview)
+		if err != nil {
+			return fmt.Errorf("格式化输出失败: %w", err)
+		}
+		fmt.Println(output)
+	} else {
+		// 表格输出
+		fmt.Println(review.FormatPreviewTable(preview))
+
+		// 如果有草稿正文,也展示主要内容
+		expandedVault := expandHomePath(vaultPath)
+		draftPath := filepath.Join(expandedVault, draftDir, "draft.md")
+		draftContent, err := os.ReadFile(draftPath)
+		if err == nil {
+			content := extractMainContent(string(draftContent))
+			if content != "" {
+				fmt.Println("### 草稿主要内容\n")
+				fmt.Println(content)
+			}
+		}
+	}
+
+	return nil
+}
+
+// extractMainContent 从 draft.md 中提取正文(去掉 frontmatter)
+func extractMainContent(content string) string {
+	if !strings.HasPrefix(content, "---") {
+		return content
+	}
+
+	// 找到第二个 ---
+	endIdx := strings.Index(content[3:], "\n---")
+	if endIdx == -1 {
+		return content
+	}
+
+	// 返回 frontmatter 之后的内容
+	mainContent := content[endIdx+6:]
+	return strings.TrimSpace(mainContent)
+}
+
+// expandHomePath 展开路径中的 ~
+func expandHomePath(path string) string {
+	if strings.HasPrefix(path, "~/") {
+		homeDir, _ := os.UserHomeDir()
+		return filepath.Join(homeDir, path[2:])
+	}
+	return path
+}
+
+// 保留 json 包引用
+var _ = json.Marshal
diff --git a/cmd/root.go b/cmd/root.go
index 6353e30..83fa380 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -6,6 +6,7 @@
 	"strings"
 
 	"github.com/spf13/cobra"
+	"github.com/aisim/kb-cli/internal/llm"
 )
 
 var (
@@ -80,11 +81,22 @@
 
 func init() {
 	homeDir, _ := os.UserHomeDir()
-	defaultVault := os.Getenv("KB_VAULT")
+
+	// 从配置文件读取默认值(优先级:配置文件 > 环境变量 > 内置默认值)
+	cfg := llm.LoadConfig()
+	defaultVault := cfg.KnowledgeBase.VaultPath
+	defaultDB := cfg.KnowledgeBase.DBPath
+
+	// 如果配置文件没有设置,使用环境变量或默认值
 	if defaultVault == "" {
-		defaultVault = homeDir + "/aisim/note/001/笔记001"
+		defaultVault = os.Getenv("KB_VAULT")
+		if defaultVault == "" {
+			defaultVault = homeDir + "/aisim/note/001/笔记001"
+		}
 	}
-	defaultDB := homeDir + "/.cache/kb-cli/kb.db"
+	if defaultDB == "" {
+		defaultDB = homeDir + "/.cache/kb-cli/kb.db"
+	}
 
 	rootCmd.PersistentFlags().StringVar(&vaultPath, "vault", defaultVault, "知识库根目录")
 	rootCmd.PersistentFlags().StringVar(&dbPath, "db", defaultDB, "索引数据库路径")
diff --git a/internal/draft/intake.go b/internal/draft/intake.go
index 49b4545..0f4c36f 100644
--- a/internal/draft/intake.go
+++ b/internal/draft/intake.go
@@ -22,6 +22,17 @@
 	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"`
@@ -99,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
@@ -137,7 +149,8 @@
 	// 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可)
 	// 这样即使某些 tag 匹配不到,其他 tag 也能找到结果
 	opts := search.SearchOptions{
-		TopN: 3,
+		TopN:        3,
+		WithContent: true, // 需要内容给 LLM 评估
 	}
 
 	results, err := search.Search(i.store, tags, opts)
@@ -157,10 +170,16 @@
 
 	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,
 		})
 	}
 
@@ -443,6 +462,34 @@
 	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
 func (i *Intake) RebuildTags(limit int, dryRun bool) error {
 	// 扫描知识库所有 .md 文件
@@ -567,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
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
index a4a52bf..883cc63 100644
--- a/internal/llm/client.go
+++ b/internal/llm/client.go
@@ -70,19 +70,20 @@
 
 // SearchCandidate 搜索候选
 type SearchCandidate struct {
-	Title string  `json:"title"`
-	Path  string  `json:"path"`
-	Score float64 `json:"score"`
+	Title   string  `json:"title"`
+	Path    string  `json:"path"`
+	Score   float64 `json:"score"`
+	Content string  `json:"content,omitempty"` // 文件内容(截取前5000字)
 }
 
 // NewClient 创建 LLM 客户端
 func NewClient() *Client {
-	config := loadConfig()
+	config := LoadConfig()
 	return &Client{config: config, usePrimary: true}
 }
 
-// loadConfig 加载配置文件
-func loadConfig() Config {
+// LoadConfig 加载配置文件(导出供其他包使用)
+func LoadConfig() Config {
 	var config Config
 
 	// 配置文件路径
@@ -193,17 +194,25 @@
 }
 
 // GenerateMergeHint 生成合并指示
-func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate) (*MergeHint, error) {
+func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate, dirStructure string) (*MergeHint, error) {
 	prompt := fmt.Sprintf(`你是一个知识库管理助手。请判断以下草稿与哪些知识库文档相关。
+
+## 知识库目录结构
+%s
 
 ## 草稿内容
 %s
 
 ## 候选文档(Top 3)
-`, draftContent)
+`, dirStructure, draftContent)
 
 	for i, cand := range candidates {
-		prompt += fmt.Sprintf("%d. 标题: %s, 路径: %s, 相关度: %.2f\n", i+1, cand.Title, cand.Path, cand.Score)
+		prompt += fmt.Sprintf("### %d. %s\n", i+1, cand.Title)
+		prompt += fmt.Sprintf("- 路径: %s\n", cand.Path)
+		prompt += fmt.Sprintf("- 相关度: %.2f\n\n", cand.Score)
+		if cand.Content != "" {
+			prompt += fmt.Sprintf("**文档内容**:\n```\n%s\n```\n\n", cand.Content)
+		}
 	}
 
 	prompt += `
@@ -213,7 +222,7 @@
 {
   "recommendation": {
     "action": "merge|new|split",
-    "target": "目标路径(如果 action=merge)",
+    "target": "目标路径(如果 action=merge,必须是上述目录结构中存在的目录)",
     "reason": "判断理由",
     "confidence": "high|medium|low"
   },
@@ -228,11 +237,16 @@
 
 判断标准:
 - action=merge: 草稿内容与某个候选文档高度相关,应该合并
-- action=new: 草稿内容是全新的,应该新建文档
+- action=new: 草稿内容是全新的,应该新建文档(target 填写建议的目录路径)
 - action=split: 草稿内容包含多个独立主题,应该拆分
 - confidence=high: 判断很确定
 - confidence=medium: 判断比较确定
 - confidence=low: 判断不太确定
+
+分类原则:
+- 平台专属内容放对应平台目录下(如电子秤平台/智能枪/)
+- 多平台共用的硬件知识放 共有硬件/ 下
+- 跨平台通用知识放 通用/通用/
 
 只输出 JSON,不要其他内容。`
 
@@ -249,6 +263,37 @@
 	return &result, nil
 }
 
+// GenerateReviewPreview 生成审阅预览概要(3句话概括目标FAQ)
+func (c *Client) GenerateReviewPreview(targetTitle, targetContent string) (string, error) {
+	// 截取前2000字符作为上下文
+	contentPreview := targetContent
+	if len(contentPreview) > 2000 {
+		contentPreview = contentPreview[:2000] + "..."
+	}
+
+	prompt := fmt.Sprintf(`请用3句话概括以下知识库文档的核心内容,帮助用户快速了解这个文档讲的是什么:
+
+## 文档标题
+%s
+
+## 文档内容(部分)
+%s
+
+要求:
+1. 第一句:这个文档解决什么问题/讲什么主题
+2. 第二句:核心内容/关键要点
+3. 第三句:适用场景/使用条件
+
+直接输出3句话,不要编号,不要其他内容。`, targetTitle, contentPreview)
+
+	response, err := c.callLLMWithRetry(prompt)
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(response), nil
+}
+
 // ClassifyDocuments 分类文档
 func (c *Client) ClassifyDocuments(prompt string) (string, error) {
 	response, err := c.callLLMWithRetry(prompt)
diff --git a/internal/review/preview.go b/internal/review/preview.go
new file mode 100644
index 0000000..a6841ff
--- /dev/null
+++ b/internal/review/preview.go
@@ -0,0 +1,292 @@
+package review
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+// DraftInfo 草稿基本信息
+type DraftInfo struct {
+	Path   string   `json:"path"`
+	Title  string   `json:"title"`
+	Source string   `json:"source"`
+	Tags   []string `json:"tags"`
+}
+
+// MergeHint 合并指示
+type MergeHint struct {
+	Action     string `json:"action"`
+	Target     string `json:"target"`
+	Reason     string `json:"reason"`
+	Confidence string `json:"confidence"`
+}
+
+// TargetFAQ 目标FAQ信息
+type TargetFAQ struct {
+	Path    string `json:"path"`
+	Title   string `json:"title"`
+	Summary string `json:"summary"` // LLM 生成的3句话概要
+}
+
+// ReviewPreview 审阅预览结果
+type ReviewPreview struct {
+	Draft        DraftInfo  `json:"draft"`
+	DraftContent string     `json:"draft_content"` // 草稿正文(去掉frontmatter)
+	MergeHint    MergeHint  `json:"merge_hint"`
+	TargetFAQ    *TargetFAQ `json:"target_faq,omitempty"`
+}
+
+// expandPath 展开路径中的 ~
+func expandPath(path string) string {
+	if strings.HasPrefix(path, "~/") {
+		homeDir, _ := os.UserHomeDir()
+		return filepath.Join(homeDir, path[2:])
+	}
+	return path
+}
+
+// extractMainContent 从 draft.md 中提取正文(去掉 frontmatter)
+func extractMainContent(content string) string {
+	if !strings.HasPrefix(content, "---") {
+		return content
+	}
+
+	// 找到第二个 ---
+	endIdx := strings.Index(content[3:], "\n---")
+	if endIdx == -1 {
+		return content
+	}
+
+	// 返回 frontmatter 之后的内容
+	mainContent := content[endIdx+6:]
+	return strings.TrimSpace(mainContent)
+}
+
+// ReadDraftAndMerge 读取草稿目录的 draft.md 和 merge.md
+func ReadDraftAndMerge(vaultPath, draftDir string) (*ReviewPreview, error) {
+	// 展开路径中的 ~
+	vaultPath = expandPath(vaultPath)
+
+	// 构建完整路径
+	fullDraftDir := draftDir
+	if !filepath.IsAbs(draftDir) {
+		fullDraftDir = filepath.Join(vaultPath, draftDir)
+	}
+
+	// 检查目录是否存在
+	if _, err := os.Stat(fullDraftDir); os.IsNotExist(err) {
+		return nil, fmt.Errorf("目录不存在: %s", fullDraftDir)
+	}
+
+	// 读取 draft.md
+	draftPath := filepath.Join(fullDraftDir, "draft.md")
+	draftContent, err := os.ReadFile(draftPath)
+	if err != nil {
+		return nil, fmt.Errorf("读取 draft.md 失败: %w", err)
+	}
+
+	// 解析 draft.md frontmatter
+	draftMeta := parseFrontmatter(string(draftContent))
+	draftInfo := DraftInfo{
+		Path:   draftDir,
+		Title:  draftMeta["title"],
+		Source: draftMeta["source"],
+		Tags:   parseTags(draftMeta["tags"]),
+	}
+
+	// 读取 merge.md
+	mergePath := filepath.Join(fullDraftDir, "merge.md")
+	mergeHint := MergeHint{
+		Action:     "new",
+		Confidence: "low",
+	}
+
+	if mergeContent, err := os.ReadFile(mergePath); err == nil {
+		mergeHint = parseMergeMd(string(mergeContent))
+	}
+
+	// 构建预览结果
+	preview := &ReviewPreview{
+		Draft:        draftInfo,
+		DraftContent: extractMainContent(string(draftContent)),
+		MergeHint:    mergeHint,
+	}
+
+	// 如果 action=merge,读取目标FAQ
+	if mergeHint.Action == "merge" && mergeHint.Target != "" {
+		targetPath := mergeHint.Target
+		if !filepath.IsAbs(targetPath) {
+			targetPath = filepath.Join(vaultPath, targetPath)
+		}
+
+		if targetContent, err := os.ReadFile(targetPath); err == nil {
+			targetMeta := parseFrontmatter(string(targetContent))
+			preview.TargetFAQ = &TargetFAQ{
+				Path:  mergeHint.Target,
+				Title: targetMeta["title"],
+			}
+		}
+	}
+
+	return preview, nil
+}
+
+// parseFrontmatter 解析 YAML 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
+	}
+
+	yamlStr := content[3 : endIdx+3]
+	var data map[string]interface{}
+	if err := yaml.Unmarshal([]byte(yamlStr), &data); err != nil {
+		return result
+	}
+
+	// 转换为 string map
+	for k, v := range data {
+		switch val := v.(type) {
+		case string:
+			result[k] = val
+		case []interface{}:
+			// tags 可能是数组
+			var tags []string
+			for _, t := range val {
+				if s, ok := t.(string); ok {
+					tags = append(tags, s)
+				}
+			}
+			if len(tags) > 0 {
+				result[k] = strings.Join(tags, ",")
+			}
+		}
+	}
+
+	return result
+}
+
+// parseTags 解析 tags 字符串为数组
+func parseTags(tagsStr string) []string {
+	if tagsStr == "" {
+		return []string{}
+	}
+	tags := strings.Split(tagsStr, ",")
+	result := make([]string, 0, len(tags))
+	for _, t := range tags {
+		t = strings.TrimSpace(t)
+		if t != "" {
+			result = append(result, t)
+		}
+	}
+	return result
+}
+
+// parseMergeMd 解析 merge.md 内容
+func parseMergeMd(content string) MergeHint {
+	result := MergeHint{
+		Action:     "new",
+		Confidence: "low",
+	}
+
+	// 解析 frontmatter 中的 confidence
+	fmMatch := regexp.MustCompile(`(?s)^---\s*\n(.*?)\n---`).FindStringSubmatch(content)
+	if len(fmMatch) > 1 {
+		var fm map[string]interface{}
+		if err := yaml.Unmarshal([]byte(fmMatch[1]), &fm); err == nil {
+			if conf, ok := fm["confidence"].(string); ok {
+				result.Confidence = conf
+			}
+		}
+	}
+
+	// 提取操作类型
+	if match := regexp.MustCompile(`\*\*操作类型\*\*:\s*(\w+)`).FindStringSubmatch(content); len(match) > 1 {
+		result.Action = match[1]
+	}
+
+	// 提取目标
+	if match := regexp.MustCompile(`\*\*目标\*\*:[ \t]*(.+)`).FindStringSubmatch(content); len(match) > 1 {
+		result.Target = strings.TrimSpace(match[1])
+	}
+
+	// 提取理由
+	if match := regexp.MustCompile(`\*\*理由\*\*:[ \t]*(.+)`).FindStringSubmatch(content); len(match) > 1 {
+		result.Reason = strings.TrimSpace(match[1])
+	}
+
+	return result
+}
+
+// FormatPreviewJSON 格式化预览结果为 JSON
+func FormatPreviewJSON(preview *ReviewPreview) (string, error) {
+	data, err := json.MarshalIndent(preview, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+// FormatPreviewTable 格式化预览结果为表格
+func FormatPreviewTable(preview *ReviewPreview) string {
+	var sb strings.Builder
+
+	sb.WriteString("## 📋 审阅预览\n\n")
+
+	// 草稿信息
+	sb.WriteString("### 草稿信息\n\n")
+	sb.WriteString(fmt.Sprintf("**标题:** %s\n", preview.Draft.Title))
+	sb.WriteString(fmt.Sprintf("**路径:** `%s`\n", preview.Draft.Path))
+	if preview.Draft.Source != "" {
+		sb.WriteString(fmt.Sprintf("**来源:** %s\n", preview.Draft.Source))
+	}
+	if len(preview.Draft.Tags) > 0 {
+		sb.WriteString(fmt.Sprintf("**标签:** %s\n", strings.Join(preview.Draft.Tags, ", ")))
+	}
+	sb.WriteString("\n")
+
+	// 合并指示
+	sb.WriteString("### 合并指示\n\n")
+	sb.WriteString(fmt.Sprintf("**操作类型:** %s\n", preview.MergeHint.Action))
+	if preview.MergeHint.Target != "" {
+		sb.WriteString(fmt.Sprintf("**目标:** `%s`\n", preview.MergeHint.Target))
+	}
+	if preview.MergeHint.Reason != "" {
+		sb.WriteString(fmt.Sprintf("**理由:** %s\n", preview.MergeHint.Reason))
+	}
+	sb.WriteString(fmt.Sprintf("**置信度:** %s\n\n", preview.MergeHint.Confidence))
+
+	// 目标FAQ概要
+	if preview.TargetFAQ != nil {
+		sb.WriteString("### 目标FAQ概要\n\n")
+		sb.WriteString(fmt.Sprintf("**标题:** %s\n", preview.TargetFAQ.Title))
+		sb.WriteString(fmt.Sprintf("**路径:** `%s`\n", preview.TargetFAQ.Path))
+		if preview.TargetFAQ.Summary != "" {
+			sb.WriteString(fmt.Sprintf("**概要:**\n%s\n", preview.TargetFAQ.Summary))
+		}
+		sb.WriteString("\n")
+	}
+
+	// 操作选项
+	sb.WriteString("---\n\n")
+	sb.WriteString("**请确认:**\n")
+	sb.WriteString("- A) 追加/合并到目标 FAQ\n")
+	sb.WriteString("- B) 归档(保留历史但不合并)\n")
+	sb.WriteString("- C) 删除(内容简单无复用价值)\n")
+	sb.WriteString("- D) 保留跟进(更新提醒日期)\n")
+
+	return sb.String()
+}

--
Gitblit v1.9.1