feat: 添加草稿创建功能
- 新增 draft create 命令,支持 AI 生成 tags 和合并指示
- 新增 tags rebuild 命令,支持批量重建现有文档的 tags
- 集成 LLM 客户端,支持主备配置自动切换
- 配置文件路径: ~/.kb-cli/config.yaml
- 草稿存储路径: ~/aisim/note/001/笔记001/待审阅/
1 files modified
3 files added
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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) |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |