feat: config priority over env vars, export LoadConfig, add review feature
- Change config loading priority: config file > env vars > defaults
- Export LoadConfig() for use in cmd package
- Fix search/engine.go duplicate loop
- Add draft intake and review commands
- Build with -tags fts5 for FTS5 support
6 files modified
2 files added
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | |
| | |
| | | var draftCmd = &cobra.Command{ |
| | | Use: "draft", |
| | | Short: "草稿管理", |
| | | Long: `kb-cli draft create # 创建草稿(AI 生成 tags + 合并指示)`, |
| | | Long: `# draft - 草稿管理 |
| | | kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿(AI 提取 tags + 生成合并指示) |
| | | kb-cli draft list [--json] # 列出待审阅区的所有活跃草稿 |
| | | kb-cli draft convert [目录名] # 转换老格式单文件草稿到新格式(NNN-标题/draft.md + merge.md)`, |
| | | } |
| | | |
| | | var draftCreateCmd = &cobra.Command{ |
| | | Use: "create", |
| | | Short: "创建草稿", |
| | | Long: `# draft create - 创建草稿 |
| | | Long: `# draft create - 创建草稿 |
| | | kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿,自动提取 tags 并生成合并指示`, |
| | | RunE: runDraftCreate, |
| | | } |
| | | |
| | | var draftListCmd = &cobra.Command{ |
| | | Use: "list", |
| | | Short: "列出所有活跃草稿", |
| | | Long: `# draft list - 列出所有活跃草稿 |
| | | kb-cli draft list [--json] # 列出待审阅区的所有草稿(目录结构 + 老格式单文件)`, |
| | | RunE: runDraftList, |
| | | } |
| | | |
| | | var draftConvertCmd = &cobra.Command{ |
| | | Use: "convert [目录名]", |
| | | Short: "转换老格式草稿到新格式", |
| | | Long: `# draft convert - 转换老格式草稿到新格式 |
| | | kb-cli draft convert # 扫描所有待审阅子目录,转换老格式单文件 |
| | | kb-cli draft convert TAPD提取 # 只转换指定子目录`, |
| | | RunE: runDraftConvert, |
| | | } |
| | | |
| | | var tagsCmd = &cobra.Command{ |
| | |
| | | } |
| | | |
| | | var ( |
| | | draftType string |
| | | draftTitle string |
| | | draftType string |
| | | draftTitle string |
| | | draftContentFile string |
| | | draftSource string |
| | | draftForce bool |
| | | tagsLimit int |
| | | tagsDryRun bool |
| | | draftSource string |
| | | draftForce bool |
| | | draftJSON bool |
| | | tagsLimit int |
| | | tagsDryRun bool |
| | | convertTarget string |
| | | ) |
| | | |
| | | func init() { |
| | |
| | | draftCreateCmd.MarkFlagRequired("title") |
| | | draftCreateCmd.MarkFlagRequired("content-file") |
| | | |
| | | // draft list 命令 |
| | | draftListCmd.Flags().BoolVar(&draftJSON, "json", false, "JSON 格式输出") |
| | | |
| | | // draft convert 命令(无额外参数,target 从 args 读取) |
| | | |
| | | draftCmd.AddCommand(draftCreateCmd) |
| | | draftCmd.AddCommand(draftListCmd) |
| | | draftCmd.AddCommand(draftConvertCmd) |
| | | |
| | | // tags rebuild 命令 |
| | | tagsRebuildCmd.Flags().IntVar(&tagsLimit, "limit", 0, "限制处理文档数量(0=全部)") |
| | |
| | | |
| | | return nil |
| | | } |
| | | |
| | | // runDraftList 列出所有活跃草稿 |
| | | func runDraftList(cmd *cobra.Command, args []string) error { |
| | | // 创建草稿录入器 |
| | | intake := draft.NewIntake(vaultPath, nil) |
| | | |
| | | // 列出草稿 |
| | | drafts, err := intake.ListDrafts() |
| | | if err != nil { |
| | | return fmt.Errorf("列出草稿失败: %w", err) |
| | | } |
| | | |
| | | if draftJSON { |
| | | // JSON 输出 |
| | | output := struct { |
| | | Drafts []draft.DraftInfo `json:"drafts"` |
| | | Total int `json:"total"` |
| | | }{ |
| | | Drafts: drafts, |
| | | Total: len(drafts), |
| | | } |
| | | encoder := json.NewEncoder(os.Stdout) |
| | | encoder.SetIndent("", " ") |
| | | return encoder.Encode(output) |
| | | } |
| | | |
| | | // 表格输出 |
| | | if len(drafts) == 0 { |
| | | fmt.Println("没有活跃草稿") |
| | | return nil |
| | | } |
| | | |
| | | fmt.Printf("找到 %d 个活跃草稿:\n\n", len(drafts)) |
| | | for _, d := range drafts { |
| | | mergeStatus := "❌" |
| | | if d.HasMergeHint { |
| | | mergeStatus = "✅" |
| | | } |
| | | fmt.Printf("• %s\n", d.Title) |
| | | fmt.Printf(" 路径: %s\n", d.Filepath) |
| | | fmt.Printf(" 类型: %s | 状态: %s | 合并指示: %s\n", d.Type, d.Status, mergeStatus) |
| | | fmt.Printf(" 创建: %s\n\n", d.Created) |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | // runDraftConvert 转换老格式草稿到新格式 |
| | | func runDraftConvert(cmd *cobra.Command, args []string) error { |
| | | // 可选参数:指定子目录 |
| | | target := "" |
| | | if len(args) > 0 { |
| | | target = args[0] |
| | | } |
| | | |
| | | // 创建草稿录入器 |
| | | intake := draft.NewIntake(vaultPath, nil) |
| | | |
| | | // 转换草稿 |
| | | converted, err := intake.ConvertOldDrafts(target) |
| | | if err != nil { |
| | | return fmt.Errorf("转换草稿失败: %w", err) |
| | | } |
| | | |
| | | fmt.Printf("\n✅ 成功转换 %d 个草稿\n", converted) |
| | | return nil |
| | | } |
| | |
| | | } |
| | | |
| | | // 写入边,将 BuildGraph ID 转换为 SQLite ID |
| | | actualEdgeCount := 0 |
| | | for _, e := range g.Edges { |
| | | fromID, ok := idMap[e.FromNode] |
| | | if !ok { |
| | |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | return fmt.Errorf("插入边失败: %w", err) |
| | | } |
| | | actualEdgeCount++ |
| | | } |
| | | |
| | | // 创建并填充 FTS5 索引 |
| | |
| | | return fmt.Errorf("记录构建时间失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), len(g.Edges)) |
| | | fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), actualEdgeCount) |
| | | return nil |
| | | } |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/llm" |
| | | "github.com/aisim/kb-cli/internal/review" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var reviewCmd = &cobra.Command{ |
| | | Use: "review", |
| | | Short: "审阅预览", |
| | | Long: `# review - 审阅预览 |
| | | kb-cli review <草稿目录> [--json] # 读取草稿和合并指示,使用 LLM 生成审阅预览`, |
| | | Args: cobra.ExactArgs(1), |
| | | RunE: runReview, |
| | | } |
| | | |
| | | var reviewJSON bool |
| | | |
| | | func init() { |
| | | reviewCmd.Flags().BoolVar(&reviewJSON, "json", false, "JSON 格式输出") |
| | | rootCmd.AddCommand(reviewCmd) |
| | | } |
| | | |
| | | func runReview(cmd *cobra.Command, args []string) error { |
| | | draftDir := args[0] |
| | | |
| | | // 读取草稿和合并指示 |
| | | preview, err := review.ReadDraftAndMerge(vaultPath, draftDir) |
| | | if err != nil { |
| | | return fmt.Errorf("读取草稿失败: %w", err) |
| | | } |
| | | |
| | | // 如果有目标FAQ,用 LLM 生成3句话概要 |
| | | if preview.TargetFAQ != nil && preview.TargetFAQ.Title != "" { |
| | | targetPath := preview.TargetFAQ.Path |
| | | if !filepath.IsAbs(targetPath) { |
| | | targetPath = filepath.Join(vaultPath, targetPath) |
| | | } |
| | | |
| | | targetContent, err := os.ReadFile(targetPath) |
| | | if err == nil && len(targetContent) > 0 { |
| | | llmClient := llm.NewClient() |
| | | summary, err := llmClient.GenerateReviewPreview(preview.TargetFAQ.Title, string(targetContent)) |
| | | if err != nil { |
| | | fmt.Fprintf(os.Stderr, "警告: LLM 生成概要失败: %v\n", err) |
| | | } else { |
| | | preview.TargetFAQ.Summary = summary |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 输出 |
| | | if reviewJSON { |
| | | output, err := review.FormatPreviewJSON(preview) |
| | | if err != nil { |
| | | return fmt.Errorf("格式化输出失败: %w", err) |
| | | } |
| | | fmt.Println(output) |
| | | } else { |
| | | // 表格输出 |
| | | fmt.Println(review.FormatPreviewTable(preview)) |
| | | |
| | | // 如果有草稿正文,也展示主要内容 |
| | | expandedVault := expandHomePath(vaultPath) |
| | | draftPath := filepath.Join(expandedVault, draftDir, "draft.md") |
| | | draftContent, err := os.ReadFile(draftPath) |
| | | if err == nil { |
| | | content := extractMainContent(string(draftContent)) |
| | | if content != "" { |
| | | fmt.Println("### 草稿主要内容\n") |
| | | fmt.Println(content) |
| | | } |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | // extractMainContent 从 draft.md 中提取正文(去掉 frontmatter) |
| | | func extractMainContent(content string) string { |
| | | if !strings.HasPrefix(content, "---") { |
| | | return content |
| | | } |
| | | |
| | | // 找到第二个 --- |
| | | endIdx := strings.Index(content[3:], "\n---") |
| | | if endIdx == -1 { |
| | | return content |
| | | } |
| | | |
| | | // 返回 frontmatter 之后的内容 |
| | | mainContent := content[endIdx+6:] |
| | | return strings.TrimSpace(mainContent) |
| | | } |
| | | |
| | | // expandHomePath 展开路径中的 ~ |
| | | func expandHomePath(path string) string { |
| | | if strings.HasPrefix(path, "~/") { |
| | | homeDir, _ := os.UserHomeDir() |
| | | return filepath.Join(homeDir, path[2:]) |
| | | } |
| | | return path |
| | | } |
| | | |
| | | // 保留 json 包引用 |
| | | var _ = json.Marshal |
| | |
| | | "strings" |
| | | |
| | | "github.com/spf13/cobra" |
| | | "github.com/aisim/kb-cli/internal/llm" |
| | | ) |
| | | |
| | | var ( |
| | |
| | | |
| | | func init() { |
| | | homeDir, _ := os.UserHomeDir() |
| | | defaultVault := os.Getenv("KB_VAULT") |
| | | |
| | | // 从配置文件读取默认值(优先级:配置文件 > 环境变量 > 内置默认值) |
| | | cfg := llm.LoadConfig() |
| | | defaultVault := cfg.KnowledgeBase.VaultPath |
| | | defaultDB := cfg.KnowledgeBase.DBPath |
| | | |
| | | // 如果配置文件没有设置,使用环境变量或默认值 |
| | | if defaultVault == "" { |
| | | defaultVault = homeDir + "/aisim/note/001/笔记001" |
| | | defaultVault = os.Getenv("KB_VAULT") |
| | | if defaultVault == "" { |
| | | defaultVault = homeDir + "/aisim/note/001/笔记001" |
| | | } |
| | | } |
| | | defaultDB := homeDir + "/.cache/kb-cli/kb.db" |
| | | if defaultDB == "" { |
| | | defaultDB = homeDir + "/.cache/kb-cli/kb.db" |
| | | } |
| | | |
| | | rootCmd.PersistentFlags().StringVar(&vaultPath, "vault", defaultVault, "知识库根目录") |
| | | rootCmd.PersistentFlags().StringVar(&dbPath, "db", defaultDB, "索引数据库路径") |
| | |
| | | store *index.Store |
| | | } |
| | | |
| | | // DraftInfo 草稿信息(用于 list 输出) |
| | | type DraftInfo struct { |
| | | Filepath string `json:"filepath"` |
| | | Filename string `json:"filename"` |
| | | Title string `json:"title,omitempty"` |
| | | Status string `json:"status,omitempty"` |
| | | Type string `json:"type,omitempty"` |
| | | Created string `json:"created,omitempty"` |
| | | HasMergeHint bool `json:"has_merge_hint"` |
| | | } |
| | | |
| | | // DraftMeta 草稿元数据 |
| | | type DraftMeta struct { |
| | | Title string `yaml:"title"` |
| | |
| | | var mergeHint *llm.MergeHint |
| | | if len(candidates) > 0 { |
| | | fmt.Println("正在生成合并指示...") |
| | | mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates) |
| | | dirStructure := i.scanDirectoryStructure() |
| | | mergeHint, err = i.llmClient.GenerateMergeHint(content, candidates, dirStructure) |
| | | if err != nil { |
| | | fmt.Printf("警告: 生成合并指示失败: %v\n", err) |
| | | mergeHint = nil |
| | |
| | | // 使用所有 tags 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可) |
| | | // 这样即使某些 tag 匹配不到,其他 tag 也能找到结果 |
| | | opts := search.SearchOptions{ |
| | | TopN: 3, |
| | | TopN: 3, |
| | | WithContent: true, // 需要内容给 LLM 评估 |
| | | } |
| | | |
| | | results, err := search.Search(i.store, tags, opts) |
| | |
| | | |
| | | candidates := make([]llm.SearchCandidate, 0, len(results)) |
| | | for _, r := range results { |
| | | // 截取前5000字给 LLM 评估 |
| | | content := r.Content |
| | | if len(content) > 5000 { |
| | | content = content[:5000] + "\n...(内容已截断)" |
| | | } |
| | | candidates = append(candidates, llm.SearchCandidate{ |
| | | Title: r.Title, |
| | | Path: r.Path, |
| | | Score: float64(r.Score), |
| | | Title: r.Title, |
| | | Path: r.Path, |
| | | Score: float64(r.Score), |
| | | Content: content, |
| | | }) |
| | | } |
| | | |
| | |
| | | return os.WriteFile(mergePath, []byte(sb.String()), 0644) |
| | | } |
| | | |
| | | // scanDirectoryStructure 扫描知识库目录结构,返回格式化的目录树字符串 |
| | | func (i *Intake) scanDirectoryStructure() string { |
| | | // 知识库根目录下的主要分类目录 |
| | | topDirs := []string{ |
| | | "电子秤平台", |
| | | "运营管理平台", |
| | | "第三方平台", |
| | | "共有硬件", |
| | | "通用", |
| | | } |
| | | |
| | | var sb strings.Builder |
| | | for _, topDir := range topDirs { |
| | | dirPath := filepath.Join(i.vaultPath, topDir) |
| | | entries, err := os.ReadDir(dirPath) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | for _, entry := range entries { |
| | | if entry.IsDir() { |
| | | sb.WriteString(fmt.Sprintf("%s/%s/\n", topDir, entry.Name())) |
| | | } |
| | | } |
| | | } |
| | | |
| | | return sb.String() |
| | | } |
| | | |
| | | // RebuildTags 批量重建 tags |
| | | func (i *Intake) RebuildTags(limit int, dryRun bool) error { |
| | | // 扫描知识库所有 .md 文件 |
| | |
| | | |
| | | return os.WriteFile(path, []byte(newContent), 0644) |
| | | } |
| | | |
| | | // ListDrafts 列出所有活跃草稿 |
| | | func (i *Intake) ListDrafts() ([]DraftInfo, error) { |
| | | reviewBase := filepath.Join(i.vaultPath, "待审阅") |
| | | |
| | | if _, err := os.Stat(reviewBase); os.IsNotExist(err) { |
| | | return []DraftInfo{}, nil |
| | | } |
| | | |
| | | var drafts []DraftInfo |
| | | |
| | | err := filepath.Walk(reviewBase, func(path string, info os.FileInfo, err error) error { |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | // 跳过 archive 目录 |
| | | if info.IsDir() && info.Name() == "archive" { |
| | | return filepath.SkipDir |
| | | } |
| | | |
| | | // 检查是否是目录结构(包含 draft.md) |
| | | if !info.IsDir() && info.Name() == "draft.md" { |
| | | draftDir := filepath.Dir(path) |
| | | mergeFile := filepath.Join(draftDir, "merge.md") |
| | | |
| | | content, err := os.ReadFile(path) |
| | | if err != nil { |
| | | return nil |
| | | } |
| | | |
| | | relPath, _ := filepath.Rel(i.vaultPath, path) |
| | | hasMerge := fileExists(mergeFile) |
| | | |
| | | draft := DraftInfo{ |
| | | Filepath: relPath, |
| | | Filename: filepath.Base(draftDir), |
| | | HasMergeHint: hasMerge, |
| | | } |
| | | |
| | | // 解析 frontmatter |
| | | fm := parseFrontmatter(string(content)) |
| | | if title, ok := fm["title"]; ok { |
| | | draft.Title = title |
| | | } |
| | | if status, ok := fm["status"]; ok { |
| | | draft.Status = status |
| | | } |
| | | if draftType, ok := fm["type"]; ok { |
| | | draft.Type = draftType |
| | | } |
| | | if created, ok := fm["created"]; ok { |
| | | draft.Created = created |
| | | } |
| | | |
| | | drafts = append(drafts, draft) |
| | | } |
| | | |
| | | // 兼容旧结构(单文件 .md) |
| | | if !info.IsDir() && filepath.Ext(info.Name()) == ".md" && info.Name() != "draft.md" && info.Name() != "merge.md" && info.Name() != "索引.json" { |
| | | // 检查是否是数字开头 |
| | | name := info.Name() |
| | | if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) { |
| | | content, err := os.ReadFile(path) |
| | | if err != nil { |
| | | return nil |
| | | } |
| | | |
| | | relPath, _ := filepath.Rel(i.vaultPath, path) |
| | | |
| | | draft := DraftInfo{ |
| | | Filepath: relPath, |
| | | Filename: name, |
| | | HasMergeHint: false, |
| | | } |
| | | |
| | | // 解析 frontmatter |
| | | fm := parseFrontmatter(string(content)) |
| | | if title, ok := fm["title"]; ok { |
| | | draft.Title = title |
| | | } |
| | | if status, ok := fm["status"]; ok { |
| | | draft.Status = status |
| | | } |
| | | if draftType, ok := fm["type"]; ok { |
| | | draft.Type = draftType |
| | | } |
| | | if created, ok := fm["created"]; ok { |
| | | draft.Created = created |
| | | } |
| | | |
| | | drafts = append(drafts, draft) |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | }) |
| | | |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | |
| | | return drafts, nil |
| | | } |
| | | |
| | | // ConvertOldDrafts 转换老格式草稿到新格式 |
| | | func (i *Intake) ConvertOldDrafts(target string) (int, error) { |
| | | reviewDir := filepath.Join(i.vaultPath, "待审阅") |
| | | |
| | | var dirs []string |
| | | if target != "" { |
| | | dirs = append(dirs, filepath.Join(reviewDir, target)) |
| | | } else { |
| | | entries, err := os.ReadDir(reviewDir) |
| | | if err != nil { |
| | | return 0, err |
| | | } |
| | | |
| | | for _, entry := range entries { |
| | | if entry.IsDir() && entry.Name() != "archive" { |
| | | dirs = append(dirs, filepath.Join(reviewDir, entry.Name())) |
| | | } |
| | | } |
| | | } |
| | | |
| | | total := 0 |
| | | for _, dir := range dirs { |
| | | if _, err := os.Stat(dir); os.IsNotExist(err) { |
| | | continue |
| | | } |
| | | |
| | | entries, err := os.ReadDir(dir) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | |
| | | var oldFiles []string |
| | | for _, entry := range entries { |
| | | if !entry.IsDir() && filepath.Ext(entry.Name()) == ".md" { |
| | | // 检查是否是数字开头的单文件 |
| | | name := entry.Name() |
| | | if len(name) >= 3 && isDigit(name[0]) && isDigit(name[1]) && isDigit(name[2]) { |
| | | oldFiles = append(oldFiles, filepath.Join(dir, name)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | if len(oldFiles) == 0 { |
| | | continue |
| | | } |
| | | |
| | | fmt.Printf("\n🔍 %s: 找到 %d 个老格式文件\n\n", filepath.Base(dir), len(oldFiles)) |
| | | |
| | | for _, oldFile := range oldFiles { |
| | | if convertOneDraft(oldFile) { |
| | | total++ |
| | | } |
| | | } |
| | | } |
| | | |
| | | return total, nil |
| | | } |
| | | |
| | | // convertOneDraft 转换单个老格式草稿 |
| | | func convertOneDraft(oldFile string) bool { |
| | | fmt.Printf("📄 转换: %s\n", filepath.Base(oldFile)) |
| | | |
| | | content, err := os.ReadFile(oldFile) |
| | | if err != nil { |
| | | fmt.Printf(" ⚠️ 读取失败: %v\n", err) |
| | | return false |
| | | } |
| | | |
| | | // 解析文件名 |
| | | name := filepath.Base(oldFile) |
| | | ext := filepath.Ext(name) |
| | | nameWithoutExt := strings.TrimSuffix(name, ext) |
| | | |
| | | // 提取编号、日期、标题 |
| | | parts := strings.SplitN(nameWithoutExt, "-", 3) |
| | | if len(parts) < 3 { |
| | | fmt.Printf(" ⚠️ 文件名格式不匹配: %s\n", name) |
| | | return false |
| | | } |
| | | |
| | | num := parts[0] |
| | | dateStr := parts[1] |
| | | title := parts[2] |
| | | |
| | | // 创建新目录 |
| | | newDirName := fmt.Sprintf("%s-%s-%s", num, dateStr, title) |
| | | newDir := filepath.Join(filepath.Dir(oldFile), newDirName) |
| | | |
| | | if err := os.MkdirAll(newDir, 0755); err != nil { |
| | | fmt.Printf(" ⚠️ 创建目录失败: %v\n", err) |
| | | return false |
| | | } |
| | | |
| | | // 解析 frontmatter |
| | | fm := parseFrontmatter(string(content)) |
| | | |
| | | // 构建标准化 frontmatter |
| | | var sb strings.Builder |
| | | sb.WriteString("---\n") |
| | | if title, ok := fm["title"]; ok { |
| | | sb.WriteString(fmt.Sprintf("title: %q\n", title)) |
| | | } else { |
| | | sb.WriteString(fmt.Sprintf("title: %q\n", strings.ReplaceAll(title, "_", "/"))) |
| | | } |
| | | sb.WriteString("status: \"draft\"\n") |
| | | if draftType, ok := fm["type"]; ok { |
| | | sb.WriteString(fmt.Sprintf("type: %q\n", draftType)) |
| | | } else { |
| | | sb.WriteString("type: \"TAPD\"\n") |
| | | } |
| | | if source, ok := fm["source"]; ok { |
| | | sb.WriteString(fmt.Sprintf("source: %q\n", source)) |
| | | } else { |
| | | sb.WriteString(fmt.Sprintf("source: \"TAPD-%s\"\n", dateStr)) |
| | | } |
| | | if tags, ok := fm["tags"]; ok { |
| | | // 如果 tags 已经是数组格式,直接使用 |
| | | if strings.HasPrefix(tags, "[") { |
| | | sb.WriteString(fmt.Sprintf("tags: %s\n", tags)) |
| | | } else { |
| | | sb.WriteString(fmt.Sprintf("tags: [%s]\n", tags)) |
| | | } |
| | | } else { |
| | | sb.WriteString("tags: []\n") |
| | | } |
| | | if created, ok := fm["created"]; ok { |
| | | sb.WriteString(fmt.Sprintf("created: %q\n", created)) |
| | | } else if created, ok := fm["date"]; ok { |
| | | sb.WriteString(fmt.Sprintf("created: %q\n", created)) |
| | | } else { |
| | | // 从日期字符串构造 |
| | | if len(dateStr) == 8 { |
| | | created := fmt.Sprintf("%s-%s-%s", dateStr[:4], dateStr[4:6], dateStr[6:8]) |
| | | sb.WriteString(fmt.Sprintf("created: %q\n", created)) |
| | | } |
| | | } |
| | | sb.WriteString("---\n\n") |
| | | |
| | | // 提取正文(去掉原 frontmatter) |
| | | body := extractBody(string(content)) |
| | | sb.WriteString(body) |
| | | |
| | | // 写入 draft.md |
| | | draftFile := filepath.Join(newDir, "draft.md") |
| | | if err := os.WriteFile(draftFile, []byte(sb.String()), 0644); err != nil { |
| | | fmt.Printf(" ⚠️ 写入 draft.md 失败: %v\n", err) |
| | | return false |
| | | } |
| | | fmt.Printf(" ✅ 创建: draft.md\n") |
| | | |
| | | // 删除原文件 |
| | | if err := os.Remove(oldFile); err != nil { |
| | | fmt.Printf(" ⚠️ 删除原文件失败: %v\n", err) |
| | | } else { |
| | | fmt.Printf(" 🗑️ 删除: %s\n", name) |
| | | } |
| | | |
| | | return true |
| | | } |
| | | |
| | | // extractBody 提取正文(去掉 frontmatter) |
| | | func extractBody(content string) string { |
| | | if !strings.HasPrefix(content, "---") { |
| | | return content |
| | | } |
| | | |
| | | endIdx := strings.Index(content[3:], "\n---") |
| | | if endIdx == -1 { |
| | | return content |
| | | } |
| | | |
| | | return strings.TrimSpace(content[endIdx+7:]) |
| | | } |
| | | |
| | | // parseFrontmatter 解析 frontmatter |
| | | func parseFrontmatter(content string) map[string]string { |
| | | result := make(map[string]string) |
| | | |
| | | if !strings.HasPrefix(content, "---") { |
| | | return result |
| | | } |
| | | |
| | | endIdx := strings.Index(content[3:], "\n---") |
| | | if endIdx == -1 { |
| | | return result |
| | | } |
| | | |
| | | fmText := content[3 : endIdx+3] |
| | | lines := strings.Split(fmText, "\n") |
| | | |
| | | for _, line := range lines { |
| | | line = strings.TrimSpace(line) |
| | | if line == "" || !strings.Contains(line, ":") { |
| | | continue |
| | | } |
| | | |
| | | parts := strings.SplitN(line, ":", 2) |
| | | if len(parts) != 2 { |
| | | continue |
| | | } |
| | | |
| | | key := strings.TrimSpace(parts[0]) |
| | | value := strings.TrimSpace(parts[1]) |
| | | |
| | | // 去除引号 |
| | | value = strings.Trim(value, "\"'") |
| | | |
| | | result[key] = value |
| | | } |
| | | |
| | | return result |
| | | } |
| | | |
| | | // isDigit 检查字符是否为数字 |
| | | func isDigit(c byte) bool { |
| | | return c >= '0' && c <= '9' |
| | | } |
| | | |
| | | // fileExists 检查文件是否存在 |
| | | func fileExists(path string) bool { |
| | | _, err := os.Stat(path) |
| | | return err == nil |
| | | } |
| | |
| | | |
| | | // SearchCandidate 搜索候选 |
| | | type SearchCandidate struct { |
| | | Title string `json:"title"` |
| | | Path string `json:"path"` |
| | | Score float64 `json:"score"` |
| | | Title string `json:"title"` |
| | | Path string `json:"path"` |
| | | Score float64 `json:"score"` |
| | | Content string `json:"content,omitempty"` // 文件内容(截取前5000字) |
| | | } |
| | | |
| | | // NewClient 创建 LLM 客户端 |
| | | func NewClient() *Client { |
| | | config := loadConfig() |
| | | config := LoadConfig() |
| | | return &Client{config: config, usePrimary: true} |
| | | } |
| | | |
| | | // loadConfig 加载配置文件 |
| | | func loadConfig() Config { |
| | | // LoadConfig 加载配置文件(导出供其他包使用) |
| | | func LoadConfig() Config { |
| | | var config Config |
| | | |
| | | // 配置文件路径 |
| | |
| | | } |
| | | |
| | | // GenerateMergeHint 生成合并指示 |
| | | func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate) (*MergeHint, error) { |
| | | func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate, dirStructure string) (*MergeHint, error) { |
| | | prompt := fmt.Sprintf(`你是一个知识库管理助手。请判断以下草稿与哪些知识库文档相关。 |
| | | |
| | | ## 知识库目录结构 |
| | | %s |
| | | |
| | | ## 草稿内容 |
| | | %s |
| | | |
| | | ## 候选文档(Top 3) |
| | | `, draftContent) |
| | | `, dirStructure, draftContent) |
| | | |
| | | for i, cand := range candidates { |
| | | prompt += fmt.Sprintf("%d. 标题: %s, 路径: %s, 相关度: %.2f\n", i+1, cand.Title, cand.Path, cand.Score) |
| | | prompt += fmt.Sprintf("### %d. %s\n", i+1, cand.Title) |
| | | prompt += fmt.Sprintf("- 路径: %s\n", cand.Path) |
| | | prompt += fmt.Sprintf("- 相关度: %.2f\n\n", cand.Score) |
| | | if cand.Content != "" { |
| | | prompt += fmt.Sprintf("**文档内容**:\n```\n%s\n```\n\n", cand.Content) |
| | | } |
| | | } |
| | | |
| | | prompt += ` |
| | |
| | | { |
| | | "recommendation": { |
| | | "action": "merge|new|split", |
| | | "target": "目标路径(如果 action=merge)", |
| | | "target": "目标路径(如果 action=merge,必须是上述目录结构中存在的目录)", |
| | | "reason": "判断理由", |
| | | "confidence": "high|medium|low" |
| | | }, |
| | |
| | | |
| | | 判断标准: |
| | | - action=merge: 草稿内容与某个候选文档高度相关,应该合并 |
| | | - action=new: 草稿内容是全新的,应该新建文档 |
| | | - action=new: 草稿内容是全新的,应该新建文档(target 填写建议的目录路径) |
| | | - action=split: 草稿内容包含多个独立主题,应该拆分 |
| | | - confidence=high: 判断很确定 |
| | | - confidence=medium: 判断比较确定 |
| | | - confidence=low: 判断不太确定 |
| | | |
| | | 分类原则: |
| | | - 平台专属内容放对应平台目录下(如电子秤平台/智能枪/) |
| | | - 多平台共用的硬件知识放 共有硬件/ 下 |
| | | - 跨平台通用知识放 通用/通用/ |
| | | |
| | | 只输出 JSON,不要其他内容。` |
| | | |
| | |
| | | return &result, nil |
| | | } |
| | | |
| | | // GenerateReviewPreview 生成审阅预览概要(3句话概括目标FAQ) |
| | | func (c *Client) GenerateReviewPreview(targetTitle, targetContent string) (string, error) { |
| | | // 截取前2000字符作为上下文 |
| | | contentPreview := targetContent |
| | | if len(contentPreview) > 2000 { |
| | | contentPreview = contentPreview[:2000] + "..." |
| | | } |
| | | |
| | | prompt := fmt.Sprintf(`请用3句话概括以下知识库文档的核心内容,帮助用户快速了解这个文档讲的是什么: |
| | | |
| | | ## 文档标题 |
| | | %s |
| | | |
| | | ## 文档内容(部分) |
| | | %s |
| | | |
| | | 要求: |
| | | 1. 第一句:这个文档解决什么问题/讲什么主题 |
| | | 2. 第二句:核心内容/关键要点 |
| | | 3. 第三句:适用场景/使用条件 |
| | | |
| | | 直接输出3句话,不要编号,不要其他内容。`, targetTitle, contentPreview) |
| | | |
| | | response, err := c.callLLMWithRetry(prompt) |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | |
| | | return strings.TrimSpace(response), nil |
| | | } |
| | | |
| | | // ClassifyDocuments 分类文档 |
| | | func (c *Client) ClassifyDocuments(prompt string) (string, error) { |
| | | response, err := c.callLLMWithRetry(prompt) |
| New file |
| | |
| | | package review |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "regexp" |
| | | "strings" |
| | | |
| | | "gopkg.in/yaml.v3" |
| | | ) |
| | | |
| | | // DraftInfo 草稿基本信息 |
| | | type DraftInfo struct { |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Source string `json:"source"` |
| | | Tags []string `json:"tags"` |
| | | } |
| | | |
| | | // MergeHint 合并指示 |
| | | type MergeHint struct { |
| | | Action string `json:"action"` |
| | | Target string `json:"target"` |
| | | Reason string `json:"reason"` |
| | | Confidence string `json:"confidence"` |
| | | } |
| | | |
| | | // TargetFAQ 目标FAQ信息 |
| | | type TargetFAQ struct { |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Summary string `json:"summary"` // LLM 生成的3句话概要 |
| | | } |
| | | |
| | | // ReviewPreview 审阅预览结果 |
| | | type ReviewPreview struct { |
| | | Draft DraftInfo `json:"draft"` |
| | | DraftContent string `json:"draft_content"` // 草稿正文(去掉frontmatter) |
| | | MergeHint MergeHint `json:"merge_hint"` |
| | | TargetFAQ *TargetFAQ `json:"target_faq,omitempty"` |
| | | } |
| | | |
| | | // expandPath 展开路径中的 ~ |
| | | func expandPath(path string) string { |
| | | if strings.HasPrefix(path, "~/") { |
| | | homeDir, _ := os.UserHomeDir() |
| | | return filepath.Join(homeDir, path[2:]) |
| | | } |
| | | return path |
| | | } |
| | | |
| | | // extractMainContent 从 draft.md 中提取正文(去掉 frontmatter) |
| | | func extractMainContent(content string) string { |
| | | if !strings.HasPrefix(content, "---") { |
| | | return content |
| | | } |
| | | |
| | | // 找到第二个 --- |
| | | endIdx := strings.Index(content[3:], "\n---") |
| | | if endIdx == -1 { |
| | | return content |
| | | } |
| | | |
| | | // 返回 frontmatter 之后的内容 |
| | | mainContent := content[endIdx+6:] |
| | | return strings.TrimSpace(mainContent) |
| | | } |
| | | |
| | | // ReadDraftAndMerge 读取草稿目录的 draft.md 和 merge.md |
| | | func ReadDraftAndMerge(vaultPath, draftDir string) (*ReviewPreview, error) { |
| | | // 展开路径中的 ~ |
| | | vaultPath = expandPath(vaultPath) |
| | | |
| | | // 构建完整路径 |
| | | fullDraftDir := draftDir |
| | | if !filepath.IsAbs(draftDir) { |
| | | fullDraftDir = filepath.Join(vaultPath, draftDir) |
| | | } |
| | | |
| | | // 检查目录是否存在 |
| | | if _, err := os.Stat(fullDraftDir); os.IsNotExist(err) { |
| | | return nil, fmt.Errorf("目录不存在: %s", fullDraftDir) |
| | | } |
| | | |
| | | // 读取 draft.md |
| | | draftPath := filepath.Join(fullDraftDir, "draft.md") |
| | | draftContent, err := os.ReadFile(draftPath) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("读取 draft.md 失败: %w", err) |
| | | } |
| | | |
| | | // 解析 draft.md frontmatter |
| | | draftMeta := parseFrontmatter(string(draftContent)) |
| | | draftInfo := DraftInfo{ |
| | | Path: draftDir, |
| | | Title: draftMeta["title"], |
| | | Source: draftMeta["source"], |
| | | Tags: parseTags(draftMeta["tags"]), |
| | | } |
| | | |
| | | // 读取 merge.md |
| | | mergePath := filepath.Join(fullDraftDir, "merge.md") |
| | | mergeHint := MergeHint{ |
| | | Action: "new", |
| | | Confidence: "low", |
| | | } |
| | | |
| | | if mergeContent, err := os.ReadFile(mergePath); err == nil { |
| | | mergeHint = parseMergeMd(string(mergeContent)) |
| | | } |
| | | |
| | | // 构建预览结果 |
| | | preview := &ReviewPreview{ |
| | | Draft: draftInfo, |
| | | DraftContent: extractMainContent(string(draftContent)), |
| | | MergeHint: mergeHint, |
| | | } |
| | | |
| | | // 如果 action=merge,读取目标FAQ |
| | | if mergeHint.Action == "merge" && mergeHint.Target != "" { |
| | | targetPath := mergeHint.Target |
| | | if !filepath.IsAbs(targetPath) { |
| | | targetPath = filepath.Join(vaultPath, targetPath) |
| | | } |
| | | |
| | | if targetContent, err := os.ReadFile(targetPath); err == nil { |
| | | targetMeta := parseFrontmatter(string(targetContent)) |
| | | preview.TargetFAQ = &TargetFAQ{ |
| | | Path: mergeHint.Target, |
| | | Title: targetMeta["title"], |
| | | } |
| | | } |
| | | } |
| | | |
| | | return preview, nil |
| | | } |
| | | |
| | | // parseFrontmatter 解析 YAML frontmatter |
| | | func parseFrontmatter(content string) map[string]string { |
| | | result := make(map[string]string) |
| | | |
| | | if !strings.HasPrefix(content, "---") { |
| | | return result |
| | | } |
| | | |
| | | // 找到第二个 --- |
| | | endIdx := strings.Index(content[3:], "\n---") |
| | | if endIdx == -1 { |
| | | return result |
| | | } |
| | | |
| | | yamlStr := content[3 : endIdx+3] |
| | | var data map[string]interface{} |
| | | if err := yaml.Unmarshal([]byte(yamlStr), &data); err != nil { |
| | | return result |
| | | } |
| | | |
| | | // 转换为 string map |
| | | for k, v := range data { |
| | | switch val := v.(type) { |
| | | case string: |
| | | result[k] = val |
| | | case []interface{}: |
| | | // tags 可能是数组 |
| | | var tags []string |
| | | for _, t := range val { |
| | | if s, ok := t.(string); ok { |
| | | tags = append(tags, s) |
| | | } |
| | | } |
| | | if len(tags) > 0 { |
| | | result[k] = strings.Join(tags, ",") |
| | | } |
| | | } |
| | | } |
| | | |
| | | return result |
| | | } |
| | | |
| | | // parseTags 解析 tags 字符串为数组 |
| | | func parseTags(tagsStr string) []string { |
| | | if tagsStr == "" { |
| | | return []string{} |
| | | } |
| | | tags := strings.Split(tagsStr, ",") |
| | | result := make([]string, 0, len(tags)) |
| | | for _, t := range tags { |
| | | t = strings.TrimSpace(t) |
| | | if t != "" { |
| | | result = append(result, t) |
| | | } |
| | | } |
| | | return result |
| | | } |
| | | |
| | | // parseMergeMd 解析 merge.md 内容 |
| | | func parseMergeMd(content string) MergeHint { |
| | | result := MergeHint{ |
| | | Action: "new", |
| | | Confidence: "low", |
| | | } |
| | | |
| | | // 解析 frontmatter 中的 confidence |
| | | fmMatch := regexp.MustCompile(`(?s)^---\s*\n(.*?)\n---`).FindStringSubmatch(content) |
| | | if len(fmMatch) > 1 { |
| | | var fm map[string]interface{} |
| | | if err := yaml.Unmarshal([]byte(fmMatch[1]), &fm); err == nil { |
| | | if conf, ok := fm["confidence"].(string); ok { |
| | | result.Confidence = conf |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 提取操作类型 |
| | | if match := regexp.MustCompile(`\*\*操作类型\*\*:\s*(\w+)`).FindStringSubmatch(content); len(match) > 1 { |
| | | result.Action = match[1] |
| | | } |
| | | |
| | | // 提取目标 |
| | | if match := regexp.MustCompile(`\*\*目标\*\*:[ \t]*(.+)`).FindStringSubmatch(content); len(match) > 1 { |
| | | result.Target = strings.TrimSpace(match[1]) |
| | | } |
| | | |
| | | // 提取理由 |
| | | if match := regexp.MustCompile(`\*\*理由\*\*:[ \t]*(.+)`).FindStringSubmatch(content); len(match) > 1 { |
| | | result.Reason = strings.TrimSpace(match[1]) |
| | | } |
| | | |
| | | return result |
| | | } |
| | | |
| | | // FormatPreviewJSON 格式化预览结果为 JSON |
| | | func FormatPreviewJSON(preview *ReviewPreview) (string, error) { |
| | | data, err := json.MarshalIndent(preview, "", " ") |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | return string(data), nil |
| | | } |
| | | |
| | | // FormatPreviewTable 格式化预览结果为表格 |
| | | func FormatPreviewTable(preview *ReviewPreview) string { |
| | | var sb strings.Builder |
| | | |
| | | sb.WriteString("## 📋 审阅预览\n\n") |
| | | |
| | | // 草稿信息 |
| | | sb.WriteString("### 草稿信息\n\n") |
| | | sb.WriteString(fmt.Sprintf("**标题:** %s\n", preview.Draft.Title)) |
| | | sb.WriteString(fmt.Sprintf("**路径:** `%s`\n", preview.Draft.Path)) |
| | | if preview.Draft.Source != "" { |
| | | sb.WriteString(fmt.Sprintf("**来源:** %s\n", preview.Draft.Source)) |
| | | } |
| | | if len(preview.Draft.Tags) > 0 { |
| | | sb.WriteString(fmt.Sprintf("**标签:** %s\n", strings.Join(preview.Draft.Tags, ", "))) |
| | | } |
| | | sb.WriteString("\n") |
| | | |
| | | // 合并指示 |
| | | sb.WriteString("### 合并指示\n\n") |
| | | sb.WriteString(fmt.Sprintf("**操作类型:** %s\n", preview.MergeHint.Action)) |
| | | if preview.MergeHint.Target != "" { |
| | | sb.WriteString(fmt.Sprintf("**目标:** `%s`\n", preview.MergeHint.Target)) |
| | | } |
| | | if preview.MergeHint.Reason != "" { |
| | | sb.WriteString(fmt.Sprintf("**理由:** %s\n", preview.MergeHint.Reason)) |
| | | } |
| | | sb.WriteString(fmt.Sprintf("**置信度:** %s\n\n", preview.MergeHint.Confidence)) |
| | | |
| | | // 目标FAQ概要 |
| | | if preview.TargetFAQ != nil { |
| | | sb.WriteString("### 目标FAQ概要\n\n") |
| | | sb.WriteString(fmt.Sprintf("**标题:** %s\n", preview.TargetFAQ.Title)) |
| | | sb.WriteString(fmt.Sprintf("**路径:** `%s`\n", preview.TargetFAQ.Path)) |
| | | if preview.TargetFAQ.Summary != "" { |
| | | sb.WriteString(fmt.Sprintf("**概要:**\n%s\n", preview.TargetFAQ.Summary)) |
| | | } |
| | | sb.WriteString("\n") |
| | | } |
| | | |
| | | // 操作选项 |
| | | sb.WriteString("---\n\n") |
| | | sb.WriteString("**请确认:**\n") |
| | | sb.WriteString("- A) 追加/合并到目标 FAQ\n") |
| | | sb.WriteString("- B) 归档(保留历史但不合并)\n") |
| | | sb.WriteString("- C) 删除(内容简单无复用价值)\n") |
| | | sb.WriteString("- D) 保留跟进(更新提醒日期)\n") |
| | | |
| | | return sb.String() |
| | | } |