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