ai_xiaopei
9 days ago cf162a137caffa5f25f646220a636d78e8253864
feat: 添加草稿创建功能

- 新增 draft create 命令,支持 AI 生成 tags 和合并指示
- 新增 tags rebuild 命令,支持批量重建现有文档的 tags
- 集成 LLM 客户端,支持主备配置自动切换
- 配置文件路径: ~/.kb-cli/config.yaml
- 草稿存储路径: ~/aisim/note/001/笔记001/待审阅/
1 files modified
3 files added
1014 ■■■■■ changed files
bin/kb-cli patch | view | raw | blame | history
cmd/draft.go 130 ●●●●● patch | view | raw | blame | history
internal/draft/intake.go 509 ●●●●● patch | view | raw | blame | history
internal/llm/client.go 375 ●●●●● patch | view | raw | blame | history
bin/kb-cli
Binary files differ
cmd/draft.go
New file
@@ -0,0 +1,130 @@
package cmd
import (
    "fmt"
    "os"
    "github.com/aisim/kb-cli/internal/draft"
    "github.com/aisim/kb-cli/internal/index"
    "github.com/spf13/cobra"
)
var draftCmd = &cobra.Command{
    Use:   "draft",
    Short: "草稿管理",
    Long:  `kb-cli draft create # 创建草稿(AI 生成 tags + 合并指示)`,
}
var draftCreateCmd = &cobra.Command{
    Use:   "create",
    Short: "创建草稿",
    Long: `kb-cli draft create --type <类型> --title <标题> --content-file <内容文件> [--source <来源>] [--force]
创建草稿,自动提取 tags 并生成合并指示。
类型: 售后/产品/运营/行业/TAPD
示例:
  kb-cli draft create --type 售后 --title "充不进气问题" --content-file /tmp/draft.md
  kb-cli draft create --type TAPD --title "档案下载失败" --content-file /tmp/bug.md --source "TAPD#12345"`,
    RunE: runDraftCreate,
}
var tagsCmd = &cobra.Command{
    Use:   "tags",
    Short: "标签管理",
    Long:  `kb-cli tags rebuild # 批量重建现有文档的 tags`,
}
var tagsRebuildCmd = &cobra.Command{
    Use:   "rebuild",
    Short: "批量重建 tags",
    Long: `kb-cli tags rebuild [--vault <路径>] [--limit <数量>] [--dry-run]
批量重建现有文档的 tags,使用 LLM 重新提取。
示例:
  kb-cli tags rebuild --limit 10           # 重建前 10 个文档的 tags
  kb-cli tags rebuild --dry-run            # 仅预览,不实际更新`,
    RunE: runTagsRebuild,
}
var (
    draftType       string
    draftTitle      string
    draftContentFile string
    draftSource     string
    draftForce      bool
    tagsLimit       int
    tagsDryRun      bool
)
func init() {
    // draft create 命令
    draftCreateCmd.Flags().StringVar(&draftType, "type", "", "草稿类型(售后/产品/运营/行业/TAPD)")
    draftCreateCmd.Flags().StringVar(&draftTitle, "title", "", "草稿标题")
    draftCreateCmd.Flags().StringVar(&draftContentFile, "content-file", "", "草稿内容文件路径")
    draftCreateCmd.Flags().StringVar(&draftSource, "source", "", "来源标识")
    draftCreateCmd.Flags().BoolVar(&draftForce, "force", false, "强制创建(跳过重复检查)")
    draftCreateCmd.MarkFlagRequired("type")
    draftCreateCmd.MarkFlagRequired("title")
    draftCreateCmd.MarkFlagRequired("content-file")
    draftCmd.AddCommand(draftCreateCmd)
    // tags rebuild 命令
    tagsRebuildCmd.Flags().IntVar(&tagsLimit, "limit", 0, "限制处理文档数量(0=全部)")
    tagsRebuildCmd.Flags().BoolVar(&tagsDryRun, "dry-run", false, "仅预览,不实际更新")
    tagsCmd.AddCommand(tagsRebuildCmd)
    rootCmd.AddCommand(draftCmd)
    rootCmd.AddCommand(tagsCmd)
}
func runDraftCreate(cmd *cobra.Command, args []string) error {
    // 读取内容文件
    content, err := os.ReadFile(draftContentFile)
    if err != nil {
        return fmt.Errorf("读取内容文件失败: %w", err)
    }
    if len(content) == 0 {
        return fmt.Errorf("内容文件为空")
    }
    // 打开索引
    store, err := index.Open(dbPath)
    if err != nil {
        return fmt.Errorf("打开索引失败: %w", err)
    }
    defer store.Close()
    // 创建草稿录入器
    intake := draft.NewIntake(vaultPath, store)
    // 创建草稿
    if err := intake.CreateDraft(draftType, draftTitle, string(content), draftSource, draftForce); err != nil {
        return fmt.Errorf("创建草稿失败: %w", err)
    }
    return nil
}
func runTagsRebuild(cmd *cobra.Command, args []string) error {
    // 打开索引
    store, err := index.Open(dbPath)
    if err != nil {
        return fmt.Errorf("打开索引失败: %w", err)
    }
    defer store.Close()
    // 创建草稿录入器
    intake := draft.NewIntake(vaultPath, store)
    // 重建 tags
    if err := intake.RebuildTags(tagsLimit, tagsDryRun); err != nil {
        return fmt.Errorf("重建 tags 失败: %w", err)
    }
    return nil
}
internal/draft/intake.go
New file
@@ -0,0 +1,509 @@
package draft
import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "regexp"
    "sort"
    "strings"
    "time"
    "github.com/aisim/kb-cli/internal/index"
    "github.com/aisim/kb-cli/internal/llm"
    "github.com/aisim/kb-cli/internal/search"
)
// Intake 草稿录入器
type Intake struct {
    vaultPath string
    llmClient *llm.Client
    store     *index.Store
}
// 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"`
}
// Counter 计数器
type Counter struct {
    Week    string `json:"week"`
    Next    int    `json:"next"`
    ResetDay string `json:"reset_day"`
    LastUpdated string `json:"last_updated"`
}
// NewIntake 创建草稿录入器
func NewIntake(vaultPath string, store *index.Store) *Intake {
    // 展开 ~ 为实际的用户目录
    if strings.HasPrefix(vaultPath, "~/") {
        if home, err := os.UserHomeDir(); err == nil {
            vaultPath = filepath.Join(home, vaultPath[2:])
        }
    }
    return &Intake{
        vaultPath: vaultPath,
        llmClient: llm.NewClient(),
        store:     store,
    }
}
// CreateDraft 创建草稿
func (i *Intake) CreateDraft(draftType, title, content, source string, force bool) error {
    // 1. 调用 LLM 提取 tags
    fmt.Println("正在提取 tags...")
    extractResult, err := i.llmClient.ExtractTags(content)
    if err != nil {
        return fmt.Errorf("提取 tags 失败: %w", err)
    }
    fmt.Printf("提取到 %d 个 tags: %v\n", len(extractResult.Tags), extractResult.Tags)
    // 2. 使用 tags 搜索知识库
    fmt.Println("正在搜索相关文档...")
    candidates, err := i.searchByTags(extractResult.Tags)
    if err != nil {
        return fmt.Errorf("搜索知识库失败: %w", err)
    }
    fmt.Printf("找到 %d 个相关文档\n", len(candidates))
    // 3. 调用 LLM 生成合并指示
    var mergeHint *llm.MergeHint
    if len(candidates) > 0 {
        fmt.Println("正在生成合并指示...")
        mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates)
        if err != nil {
            fmt.Printf("警告: 生成合并指示失败: %v\n", err)
            mergeHint = nil
        }
    }
    // 4. 创建草稿目录和文件
    draftDir, err := i.createDraftDir(draftType, title, content, source, extractResult.Tags, force)
    if err != nil {
        return fmt.Errorf("创建草稿目录失败: %w", err)
    }
    // 5. 写入 merge.md
    if mergeHint != nil {
        if err := i.writeMergeHint(draftDir, mergeHint, candidates); err != nil {
            fmt.Printf("警告: 写入合并指示失败: %v\n", err)
        }
    }
    fmt.Printf("草稿创建成功: %s\n", draftDir)
    return nil
}
// searchByTags 使用 tags 搜索知识库
func (i *Intake) searchByTags(tags []string) ([]llm.SearchCandidate, error) {
    if len(tags) == 0 {
        return nil, nil
    }
    // 使用所有 tags 作为关键词搜索
    opts := search.SearchOptions{
        TopN: 3,
    }
    results, err := search.Search(i.store, tags, opts)
    if err != nil {
        return nil, err
    }
    candidates := make([]llm.SearchCandidate, 0, len(results))
    for _, r := range results {
        candidates = append(candidates, llm.SearchCandidate{
            Title: r.Title,
            Path:  r.Path,
            Score: float64(r.Score),
        })
    }
    return candidates, nil
}
// createDraftDir 创建草稿目录和文件
func (i *Intake) createDraftDir(draftType, title, content, source string, tags []string, force bool) (string, error) {
    // 确定子目录
    subDir := i.getSubDir(draftType)
    reviewDir := filepath.Join(i.vaultPath, "待审阅", subDir)
    // 检查是否已存在相同标题的草稿
    if !force {
        if err := i.checkDuplicate(reviewDir, title); err != nil {
            return "", err
        }
    }
    // 获取下一个编号
    seq, err := i.getNextSeq(reviewDir)
    if err != nil {
        return "", fmt.Errorf("获取编号失败: %w", err)
    }
    // 生成目录名
    now := time.Now()
    dateStr := now.Format("20060102")
    safeTitle := i.sanitizeTitle(title)
    dirName := fmt.Sprintf("%03d-%s-%s", seq, dateStr, safeTitle)
    draftDir := filepath.Join(reviewDir, dirName)
    // 创建目录
    if err := os.MkdirAll(draftDir, 0755); err != nil {
        return "", fmt.Errorf("创建目录失败: %w", err)
    }
    // 写入 draft.md
    draftPath := filepath.Join(draftDir, "draft.md")
    if err := i.writeDraftFile(draftPath, title, draftType, source, tags, content); err != nil {
        return "", fmt.Errorf("写入 draft.md 失败: %w", err)
    }
    return draftDir, nil
}
// getSubDir 获取子目录名
func (i *Intake) getSubDir(draftType string) string {
    typeMap := map[string]string{
        "售后": "售后提取",
        "产品": "产品提取",
        "运营": "运营提取",
        "行业": "行业提取",
        "TAPD": "TAPD提取",
    }
    if subDir, ok := typeMap[draftType]; ok {
        return subDir
    }
    return "沟通提取"
}
// checkDuplicate 检查重复
func (i *Intake) checkDuplicate(reviewDir, title string) error {
    entries, err := os.ReadDir(reviewDir)
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    safeTitle := i.sanitizeTitle(title)
    for _, entry := range entries {
        if !entry.IsDir() {
            continue
        }
        // 检查目录名是否包含相同标题
        if strings.Contains(entry.Name(), safeTitle) {
            return fmt.Errorf("待审阅区已存在相同标题的草稿: %s,使用 --force 强制创建", entry.Name())
        }
    }
    return nil
}
// getNextSeq 获取下一个编号
func (i *Intake) getNextSeq(reviewDir string) (int, error) {
    // 读取 counter.json
    counterPath := filepath.Join(i.vaultPath, "待审阅", "counter.json")
    counter, err := i.readCounter(counterPath)
    if err != nil {
        // 如果不存在,从目录中推断
        return i.inferNextSeq(reviewDir)
    }
    // 检查是否需要重置(每周重置)
    now := time.Now()
    currentWeek := i.getWeekNumber(now)
    if counter.Week != currentWeek {
        counter.Week = currentWeek
        counter.Next = 1
        counter.ResetDay = now.Format("2006-01-02")
    }
    nextSeq := counter.Next
    counter.Next++
    counter.LastUpdated = now.Format(time.RFC3339)
    // 写回 counter.json
    if err := i.writeCounter(counterPath, counter); err != nil {
        return 0, fmt.Errorf("更新计数器失败: %w", err)
    }
    return nextSeq, nil
}
// readCounter 读取计数器
func (i *Intake) readCounter(path string) (*Counter, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var counter Counter
    if err := json.Unmarshal(data, &counter); err != nil {
        return nil, err
    }
    return &counter, nil
}
// writeCounter 写入计数器
func (i *Intake) writeCounter(path string, counter *Counter) error {
    data, err := json.MarshalIndent(counter, "", "  ")
    if err != nil {
        return err
    }
    return os.WriteFile(path, data, 0644)
}
// inferNextSeq 从目录推断下一个编号
func (i *Intake) inferNextSeq(reviewDir string) (int, error) {
    entries, err := os.ReadDir(reviewDir)
    if err != nil {
        if os.IsNotExist(err) {
            return 1, nil
        }
        return 0, err
    }
    maxSeq := 0
    re := regexp.MustCompile(`^(\d{3})-`)
    for _, entry := range entries {
        if !entry.IsDir() {
            continue
        }
        matches := re.FindStringSubmatch(entry.Name())
        if len(matches) > 1 {
            var seq int
            fmt.Sscanf(matches[1], "%d", &seq)
            if seq > maxSeq {
                maxSeq = seq
            }
        }
    }
    return maxSeq + 1, nil
}
// getWeekNumber 获取周数
func (i *Intake) getWeekNumber(t time.Time) string {
    year, week := t.ISOWeek()
    return fmt.Sprintf("%d-W%02d", year, week)
}
// sanitizeTitle 清理标题
func (i *Intake) sanitizeTitle(title string) string {
    // 替换不安全字符
    re := regexp.MustCompile(`[\\/:*?"<>|\s]`)
    safe := re.ReplaceAllString(title, "_")
    // 限制长度
    if len(safe) > 50 {
        safe = safe[:50]
    }
    return safe
}
// writeDraftFile 写入 draft.md
func (i *Intake) writeDraftFile(path, title, draftType, source string, tags []string, content string) error {
    now := time.Now().Format("2006-01-02 15:04:05")
    // 构建 frontmatter
    meta := DraftMeta{
        Title:   title,
        Type:    draftType,
        Status:  "待确认",
        Draft:   true,
        Source:  source,
        Tags:    tags,
        Created: now,
    }
    var sb strings.Builder
    sb.WriteString("---\n")
    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))
    }
    sb.WriteString("tags: [")
    for i, tag := range meta.Tags {
        if i > 0 {
            sb.WriteString(", ")
        }
        sb.WriteString(fmt.Sprintf("%q", tag))
    }
    sb.WriteString("]\n")
    sb.WriteString(fmt.Sprintf("created: %q\n", meta.Created))
    sb.WriteString("---\n\n")
    sb.WriteString(content)
    return os.WriteFile(path, []byte(sb.String()), 0644)
}
// writeMergeHint 写入 merge.md
func (i *Intake) writeMergeHint(draftDir string, hint *llm.MergeHint, candidates []llm.SearchCandidate) 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(fmt.Sprintf("confidence: %q\n", hint.Recommendation.Confidence))
    sb.WriteString("---\n\n")
    sb.WriteString("## 合并指示\n\n")
    sb.WriteString(fmt.Sprintf("**操作类型**: %s\n", hint.Recommendation.Action))
    sb.WriteString(fmt.Sprintf("**目标**: %s\n", hint.Recommendation.Target))
    sb.WriteString(fmt.Sprintf("**理由**: %s\n\n", hint.Recommendation.Reason))
    sb.WriteString("## 依据\n\n")
    sb.WriteString("### 搜索到的相关文档(Top 3)\n\n")
    for i, cand := range candidates {
        sb.WriteString(fmt.Sprintf("%d. **%s** (相似度: %.2f)\n", i+1, cand.Title, cand.Score))
        sb.WriteString(fmt.Sprintf("   - 路径: `%s`\n\n", cand.Path))
    }
    sb.WriteString("## LLM 判断\n\n")
    for _, a := range hint.Analysis {
        sb.WriteString(fmt.Sprintf("- **%s**: %s - %s\n", a.Path, a.Relevance, a.Reason))
    }
    return os.WriteFile(mergePath, []byte(sb.String()), 0644)
}
// RebuildTags 批量重建 tags
func (i *Intake) RebuildTags(limit int, dryRun bool) error {
    // 扫描知识库所有 .md 文件
    files, err := i.scanMarkdownFiles()
    if err != nil {
        return fmt.Errorf("扫描文件失败: %w", err)
    }
    if limit > 0 && len(files) > limit {
        files = files[:limit]
    }
    fmt.Printf("找到 %d 个文件\n", len(files))
    updated := 0
    for _, file := range files {
        fmt.Printf("处理: %s\n", file)
        // 读取文件内容
        content, err := os.ReadFile(file)
        if err != nil {
            fmt.Printf("  警告: 读取失败: %v\n", err)
            continue
        }
        // 提取正文(去掉 frontmatter)
        body := i.extractBody(string(content))
        // 调用 LLM 提取 tags
        extractResult, err := i.llmClient.ExtractTags(body)
        if err != nil {
            fmt.Printf("  警告: 提取 tags 失败: %v\n", err)
            continue
        }
        if dryRun {
            fmt.Printf("  将更新 tags: %v\n", extractResult.Tags)
        } else {
            // 更新文件 frontmatter
            if err := i.updateTagsInFile(file, string(content), extractResult.Tags); err != nil {
                fmt.Printf("  警告: 更新 tags 失败: %v\n", err)
                continue
            }
            fmt.Printf("  已更新 tags: %v\n", extractResult.Tags)
        }
        updated++
    }
    fmt.Printf("处理完成: %d/%d 文件\n", updated, len(files))
    return nil
}
// scanMarkdownFiles 扫描 markdown 文件
func (i *Intake) scanMarkdownFiles() ([]string, error) {
    var files []string
    // 扫描 FAQ、知识、文档目录
    dirs := []string{"FAQ", "知识", "文档"}
    for _, dir := range dirs {
        dirPath := filepath.Join(i.vaultPath, dir)
        if _, err := os.Stat(dirPath); os.IsNotExist(err) {
            continue
        }
        err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.IsDir() && strings.HasSuffix(path, ".md") {
                files = append(files, path)
            }
            return nil
        })
        if err != nil {
            return nil, err
        }
    }
    // 排序
    sort.Strings(files)
    return files, nil
}
// extractBody 提取正文(去掉 frontmatter)
func (i *Intake) extractBody(content string) string {
    // 查找 frontmatter 结束位置
    re := regexp.MustCompile(`(?s)^---\n.*?\n---\n`)
    loc := re.FindStringIndex(content)
    if loc != nil {
        return content[loc[1]:]
    }
    return content
}
// updateTagsInFile 更新文件中的 tags
func (i *Intake) updateTagsInFile(path, content string, tags []string) error {
    // 解析现有 frontmatter
    re := regexp.MustCompile(`(?s)^---\n(.*?)\n---`)
    matches := re.FindStringSubmatchIndex(content)
    if matches == nil {
        return fmt.Errorf("未找到 frontmatter")
    }
    frontmatter := content[matches[2]:matches[3]]
    // 替换 tags 行
    tagsRe := regexp.MustCompile(`(?m)^tags:.*$`)
    tagsLine := "tags: ["
    for i, tag := range tags {
        if i > 0 {
            tagsLine += ", "
        }
        tagsLine += fmt.Sprintf("%q", tag)
    }
    tagsLine += "]"
    newFrontmatter := tagsRe.ReplaceAllString(frontmatter, tagsLine)
    // 重建文件内容
    newContent := content[:matches[2]] + newFrontmatter + content[matches[3]:]
    return os.WriteFile(path, []byte(newContent), 0644)
}
internal/llm/client.go
New file
@@ -0,0 +1,375 @@
package llm
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "regexp"
    "strings"
    "time"
    "gopkg.in/yaml.v3"
)
// LLMConfig 单个 LLM 配置
type LLMConfig struct {
    APIBase         string  `yaml:"api_base"`
    APIKey          string  `yaml:"api_key"`
    Model           string  `yaml:"model"`
    Temperature     float64 `yaml:"temperature"`
    MaxTokens       int     `yaml:"max_tokens"`
    DisableThinking bool    `yaml:"disable_thinking"`
}
// Config 配置文件结构
type Config struct {
    LLM struct {
        Primary LLMConfig `yaml:"primary"`
        Fallback *LLMConfig `yaml:"fallback"`
    } `yaml:"llm"`
    KnowledgeBase struct {
        VaultPath string `yaml:"vault_path"`
        DBPath    string `yaml:"db_path"`
    } `yaml:"knowledge_base"`
    Draft struct {
        ReviewDir   string `yaml:"review_dir"`
        DefaultType string `yaml:"default_type"`
    } `yaml:"draft"`
}
// Client LLM 客户端
type Client struct {
    config     Config
    usePrimary bool
}
// ExtractResult 提取结果
type ExtractResult struct {
    Tags        []string `json:"tags"`
    RelatedDocs []string `json:"related_docs"`
}
// MergeHint 合并指示
type MergeHint struct {
    Recommendation struct {
        Action     string `json:"action"`
        Target     string `json:"target"`
        Reason     string `json:"reason"`
        Confidence string `json:"confidence"`
    } `json:"recommendation"`
    Analysis []struct {
        Path      string `json:"path"`
        Relevance string `json:"relevance"`
        Reason    string `json:"reason"`
    } `json:"analysis"`
}
// SearchCandidate 搜索候选
type SearchCandidate struct {
    Title string  `json:"title"`
    Path  string  `json:"path"`
    Score float64 `json:"score"`
}
// NewClient 创建 LLM 客户端
func NewClient() *Client {
    config := loadConfig()
    return &Client{config: config, usePrimary: true}
}
// loadConfig 加载配置文件
func loadConfig() Config {
    var config Config
    // 配置文件路径
    homeDir, _ := os.UserHomeDir()
    configPath := filepath.Join(homeDir, ".kb-cli", "config.yaml")
    // 读取配置文件
    data, err := os.ReadFile(configPath)
    if err != nil {
        // 如果配置文件不存在,使用默认值
        fmt.Fprintf(os.Stderr, "警告: 无法读取配置文件 %s,使用默认值\n", configPath)
        return getDefaultConfig()
    }
    // 解析 YAML
    if err := yaml.Unmarshal(data, &config); err != nil {
        fmt.Fprintf(os.Stderr, "警告: 解析配置文件失败: %v,使用默认值\n", err)
        return getDefaultConfig()
    }
    // 展开 ~ 路径
    config.LLM.Primary.APIBase = expandPath(config.LLM.Primary.APIBase)
    config.KnowledgeBase.VaultPath = expandPath(config.KnowledgeBase.VaultPath)
    config.KnowledgeBase.DBPath = expandPath(config.KnowledgeBase.DBPath)
    if config.LLM.Fallback != nil {
        config.LLM.Fallback.APIBase = expandPath(config.LLM.Fallback.APIBase)
    }
    return config
}
// getDefaultConfig 获取默认配置
func getDefaultConfig() Config {
    var config Config
    config.LLM.Primary.APIBase = "http://192.168.3.246:1127/v1"
    config.LLM.Primary.APIKey = "sk-local"
    config.LLM.Primary.Model = "qwen3.6-35b-a3b"
    config.LLM.Primary.Temperature = 0.3
    config.LLM.Primary.MaxTokens = 2000
    config.LLM.Primary.DisableThinking = true
    config.KnowledgeBase.VaultPath = "~/aisim/note/001/笔记001"
    config.KnowledgeBase.DBPath = "~/.cache/kb-cli/kb.db"
    config.Draft.ReviewDir = "待审阅"
    config.Draft.DefaultType = "售后"
    return config
}
// expandPath 展开路径中的 ~
func expandPath(path string) string {
    if strings.HasPrefix(path, "~/") {
        homeDir, _ := os.UserHomeDir()
        return filepath.Join(homeDir, path[2:])
    }
    return path
}
// getCurrentConfig 获取当前使用的 LLM 配置
func (c *Client) getCurrentConfig() LLMConfig {
    if c.usePrimary {
        return c.config.LLM.Primary
    }
    if c.config.LLM.Fallback != nil {
        return *c.config.LLM.Fallback
    }
    return c.config.LLM.Primary
}
// switchToNext 切换到下一个可用的 LLM
func (c *Client) switchToNext() bool {
    if c.usePrimary && c.config.LLM.Fallback != nil {
        fmt.Fprintf(os.Stderr, "主 LLM 不可用,切换到备用 LLM\n")
        c.usePrimary = false
        return true
    }
    return false
}
// ExtractTags 提取 tags 和相关文档
func (c *Client) ExtractTags(content string) (*ExtractResult, error) {
    prompt := fmt.Sprintf(`分析以下知识库草稿,提取:
1. tags(5-10个):
   - 核心问题标签(如"充不进气"、"档案下载失败")
   - 扩展词(不同人可能的描述,如"充气慢"、"进气不足")
   - 平台/设备标签(如"电子秤平台"、"智能枪")
2. related_docs(0-5个):相关文档标题(用于创建链接)
输出 JSON:
{
  "tags": ["充不进气", "充气慢", "进气不足", "智能枪", "电子秤平台"],
  "related_docs": ["智能枪通气杆卡住漏气", "角阀充装功率不足"]
}
草稿内容:
%s`, content)
    response, err := c.callLLMWithRetry(prompt)
    if err != nil {
        return nil, err
    }
    // 解析 JSON
    var result ExtractResult
    if err := parseJSON(response, &result); err != nil {
        return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
    }
    return &result, nil
}
// GenerateMergeHint 生成合并指示
func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate) (*MergeHint, error) {
    prompt := fmt.Sprintf(`你是一个知识库管理助手。请判断以下草稿与哪些知识库文档相关。
## 草稿内容
%s
## 候选文档(Top 3)
`, draftContent)
    for i, cand := range candidates {
        prompt += fmt.Sprintf("%d. 标题: %s, 路径: %s, 相关度: %.2f\n", i+1, cand.Title, cand.Path, cand.Score)
    }
    prompt += `
## 任务
请分析草稿与每个候选文档的相关性,输出 JSON 格式:
{
  "recommendation": {
    "action": "merge|new|split",
    "target": "目标路径(如果 action=merge)",
    "reason": "判断理由",
    "confidence": "high|medium|low"
  },
  "analysis": [
    {
      "path": "文档路径",
      "relevance": "high|medium|low",
      "reason": "相关性说明"
    }
  ]
}
判断标准:
- action=merge: 草稿内容与某个候选文档高度相关,应该合并
- action=new: 草稿内容是全新的,应该新建文档
- action=split: 草稿内容包含多个独立主题,应该拆分
- confidence=high: 判断很确定
- confidence=medium: 判断比较确定
- confidence=low: 判断不太确定
只输出 JSON,不要其他内容。`
    response, err := c.callLLMWithRetry(prompt)
    if err != nil {
        return nil, err
    }
    var result MergeHint
    if err := parseJSON(response, &result); err != nil {
        return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
    }
    return &result, nil
}
// callLLMWithRetry 带重试的 LLM 调用
func (c *Client) callLLMWithRetry(prompt string) (string, error) {
    // 第一次尝试
    response, err := c.callLLM(prompt)
    if err == nil {
        return response, nil
    }
    // 判断是否是需要切换的错误
    if shouldSwitchLLM(err) {
        // 尝试切换到备用 LLM
        if c.switchToNext() {
            fmt.Fprintf(os.Stderr, "重试备用 LLM...\n")
            response, err = c.callLLM(prompt)
            if err == nil {
                return response, nil
            }
        }
    }
    return "", err
}
// shouldSwitchLLM 判断是否应该切换到备用 LLM
func shouldSwitchLLM(err error) bool {
    errMsg := err.Error()
    // 网络错误、超时、认证失败等应该切换
    return strings.Contains(errMsg, "connection") ||
        strings.Contains(errMsg, "timeout") ||
        strings.Contains(errMsg, "401") ||
        strings.Contains(errMsg, "403") ||
        strings.Contains(errMsg, "500") ||
        strings.Contains(errMsg, "502") ||
        strings.Contains(errMsg, "503") ||
        strings.Contains(errMsg, "504")
}
// callLLM 调用 LLM API
func (c *Client) callLLM(prompt string) (string, error) {
    llmConfig := c.getCurrentConfig()
    url := llmConfig.APIBase + "/chat/completions"
    payload := map[string]interface{}{
        "model": llmConfig.Model,
        "messages": []map[string]string{
            {"role": "user", "content": prompt},
        },
        "temperature": llmConfig.Temperature,
        "max_tokens":  llmConfig.MaxTokens,
    }
    // 如果需要禁用思考模式
    if llmConfig.DisableThinking {
        payload["chat_template_kwargs"] = map[string]bool{
            "enable_thinking": false,
        }
    }
    jsonData, err := json.Marshal(payload)
    if err != nil {
        return "", fmt.Errorf("序列化请求失败: %w", err)
    }
    req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
    if err != nil {
        return "", fmt.Errorf("创建请求失败: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")
    if llmConfig.APIKey != "" {
        req.Header.Set("Authorization", "Bearer "+llmConfig.APIKey)
    }
    client := &http.Client{Timeout: 60 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return "", fmt.Errorf("请求失败: %w", err)
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("读取响应失败: %w", err)
    }
    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("API 返回错误状态: %d, body: %s", resp.StatusCode, string(body))
    }
    // 解析响应
    var result struct {
        Choices []struct {
            Message struct {
                Content string `json:"content"`
            } `json:"message"`
        } `json:"choices"`
    }
    if err := json.Unmarshal(body, &result); err != nil {
        return "", fmt.Errorf("解析响应失败: %w", err)
    }
    if len(result.Choices) == 0 {
        return "", fmt.Errorf("API 返回空结果")
    }
    return result.Choices[0].Message.Content, nil
}
// parseJSON 解析 JSON(支持 markdown 代码块)
func parseJSON(content string, v interface{}) error {
    // 尝试提取 ```json 代码块
    re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```")
    matches := re.FindStringSubmatch(content)
    if len(matches) > 1 {
        content = matches[1]
    }
    if err := json.Unmarshal([]byte(content), v); err != nil {
        return err
    }
    return nil
}