From cf162a137caffa5f25f646220a636d78e8253864 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 12:27:36 +0800
Subject: [PATCH] feat: 添加草稿创建功能

---
 cmd/draft.go             |  130 +++++++
 bin/kb-cli               |    0 
 internal/draft/intake.go |  509 +++++++++++++++++++++++++++++
 internal/llm/client.go   |  375 ++++++++++++++++++++++
 4 files changed, 1,014 insertions(+), 0 deletions(-)

diff --git a/bin/kb-cli b/bin/kb-cli
index 2578a39..2c32481 100755
--- a/bin/kb-cli
+++ b/bin/kb-cli
Binary files differ
diff --git a/cmd/draft.go b/cmd/draft.go
new file mode 100644
index 0000000..32d280c
--- /dev/null
+++ b/cmd/draft.go
@@ -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
+}
diff --git a/internal/draft/intake.go b/internal/draft/intake.go
new file mode 100644
index 0000000..f28d56f
--- /dev/null
+++ b/internal/draft/intake.go
@@ -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)
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
new file mode 100644
index 0000000..5ffd7e2
--- /dev/null
+++ b/internal/llm/client.go
@@ -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
+}

--
Gitblit v1.9.1