ai_xiaopei
6 days ago 1196f409d86bc61e7596eb274840244a62ce84ba
feat: config priority over env vars, export LoadConfig, add review feature

- Change config loading priority: config file > env vars > defaults
- Export LoadConfig() for use in cmd package
- Fix search/engine.go duplicate loop
- Add draft intake and review commands
- Build with -tags fts5 for FTS5 support
6 files modified
2 files added
992 ■■■■■ changed files
bin/kb-cli patch | view | raw | blame | history
cmd/draft.go 113 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go 4 ●●● patch | view | raw | blame | history
cmd/review.go 113 ●●●●● patch | view | raw | blame | history
cmd/root.go 18 ●●●● patch | view | raw | blame | history
internal/draft/intake.go 385 ●●●●● patch | view | raw | blame | history
internal/llm/client.go 67 ●●●● patch | view | raw | blame | history
internal/review/preview.go 292 ●●●●● patch | view | raw | blame | history
bin/kb-cli
Binary files differ
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
}
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
}
cmd/review.go
New file
@@ -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
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, "索引数据库路径")
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
}
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)
internal/review/preview.go
New file
@@ -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()
}