10 files modified
21 files added
| New file |
| | |
| | | .PHONY: build test clean install |
| | | |
| | | # 构建 |
| | | build: |
| | | CGO_ENABLED=1 go build -tags fts5 -o bin/kb-cli . |
| | | |
| | | # 安装到 ~/go/bin |
| | | install: |
| | | CGO_ENABLED=1 go install -tags fts5 . |
| | | |
| | | # 测试 |
| | | test: |
| | | CGO_ENABLED=1 go test -tags fts5 ./... |
| | | |
| | | # 详细测试 |
| | | test-v: |
| | | CGO_ENABLED=1 go test -tags fts5 -v ./... |
| | | |
| | | # 清理 |
| | | clean: |
| | | rm -rf bin/ |
| | | go clean |
| | | |
| | | # 运行示例 |
| | | run-search: |
| | | ./bin/kb-cli search 充装 --top 5 |
| | | |
| | | run-index: |
| | | ./bin/kb-cli index build |
| | | |
| | | run-status: |
| | | ./bin/kb-cli index status |
| New file |
| | |
| | | # kb-cli |
| | | |
| | | 知识库 CLI 工具,支持 Obsidian 风格 Markdown 文件的图谱搜索。 |
| | | |
| | | ## 功能特性 |
| | | |
| | | - ✅ **Vault 解析器**:解析 Obsidian frontmatter、标签、实体、wikilinks |
| | | - ✅ **知识图谱构建**:文件 → 节点,标签/实体/wikilinks → 边 |
| | | - ✅ **SQLite 存储**:高效持久化,支持增量更新 |
| | | - ✅ **FTS5 全文搜索**:基于 SQLite FTS5 的快速搜索 |
| | | - ✅ **图谱评分算法**:考虑标签、实体、wikilinks 权重 |
| | | - ✅ **多种输出格式**:表格、JSON |
| | | - ✅ **内容返回**:`--with-content` 返回完整文件内容 |
| | | - ✅ **关联链接**:`--with-links` 返回 wikilink 关联文档 |
| | | |
| | | ## 安装 |
| | | |
| | | ```bash |
| | | # 编译 |
| | | make build |
| | | |
| | | # 安装到 ~/go/bin |
| | | make install |
| | | |
| | | # 运行测试 |
| | | make test |
| | | ``` |
| | | |
| | | **注意**:需要 CGO 和 FTS5 支持。 |
| | | |
| | | ## 使用方法 |
| | | |
| | | ### 搜索 |
| | | |
| | | ```bash |
| | | # 基础搜索 |
| | | kb-cli search 充装规格 |
| | | |
| | | # 带扩展词 |
| | | kb-cli search 充装 --expanded 重量,规格 |
| | | |
| | | # 带症状词 |
| | | kb-cli search 充装失败 --symptom 报错,无法启动 |
| | | |
| | | # 返回完整文件内容 |
| | | kb-cli search 充装规格 --with-content |
| | | |
| | | # 返回关联文档链接 |
| | | kb-cli search 充装规格 --with-links |
| | | |
| | | # 同时返回内容和链接 |
| | | kb-cli search 充装规格 --with-content --with-links |
| | | |
| | | # JSON 输出 |
| | | kb-cli search 充装 --json |
| | | |
| | | # 限制结果数 |
| | | kb-cli search 充装 --top 5 |
| | | ``` |
| | | |
| | | ### 索引管理 |
| | | |
| | | ```bash |
| | | # 构建/重建索引 |
| | | kb-cli index build |
| | | |
| | | # 查看索引状态 |
| | | kb-cli index status |
| | | |
| | | # 清理孤立节点 |
| | | kb-cli index gc |
| | | ``` |
| | | |
| | | ### Git 同步 |
| | | |
| | | ```bash |
| | | # 同步知识库到 Git 仓库 |
| | | kb-cli git sync |
| | | ``` |
| | | |
| | | ### 全局选项 |
| | | |
| | | ```bash |
| | | --vault 知识库路径(默认:~/aisim/note/001/笔记001) |
| | | --db 索引数据库路径(默认:~/.cache/kb-cli/kb.db) |
| | | ``` |
| | | |
| | | ## 项目结构 |
| | | |
| | | ``` |
| | | kb-cli/ |
| | | ├── cmd/ # CLI 命令 |
| | | │ ├── root.go # 根命令 |
| | | │ ├── search.go # search 命令 |
| | | │ ├── index.go # index 命令 |
| | | │ └── rebuild.go # 索引重建逻辑 |
| | | ├── internal/ |
| | | │ ├── vault/ # Vault 解析器 |
| | | │ │ ├── parser.go |
| | | │ │ ├── scanner.go |
| | | │ │ └── sections.go |
| | | │ ├── graph/ # 知识图谱 |
| | | │ │ ├── model.go |
| | | │ │ └── builder.go |
| | | │ ├── index/ # SQLite 存储 |
| | | │ │ ├── sqlite.go |
| | | │ │ ├── fts.go |
| | | │ │ └── cache.go |
| | | │ ├── search/ # 搜索引擎 |
| | | │ │ ├── engine.go |
| | | │ │ └── scorer.go |
| | | │ └── output/ # 输出格式化 |
| | | │ └── formatter.go |
| | | └── main.go |
| | | ``` |
| | | |
| | | ## 评分算法 |
| | | |
| | | 搜索结果评分考虑: |
| | | - **FTS5 rank**:全文搜索相关性 |
| | | - **标签匹配**:标签权重 2.0 |
| | | - **实体匹配**:实体权重 1.5 |
| | | - **Wikilinks**:引用关系权重 1.2 |
| | | - **扩展词加成**:提升相关实体权重 |
| | | |
| | | ## 开发 |
| | | |
| | | ```bash |
| | | # 运行所有测试 |
| | | make test |
| | | |
| | | # 详细测试输出 |
| | | make test-v |
| | | |
| | | # 构建并运行 |
| | | make build |
| | | ./bin/kb search 充装 |
| | | ``` |
| | | |
| | | ## 依赖 |
| | | |
| | | - Go 1.21+ |
| | | - SQLite3(带 FTS5 支持) |
| | | - github.com/mattn/go-sqlite3 |
| | | - github.com/spf13/cobra |
| | | - gopkg.in/yaml.v3 |
| | | |
| | | ## 许可证 |
| | | |
| | | MIT |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "fmt" |
| | | |
| | | "github.com/aisim/kb-cli/internal/classify" |
| | | "github.com/aisim/kb-cli/internal/llm" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var classifyCmd = &cobra.Command{ |
| | | Use: "classify", |
| | | Short: "使用 LLM 对知识库文档进行分类", |
| | | Long: `# classify - LLM 分类 |
| | | kb-cli classify [--batch-size <数量>] [--dry-run] # 使用 LLM 分析文档,提取平台、设备、内容类型`, |
| | | RunE: runClassify, |
| | | } |
| | | |
| | | var ( |
| | | classifyBatchSize int |
| | | classifyDryRun bool |
| | | ) |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(classifyCmd) |
| | | classifyCmd.Flags().IntVar(&classifyBatchSize, "batch-size", 10, "每批处理的文档数量") |
| | | classifyCmd.Flags().BoolVar(&classifyDryRun, "dry-run", false, "仅显示将要执行的操作") |
| | | } |
| | | |
| | | func runClassify(cmd *cobra.Command, args []string) error { |
| | | vaultPath := cmd.Flag("vault").Value.String() |
| | | |
| | | // 创建 LLM 客户端(从配置文件加载) |
| | | llmClient := llm.NewClient() |
| | | |
| | | fmt.Println("步骤 1/3: 提取实体信息...") |
| | | entities, relations, err := classify.ExtractEntities(vaultPath) |
| | | if err != nil { |
| | | return fmt.Errorf("提取实体失败: %w", err) |
| | | } |
| | | |
| | | if !classifyDryRun { |
| | | if err := classify.SaveEntities(vaultPath, entities, relations); err != nil { |
| | | return fmt.Errorf("保存实体失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 已生成 entities.json 和 relations.json\n") |
| | | } else { |
| | | fmt.Printf("[DRY-RUN] 将生成 entities.json 和 relations.json\n") |
| | | } |
| | | |
| | | fmt.Println("\n步骤 2/3: 扫描文档...") |
| | | docs, err := classify.ScanDocuments(vaultPath) |
| | | if err != nil { |
| | | return fmt.Errorf("扫描文档失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 发现 %d 个文档\n", len(docs)) |
| | | |
| | | if len(docs) == 0 { |
| | | fmt.Println("警告: 未发现任何文档") |
| | | return nil |
| | | } |
| | | |
| | | fmt.Println("\n步骤 3/3: 使用 LLM 分类...") |
| | | if classifyDryRun { |
| | | fmt.Printf("[DRY-RUN] 将对 %d 个文档进行分类(批次大小: %d)\n", len(docs), classifyBatchSize) |
| | | return nil |
| | | } |
| | | |
| | | // 分批处理 |
| | | var allResults []classify.Classification |
| | | for i := 0; i < len(docs); i += classifyBatchSize { |
| | | end := i + classifyBatchSize |
| | | if end > len(docs) { |
| | | end = len(docs) |
| | | } |
| | | |
| | | batch := docs[i:end] |
| | | fmt.Printf("处理批次 %d-%d / %d...\n", i+1, end, len(docs)) |
| | | |
| | | results, err := classify.ClassifyBatch(batch, llmClient) |
| | | if err != nil { |
| | | fmt.Printf("警告: 批次 %d 分类失败: %v\n", i/classifyBatchSize+1, err) |
| | | continue |
| | | } |
| | | |
| | | allResults = append(allResults, results...) |
| | | } |
| | | |
| | | if len(allResults) == 0 { |
| | | return fmt.Errorf("分类失败: 未获得任何结果") |
| | | } |
| | | |
| | | if err := classify.SaveClassification(vaultPath, allResults); err != nil { |
| | | return fmt.Errorf("保存分类结果失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 已生成 classification.json(%d 个文档)\n", len(allResults)) |
| | | |
| | | return nil |
| | | } |
| 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: `# draft create - 创建草稿 |
| | | kb-cli draft create --type <类型> --title <标题> --content-file <文件> [--source <来源>] [--force] # 创建草稿,自动提取 tags 并生成合并指示`, |
| | | RunE: runDraftCreate, |
| | | } |
| | | |
| | | var tagsCmd = &cobra.Command{ |
| | | Use: "tags", |
| | | Short: "标签管理", |
| | | Long: `kb-cli tags rebuild # 批量重建现有文档的 tags`, |
| | | } |
| | | |
| | | var tagsRebuildCmd = &cobra.Command{ |
| | | Use: "rebuild", |
| | | Short: "批量重建 tags", |
| | | Long: `# tags rebuild - 批量重建 tags |
| | | kb-cli tags rebuild [--vault <路径>] [--limit <数量>] [--dry-run] # 使用 LLM 重新提取现有文档的 tags`, |
| | | 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 cmd |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var graphCmd = &cobra.Command{ |
| | | Use: "graph", |
| | | Short: "知识图谱查询与管理", |
| | | } |
| | | |
| | | var graphStatsCmd = &cobra.Command{ |
| | | Use: "stats", |
| | | Short: "查看知识图谱统计信息", |
| | | Long: `kb-cli graph stats [--json] # 查看知识图谱统计信息(节点数、边数、关系类型分布)`, |
| | | RunE: runGraphStats, |
| | | } |
| | | |
| | | var graphQueryCmd = &cobra.Command{ |
| | | Use: "query <关键词>", |
| | | Short: "查询节点的关联关系", |
| | | Long: `kb-cli graph query <关键词> [--relation <类型>] [--depth <深度>] [--json] # 查询节点的关联关系`, |
| | | Args: cobra.ExactArgs(1), |
| | | RunE: runGraphQuery, |
| | | } |
| | | |
| | | var graphRelatedCmd = &cobra.Command{ |
| | | Use: "related <关键词>", |
| | | Short: "查找与关键词相关的所有节点", |
| | | Long: `kb-cli graph related <关键词> [--top N] [--json] # 查找与关键词相关的所有节点`, |
| | | Args: cobra.ExactArgs(1), |
| | | RunE: runGraphRelated, |
| | | } |
| | | |
| | | var ( |
| | | graphRelation string |
| | | graphDepth int |
| | | graphJSON bool |
| | | graphTopN int |
| | | ) |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(graphCmd) |
| | | graphCmd.AddCommand(graphStatsCmd) |
| | | graphCmd.AddCommand(graphQueryCmd) |
| | | graphCmd.AddCommand(graphRelatedCmd) |
| | | |
| | | // graph stats flags |
| | | graphStatsCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出") |
| | | |
| | | // graph query flags |
| | | graphQueryCmd.Flags().StringVar(&graphRelation, "relation", "", "关系类型(tag/entity/wikilink)") |
| | | graphQueryCmd.Flags().IntVar(&graphDepth, "depth", 1, "查询深度(1-3)") |
| | | graphQueryCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出") |
| | | |
| | | // graph related flags |
| | | graphRelatedCmd.Flags().IntVar(&graphTopN, "top", 10, "返回前 N 条结果") |
| | | graphRelatedCmd.Flags().BoolVar(&graphJSON, "json", false, "JSON 格式输出") |
| | | } |
| | | |
| | | func runGraphStats(cmd *cobra.Command, args []string) error { |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 获取节点数和边数 |
| | | nodeCount, err := store.NodeCount() |
| | | if err != nil { |
| | | return fmt.Errorf("获取节点数失败: %w", err) |
| | | } |
| | | |
| | | edgeCount, err := store.EdgeCount() |
| | | if err != nil { |
| | | return fmt.Errorf("获取边数失败: %w", err) |
| | | } |
| | | |
| | | // 获取关系类型分布 |
| | | relationStats, err := store.GetRelationStats() |
| | | if err != nil { |
| | | return fmt.Errorf("获取关系统计失败: %w", err) |
| | | } |
| | | |
| | | if graphJSON { |
| | | stats := map[string]interface{}{ |
| | | "node_count": nodeCount, |
| | | "edge_count": edgeCount, |
| | | "relations": relationStats, |
| | | } |
| | | data, err := json.MarshalIndent(stats, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | fmt.Println(string(data)) |
| | | } else { |
| | | fmt.Printf("知识图谱统计:\n") |
| | | fmt.Printf(" 节点数: %d\n", nodeCount) |
| | | fmt.Printf(" 边数: %d\n", edgeCount) |
| | | fmt.Printf("\n关系类型分布:\n") |
| | | for rel, count := range relationStats { |
| | | fmt.Printf(" %-12s %d\n", rel, count) |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | func runGraphQuery(cmd *cobra.Command, args []string) error { |
| | | keyword := args[0] |
| | | |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 查找匹配关键词的节点 |
| | | nodes, err := store.FindNodesByKeyword(keyword) |
| | | if err != nil { |
| | | return fmt.Errorf("查找节点失败: %w", err) |
| | | } |
| | | |
| | | if len(nodes) == 0 { |
| | | fmt.Println("未找到匹配的节点") |
| | | return nil |
| | | } |
| | | |
| | | // 查询每个节点的关联关系 |
| | | var results []QueryResult |
| | | for _, node := range nodes { |
| | | edges, err := store.GetNodeEdges(node.ID, graphRelation, graphDepth) |
| | | if err != nil { |
| | | return fmt.Errorf("查询关联失败: %w", err) |
| | | } |
| | | |
| | | results = append(results, QueryResult{ |
| | | Node: node, |
| | | Edges: edges, |
| | | }) |
| | | } |
| | | |
| | | if graphJSON { |
| | | data, err := json.MarshalIndent(results, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | fmt.Println(string(data)) |
| | | } else { |
| | | for _, r := range results { |
| | | fmt.Printf("\n节点: %s (%s)\n", r.Node.Title, r.Node.Path) |
| | | if len(r.Edges) == 0 { |
| | | fmt.Println(" 无关联关系") |
| | | continue |
| | | } |
| | | |
| | | // 按关系类型分组 |
| | | grouped := make(map[string][]string) |
| | | for _, edge := range r.Edges { |
| | | grouped[edge.Relation] = append(grouped[edge.Relation], edge.Label) |
| | | } |
| | | |
| | | for rel, labels := range grouped { |
| | | fmt.Printf(" [%s]\n", rel) |
| | | for _, label := range labels { |
| | | fmt.Printf(" - %s\n", label) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | func runGraphRelated(cmd *cobra.Command, args []string) error { |
| | | keyword := args[0] |
| | | |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 查找相关节点 |
| | | related, err := store.FindRelatedNodes(keyword, graphTopN) |
| | | if err != nil { |
| | | return fmt.Errorf("查找相关节点失败: %w", err) |
| | | } |
| | | |
| | | if len(related) == 0 { |
| | | fmt.Println("未找到相关节点") |
| | | return nil |
| | | } |
| | | |
| | | if graphJSON { |
| | | data, err := json.MarshalIndent(related, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | fmt.Println(string(data)) |
| | | } else { |
| | | fmt.Printf("与 '%s' 相关的节点:\n\n", keyword) |
| | | for i, r := range related { |
| | | fmt.Printf("%d. %s\n", i+1, r.Title) |
| | | fmt.Printf(" 路径: %s\n", r.Path) |
| | | fmt.Printf(" 板块: %s\n", r.Section) |
| | | fmt.Printf(" 关联度: %d\n", r.Relevance) |
| | | if len(r.Tags) > 0 { |
| | | fmt.Printf(" 标签: %v\n", r.Tags) |
| | | } |
| | | fmt.Println() |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | // QueryResult 查询结果 |
| | | type QueryResult struct { |
| | | Node *graph.Node `json:"node"` |
| | | Edges []*graph.Edge `json:"edges"` |
| | | } |
| | | |
| | | // RelatedNode 相关节点 |
| | | type RelatedNode struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Tags []string `json:"tags"` |
| | | Relevance int `json:"relevance"` |
| | | } |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "os/exec" |
| | | "path/filepath" |
| | | "strings" |
| | | "time" |
| | | |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var indexCmd = &cobra.Command{ |
| | | Use: "index", |
| | | Short: "索引管理", |
| | | } |
| | | |
| | | var indexBuildCmd = &cobra.Command{ |
| | | Use: "build", |
| | | Short: "构建或重建知识库索引", |
| | | Long: `kb-cli index build [--vault <路径>] [--db <路径>] # 构建或重建知识库索引`, |
| | | RunE: runIndexBuild, |
| | | } |
| | | |
| | | var indexStatusCmd = &cobra.Command{ |
| | | Use: "status", |
| | | Short: "查看索引状态信息", |
| | | Long: `kb-cli index status [--vault <路径>] [--db <路径>] # 查看索引状态(节点数、边数、commit、构建时间)`, |
| | | RunE: runIndexStatus, |
| | | } |
| | | |
| | | var indexGcCmd = &cobra.Command{ |
| | | Use: "gc", |
| | | Short: "清理孤立节点和边", |
| | | Long: `kb-cli index gc [--vault <路径>] [--db <路径>] # 清理索引中文件已删除的孤立节点和边`, |
| | | RunE: runIndexGc, |
| | | } |
| | | |
| | | var gitCmd = &cobra.Command{ |
| | | Use: "git", |
| | | Short: "Git 同步操作", |
| | | } |
| | | |
| | | var gitSyncCmd = &cobra.Command{ |
| | | Use: "sync", |
| | | Short: "同步知识库到 Git 仓库", |
| | | Long: `kb-cli git sync [--vault <路径>] # 执行 git add、commit、push 同步知识库`, |
| | | RunE: runGitSync, |
| | | } |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(indexCmd) |
| | | indexCmd.AddCommand(indexBuildCmd) |
| | | indexCmd.AddCommand(indexStatusCmd) |
| | | indexCmd.AddCommand(indexGcCmd) |
| | | |
| | | rootCmd.AddCommand(gitCmd) |
| | | gitCmd.AddCommand(gitSyncCmd) |
| | | } |
| | | |
| | | func runIndexBuild(cmd *cobra.Command, args []string) error { |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 获取当前 commit |
| | | commit, err := index.GetGitCommit(vaultPath) |
| | | if err != nil { |
| | | fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err) |
| | | commit = "" |
| | | } |
| | | |
| | | fmt.Fprintln(os.Stderr, "正在重建索引...") |
| | | if err := rebuildIndex(store, commit); err != nil { |
| | | return fmt.Errorf("重建索引失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintln(os.Stderr, "索引重建完成") |
| | | return nil |
| | | } |
| | | |
| | | func runIndexStatus(cmd *cobra.Command, args []string) error { |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 获取元信息 |
| | | gitCommit, err := store.GetMeta("git_commit") |
| | | if err != nil { |
| | | return fmt.Errorf("获取元信息失败: %w", err) |
| | | } |
| | | builtAt, _ := store.GetMeta("built_at") |
| | | |
| | | // 获取节点数 |
| | | nodeCount, err := store.NodeCount() |
| | | if err != nil { |
| | | return fmt.Errorf("获取节点数失败: %w", err) |
| | | } |
| | | |
| | | // 获取边数 |
| | | edgeCount, err := store.EdgeCount() |
| | | if err != nil { |
| | | return fmt.Errorf("获取边数失败: %w", err) |
| | | } |
| | | |
| | | // 获取当前 commit |
| | | currentCommit, err := index.GetGitCommit(vaultPath) |
| | | if err != nil { |
| | | currentCommit = "" |
| | | } |
| | | |
| | | // 输出状态 |
| | | fmt.Printf("索引状态:\n") |
| | | fmt.Printf(" 知识库路径: %s\n", vaultPath) |
| | | fmt.Printf(" 索引文件: %s\n", dbPath) |
| | | fmt.Printf(" 节点数: %d\n", nodeCount) |
| | | fmt.Printf(" 边数: %d\n", edgeCount) |
| | | fmt.Printf(" 索引 commit: %s\n", gitCommit) |
| | | fmt.Printf(" 当前 commit: %s\n", currentCommit) |
| | | fmt.Printf(" 构建时间: %s\n", builtAt) |
| | | |
| | | // 检查是否需要更新 |
| | | needsRebuild, _, err := index.NeedsRebuild(store, vaultPath) |
| | | if err != nil { |
| | | return fmt.Errorf("检查索引状态失败: %w", err) |
| | | } |
| | | |
| | | if needsRebuild { |
| | | fmt.Printf(" 状态: 需要更新\n") |
| | | } else { |
| | | fmt.Printf(" 状态: 最新\n") |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | func runIndexGc(cmd *cobra.Command, args []string) error { |
| | | // 展开 ~ 为实际路径 |
| | | expandedVaultPath := vaultPath |
| | | if strings.HasPrefix(vaultPath, "~/") { |
| | | home, err := os.UserHomeDir() |
| | | if err != nil { |
| | | return fmt.Errorf("获取用户目录失败: %w", err) |
| | | } |
| | | expandedVaultPath = filepath.Join(home, vaultPath[2:]) |
| | | } |
| | | |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | fmt.Fprintln(os.Stderr, "正在清理孤立节点和边...") |
| | | |
| | | // 获取所有节点路径 |
| | | nodes, err := store.GetAllNodes() |
| | | if err != nil { |
| | | return fmt.Errorf("获取节点失败: %w", err) |
| | | } |
| | | |
| | | // 检查文件是否存在 |
| | | var orphanPaths []string |
| | | for _, node := range nodes { |
| | | fullPath := filepath.Join(expandedVaultPath, node.Path) |
| | | if _, err := os.Stat(fullPath); os.IsNotExist(err) { |
| | | orphanPaths = append(orphanPaths, node.Path) |
| | | } |
| | | } |
| | | |
| | | if len(orphanPaths) == 0 { |
| | | fmt.Fprintln(os.Stderr, "没有发现孤立节点") |
| | | return nil |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "发现 %d 个孤立节点,正在清理...\n", len(orphanPaths)) |
| | | |
| | | // 删除孤立节点 |
| | | deletedCount, err := store.DeleteNodesByPaths(orphanPaths) |
| | | if err != nil { |
| | | return fmt.Errorf("删除节点失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "已清理 %d 个孤立节点\n", deletedCount) |
| | | return nil |
| | | } |
| | | |
| | | func runGitSync(cmd *cobra.Command, args []string) error { |
| | | // 展开 ~ 为实际路径 |
| | | expandedVaultPath := vaultPath |
| | | if strings.HasPrefix(vaultPath, "~/") { |
| | | home, err := os.UserHomeDir() |
| | | if err != nil { |
| | | return fmt.Errorf("获取用户目录失败: %w", err) |
| | | } |
| | | expandedVaultPath = filepath.Join(home, vaultPath[2:]) |
| | | } |
| | | |
| | | fmt.Fprintln(os.Stderr, "正在同步知识库到 Git 仓库...") |
| | | |
| | | // 检查是否是 git 仓库 |
| | | gitDir := filepath.Join(expandedVaultPath, ".git") |
| | | if _, err := os.Stat(gitDir); os.IsNotExist(err) { |
| | | return fmt.Errorf("知识库目录不是 Git 仓库: %s", expandedVaultPath) |
| | | } |
| | | |
| | | // 执行 git add -A |
| | | addCmd := exec.Command("git", "-C", expandedVaultPath, "add", "-A") |
| | | if output, err := addCmd.CombinedOutput(); err != nil { |
| | | return fmt.Errorf("git add 失败: %w\n%s", err, output) |
| | | } |
| | | |
| | | // 检查是否有变更 |
| | | statusCmd := exec.Command("git", "-C", expandedVaultPath, "status", "--porcelain") |
| | | statusOutput, err := statusCmd.Output() |
| | | if err != nil { |
| | | return fmt.Errorf("git status 失败: %w", err) |
| | | } |
| | | |
| | | if len(statusOutput) == 0 { |
| | | fmt.Fprintln(os.Stderr, "没有变更需要提交") |
| | | return nil |
| | | } |
| | | |
| | | // 生成 commit 信息 |
| | | commitMsg := fmt.Sprintf("kb-cli: 自动同步 %s", time.Now().Format("2006-01-02 15:04:05")) |
| | | commitCmd := exec.Command("git", "-C", expandedVaultPath, "commit", "-m", commitMsg) |
| | | if output, err := commitCmd.CombinedOutput(); err != nil { |
| | | return fmt.Errorf("git commit 失败: %w\n%s", err, output) |
| | | } |
| | | |
| | | // 执行 git push |
| | | pushCmd := exec.Command("git", "-C", expandedVaultPath, "push") |
| | | if output, err := pushCmd.CombinedOutput(); err != nil { |
| | | return fmt.Errorf("git push 失败: %w\n%s", err, output) |
| | | } |
| | | |
| | | fmt.Fprintln(os.Stderr, "Git 同步完成") |
| | | return nil |
| | | } |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "time" |
| | | |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | | // rebuildIndex 重建索引:扫描 vault → 构建图 → 写入 SQLite |
| | | func rebuildIndex(store *index.Store, commit string) error { |
| | | // 清空旧数据 |
| | | if err := store.ClearData(); err != nil { |
| | | return fmt.Errorf("清空数据失败: %w", err) |
| | | } |
| | | |
| | | // 扫描 vault |
| | | files, err := vault.ScanVault(vaultPath) |
| | | if err != nil { |
| | | return fmt.Errorf("扫描知识库失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "扫描到 %d 个文件\n", len(files)) |
| | | |
| | | // 构建图 |
| | | g := graph.BuildGraph(files) |
| | | |
| | | // 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射 |
| | | idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID |
| | | for _, n := range g.Nodes { |
| | | sqliteID, err := store.InsertNode(n) |
| | | if err != nil { |
| | | return fmt.Errorf("插入节点失败 [%s]: %w", n.Path, err) |
| | | } |
| | | idMap[n.ID] = sqliteID |
| | | } |
| | | |
| | | // 写入边,将 BuildGraph ID 转换为 SQLite ID |
| | | for _, e := range g.Edges { |
| | | fromID, ok := idMap[e.FromNode] |
| | | if !ok { |
| | | // 可能是虚拟节点(tag/entity),跳过 |
| | | continue |
| | | } |
| | | toID, ok := idMap[e.ToNode] |
| | | if !ok { |
| | | // 目标节点不存在,跳过 |
| | | continue |
| | | } |
| | | edge := &graph.Edge{ |
| | | FromNode: fromID, |
| | | ToNode: toID, |
| | | Relation: e.Relation, |
| | | Label: e.Label, |
| | | } |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | return fmt.Errorf("插入边失败: %w", err) |
| | | } |
| | | } |
| | | |
| | | // 创建并填充 FTS5 索引 |
| | | if err := store.CreateFTS(); err != nil { |
| | | return fmt.Errorf("创建 FTS 索引失败: %w", err) |
| | | } |
| | | if err := store.PopulateFTS(); err != nil { |
| | | return fmt.Errorf("填充 FTS 索引失败: %w", err) |
| | | } |
| | | |
| | | // 记录元信息 |
| | | if commit != "" { |
| | | if err := store.SetMeta("git_commit", commit); err != nil { |
| | | return fmt.Errorf("记录 git commit 失败: %w", err) |
| | | } |
| | | } |
| | | if err := store.SetMeta("built_at", time.Now().Format(time.RFC3339)); err != nil { |
| | | return fmt.Errorf("记录构建时间失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), len(g.Edges)) |
| | | return nil |
| | | } |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "fmt" |
| | | |
| | | "github.com/aisim/kb-cli/internal/classify" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var reorgCmd = &cobra.Command{ |
| | | Use: "reorg", |
| | | Short: "根据分类结果重组知识库目录", |
| | | Long: `# reorg - 目录重组 |
| | | kb-cli reorg [--plan <文件>] [--dry-run] [--execute] # 根据分类结果重组知识库目录`, |
| | | RunE: runReorg, |
| | | } |
| | | |
| | | var ( |
| | | reorgPlan string |
| | | reorgDryRun bool |
| | | reorgExecute bool |
| | | ) |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(reorgCmd) |
| | | reorgCmd.Flags().StringVar(&reorgPlan, "plan", "classification.json", "分类结果文件") |
| | | reorgCmd.Flags().BoolVar(&reorgDryRun, "dry-run", false, "仅生成迁移计划,不执行移动") |
| | | reorgCmd.Flags().BoolVar(&reorgExecute, "execute", false, "执行迁移(需要明确确认)") |
| | | } |
| | | |
| | | func runReorg(cmd *cobra.Command, args []string) error { |
| | | vaultPath := cmd.Flag("vault").Value.String() |
| | | |
| | | fmt.Println("步骤 1/3: 读取分类结果...") |
| | | classification, err := classify.LoadClassification(vaultPath, reorgPlan) |
| | | if err != nil { |
| | | return fmt.Errorf("读取分类结果失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 读取 %d 个文档分类\n", len(classification.Items)) |
| | | |
| | | fmt.Println("\n步骤 2/3: 生成迁移计划...") |
| | | plan, err := classify.GenerateMigrationPlan(vaultPath, classification) |
| | | if err != nil { |
| | | return fmt.Errorf("生成迁移计划失败: %w", err) |
| | | } |
| | | |
| | | if err := classify.SaveMigrationPlan(vaultPath, plan); err != nil { |
| | | return fmt.Errorf("保存迁移计划失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 已生成 migration-plan.json(%d 个文件)\n", len(plan.Migrations)) |
| | | |
| | | if reorgDryRun { |
| | | fmt.Println("\n[DRY-RUN] 迁移计划已生成,未执行移动") |
| | | fmt.Println("使用 --execute 执行迁移") |
| | | return nil |
| | | } |
| | | |
| | | if !reorgExecute { |
| | | fmt.Println("\n警告: 未指定 --execute,迁移未执行") |
| | | fmt.Println("请检查 migration-plan.json,然后使用 --execute 执行") |
| | | return nil |
| | | } |
| | | |
| | | fmt.Println("\n步骤 3/3: 执行迁移...") |
| | | if err := classify.ExecuteMigration(vaultPath, plan); err != nil { |
| | | return fmt.Errorf("执行迁移失败: %w", err) |
| | | } |
| | | fmt.Printf("✓ 迁移完成(%d 个文件)\n", len(plan.Migrations)) |
| | | |
| | | return nil |
| | | } |
| | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "strings" |
| | | |
| | | "github.com/spf13/cobra" |
| | | ) |
| | |
| | | dbPath string |
| | | ) |
| | | |
| | | // buildLong 为父命令动态生成 Long,格式:# cmd - Short\n子命令详细用法 |
| | | func buildLong(cmd *cobra.Command) string { |
| | | var sb strings.Builder |
| | | sb.WriteString(fmt.Sprintf("# %s - %s\n", cmd.Name(), cmd.Short)) |
| | | for _, sub := range cmd.Commands() { |
| | | if sub.Hidden { |
| | | continue |
| | | } |
| | | if sub.Long != "" { |
| | | sb.WriteString(sub.Long) |
| | | // 确保 Long 末尾有换行 |
| | | if !strings.HasSuffix(sub.Long, "\n") { |
| | | sb.WriteString("\n") |
| | | } |
| | | } else { |
| | | sb.WriteString(fmt.Sprintf("%s # %s\n", sub.CommandPath(), sub.Short)) |
| | | } |
| | | } |
| | | return sb.String() |
| | | } |
| | | |
| | | var rootCmd = &cobra.Command{ |
| | | Use: "kb", |
| | | Short: "知识库 CLI 工具", |
| | | Long: "kb-cli: 知识库搜索与管理工具,支持知识图谱搜索", |
| | | Use: "kb-cli", |
| | | Short: "知识库搜索与管理工具", |
| | | Long: `kb-cli - 知识库搜索与管理工具 |
| | | |
| | | Global: [--vault <路径>] [--db <路径>]`, |
| | | } |
| | | |
| | | func Execute() { |
| | | // 为每个父命令动态生成 Long(包含子命令详细用法) |
| | | for _, cmd := range rootCmd.Commands() { |
| | | if cmd.HasAvailableSubCommands() && !cmd.Hidden { |
| | | cmd.Long = buildLong(cmd) |
| | | } |
| | | } |
| | | |
| | | // root Long = 头部 + 所有一级子命令的 Long |
| | | var sb strings.Builder |
| | | sb.WriteString(rootCmd.Long) |
| | | for _, cmd := range rootCmd.Commands() { |
| | | if cmd.Hidden { |
| | | continue |
| | | } |
| | | sb.WriteString("\n") |
| | | sb.WriteString(cmd.Long) |
| | | } |
| | | rootCmd.Long = sb.String() |
| | | |
| | | if err := rootCmd.Execute(); err != nil { |
| | | fmt.Fprintln(os.Stderr, err) |
| | | os.Exit(1) |
| | | // 如果是参数不足的错误,显示对应命令的 help |
| | | errMsg := err.Error() |
| | | if strings.Contains(errMsg, "arg(s)") || strings.Contains(errMsg, "received") || strings.Contains(errMsg, "required flag") { |
| | | // 找到当前命令并显示其 help |
| | | cmd, _, findErr := rootCmd.Find(os.Args[1:]) |
| | | if findErr == nil { |
| | | cmd.Help() |
| | | } |
| | | fmt.Println() // 空行 |
| | | } else { |
| | | cobra.CheckErr(err) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | rootCmd.PersistentFlags().StringVar(&vaultPath, "vault", defaultVault, "知识库根目录") |
| | | rootCmd.PersistentFlags().StringVar(&dbPath, "db", defaultDB, "索引数据库路径") |
| | | |
| | | // 自定义 help 模板:只输出 Long |
| | | helpTemplate := `{{.Long}}` |
| | | rootCmd.SetUsageTemplate(helpTemplate) |
| | | rootCmd.SetHelpTemplate(helpTemplate) |
| | | |
| | | rootCmd.SilenceUsage = true |
| | | rootCmd.SilenceErrors = true |
| | | } |
| New file |
| | |
| | | package cmd |
| | | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | "github.com/aisim/kb-cli/internal/search" |
| | | "github.com/aisim/kb-cli/internal/output" |
| | | "github.com/spf13/cobra" |
| | | ) |
| | | |
| | | var ( |
| | | expanded []string |
| | | symptom []string |
| | | topN int |
| | | jsonOut bool |
| | | withContent bool |
| | | withLinks bool |
| | | ) |
| | | |
| | | var searchCmd = &cobra.Command{ |
| | | Use: "search [keywords...]", |
| | | Short: "搜索知识库", |
| | | Long: `# search - 搜索 |
| | | kb-cli search <关键词> [--top N] [--expanded <词>] [--symptom <词>] [--with-content] [--with-links] [--json] # 搜索知识库,支持关键词、扩展词、症状词`, |
| | | Args: cobra.MinimumNArgs(1), |
| | | RunE: runSearch, |
| | | } |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(searchCmd) |
| | | searchCmd.Flags().StringSliceVar(&expanded, "expanded", nil, "扩展词(提升相关实体权重)") |
| | | searchCmd.Flags().StringSliceVar(&symptom, "symptom", nil, "症状词(针对具体症状)") |
| | | searchCmd.Flags().IntVar(&topN, "top", 10, "返回前 N 条结果") |
| | | searchCmd.Flags().BoolVar(&jsonOut, "json", false, "JSON 格式输出") |
| | | searchCmd.Flags().BoolVar(&withContent, "with-content", false, "返回完整文件内容") |
| | | searchCmd.Flags().BoolVar(&withLinks, "with-links", false, "显示关联文档链接") |
| | | |
| | | // 参数不足时显示 help |
| | | searchCmd.SetUsageTemplate(searchCmd.Long) |
| | | } |
| | | |
| | | func runSearch(cmd *cobra.Command, args []string) error { |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 检查是否需要重建索引 |
| | | needsRebuild, commit, err := index.NeedsRebuild(store, vaultPath) |
| | | if err != nil { |
| | | return fmt.Errorf("检查索引状态失败: %w", err) |
| | | } |
| | | |
| | | if needsRebuild { |
| | | fmt.Fprintln(os.Stderr, "索引过期,正在重建...") |
| | | if err := rebuildIndex(store, commit); err != nil { |
| | | return fmt.Errorf("重建索引失败: %w", err) |
| | | } |
| | | } |
| | | |
| | | // 合并所有关键词(支持空格分隔和引号分隔) |
| | | var allKeywords []string |
| | | for _, arg := range args { |
| | | // 如果参数包含空格,按空格分割 |
| | | parts := strings.Fields(arg) |
| | | allKeywords = append(allKeywords, parts...) |
| | | } |
| | | |
| | | if len(allKeywords) == 0 { |
| | | return fmt.Errorf("请提供至少一个关键词") |
| | | } |
| | | |
| | | // 执行搜索 |
| | | opts := search.SearchOptions{ |
| | | Expanded: expanded, |
| | | Symptom: symptom, |
| | | TopN: topN, |
| | | WithContent: withContent, |
| | | WithLinks: withLinks, |
| | | } |
| | | |
| | | results, err := search.Search(store, allKeywords, opts) |
| | | if err != nil { |
| | | return fmt.Errorf("搜索失败: %w", err) |
| | | } |
| | | |
| | | // 输出结果 |
| | | if jsonOut { |
| | | jsonStr, err := output.FormatJSON(results) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | fmt.Println(jsonStr) |
| | | } else { |
| | | fmt.Print(output.FormatTable(results)) |
| | | } |
| | | |
| | | return nil |
| | | } |
| New file |
| | |
| | | # kb-cli 增强功能实现计划 |
| | | |
| | | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| | | |
| | | **Goal:** 为 kb-cli 添加 `--with-content` 和 `--with-links` 功能,并调整 kb-search 技能优先使用 kb-cli |
| | | |
| | | **Architecture:** 在现有 SearchResult 结构体上添加可选字段 Content 和 Links,通过 SearchOptions 控制是否填充这些字段。修改输出格式化器支持新字段的展示。 |
| | | |
| | | **Tech Stack:** Go 1.22, SQLite FTS5, Cobra CLI |
| | | |
| | | ## Global Constraints |
| | | |
| | | - 所有新功能必须有单元测试 |
| | | - JSON 输出使用 `omitempty` 标签 |
| | | - 保持向后兼容(不破坏现有命令) |
| | | - 性能目标:< 100ms(包含内容和链接获取) |
| | | |
| | | --- |
| | | |
| | | ## Task 1: 添加 GetNodeLinks 数据库方法 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/index/sqlite.go:120-140` |
| | | - Test: `internal/index/sqlite_test.go` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: `Store.db` (SQLite 连接) |
| | | - Produces: `GetNodeLinks(nodeID int64) ([]string, error)` 方法 |
| | | |
| | | - [ ] **Step 1: 编写失败测试** |
| | | |
| | | 在 `internal/index/sqlite_test.go` 添加: |
| | | |
| | | ```go |
| | | func TestGetNodeLinks(t *testing.T) { |
| | | store := setupTestStore(t) |
| | | defer store.Close() |
| | | |
| | | // 插入测试节点 |
| | | node1 := &graph.Node{ |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Content: "# 测试\n\n内容", |
| | | } |
| | | node2 := &graph.Node{ |
| | | Path: "FAQ/002-相关.md", |
| | | Title: "相关文档", |
| | | Section: "FAQ", |
| | | Content: "# 相关\n\n内容", |
| | | } |
| | | |
| | | id1, err := store.InsertNode(node1) |
| | | require.NoError(t, err) |
| | | |
| | | id2, err := store.InsertNode(node2) |
| | | require.NoError(t, err) |
| | | |
| | | // 插入 wikilink 边 |
| | | edge := &graph.Edge{ |
| | | FromNode: id1, |
| | | ToNode: id2, |
| | | Relation: "wikilink", |
| | | Label: "相关文档", |
| | | } |
| | | err = store.InsertEdge(edge) |
| | | require.NoError(t, err) |
| | | |
| | | // 测试获取链接 |
| | | links, err := store.GetNodeLinks(id1) |
| | | require.NoError(t, err) |
| | | assert.Len(t, links, 1) |
| | | assert.Equal(t, "FAQ/002-相关.md", links[0]) |
| | | |
| | | // 测试无链接的节点 |
| | | links, err = store.GetNodeLinks(id2) |
| | | require.NoError(t, err) |
| | | assert.Len(t, links, 0) |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/index -run TestGetNodeLinks -v |
| | | ``` |
| | | |
| | | Expected: FAIL - "store.GetNodeLinks undefined" |
| | | |
| | | - [ ] **Step 3: 实现 GetNodeLinks 方法** |
| | | |
| | | 在 `internal/index/sqlite.go` 的 `GetNodeContent` 方法后添加: |
| | | |
| | | ```go |
| | | // GetNodeLinks 获取节点的关联链接(wikilink 目标) |
| | | func (s *Store) GetNodeLinks(nodeID int64) ([]string, error) { |
| | | query := ` |
| | | SELECT n.path |
| | | FROM edges e |
| | | JOIN nodes n ON n.id = e.to_node |
| | | WHERE e.from_node = ? AND e.relation = 'wikilink' |
| | | ` |
| | | rows, err := s.db.Query(query, nodeID) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询链接失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var links []string |
| | | for rows.Next() { |
| | | var path string |
| | | if err := rows.Scan(&path); err != nil { |
| | | return nil, fmt.Errorf("扫描链接失败: %w", err) |
| | | } |
| | | links = append(links, path) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历链接失败: %w", err) |
| | | } |
| | | |
| | | return links, nil |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 运行测试确认通过** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/index -run TestGetNodeLinks -v |
| | | ``` |
| | | |
| | | Expected: PASS |
| | | |
| | | - [ ] **Step 5: 提交** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add internal/index/sqlite.go internal/index/sqlite_test.go |
| | | git commit -m "feat: add GetNodeLinks method for wikilink retrieval" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 2: 扩展 SearchResult 结构体 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/search/engine.go:15-25` |
| | | - Test: `internal/search/engine_test.go` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: 无 |
| | | - Produces: 扩展的 `SearchResult` 结构体(Content, Links 字段) |
| | | |
| | | - [ ] **Step 1: 编写失败测试** |
| | | |
| | | 在 `internal/search/engine_test.go` 添加: |
| | | |
| | | ```go |
| | | func TestSearchResultJSON_OmitEmpty(t *testing.T) { |
| | | // 测试无内容和链接时,JSON 不包含这些字段 |
| | | result := SearchResult{ |
| | | ID: 1, |
| | | Path: "test.md", |
| | | Title: "测试", |
| | | Section: "FAQ", |
| | | Score: 10, |
| | | } |
| | | |
| | | jsonBytes, err := json.Marshal(result) |
| | | require.NoError(t, err) |
| | | |
| | | jsonStr := string(jsonBytes) |
| | | assert.NotContains(t, jsonStr, "content") |
| | | assert.NotContains(t, jsonStr, "links") |
| | | |
| | | // 测试有内容时,JSON 包含 content 字段 |
| | | result.Content = "# 测试内容" |
| | | jsonBytes, err = json.Marshal(result) |
| | | require.NoError(t, err) |
| | | |
| | | jsonStr = string(jsonBytes) |
| | | assert.Contains(t, jsonStr, `"content":"# 测试内容"`) |
| | | |
| | | // 测试有链接时,JSON 包含 links 字段 |
| | | result.Links = []string{"related.md"} |
| | | jsonBytes, err = json.Marshal(result) |
| | | require.NoError(t, err) |
| | | |
| | | jsonStr = string(jsonBytes) |
| | | assert.Contains(t, jsonStr, `"links":["related.md"]`) |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/search -run TestSearchResultJSON_OmitEmpty -v |
| | | ``` |
| | | |
| | | Expected: FAIL - "Content undefined" 或测试失败(因为字段不存在) |
| | | |
| | | - [ ] **Step 3: 扩展 SearchResult 结构体** |
| | | |
| | | 修改 `internal/search/engine.go` 中的 `SearchResult` 结构体: |
| | | |
| | | ```go |
| | | // SearchResult 搜索结果 |
| | | type SearchResult struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Score int `json:"score"` |
| | | |
| | | // 可选字段(omitempty) |
| | | Content string `json:"content,omitempty"` // --with-content 时填充 |
| | | Links []string `json:"links,omitempty"` // --with-links 时填充 |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 运行测试确认通过** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/search -run TestSearchResultJSON_OmitEmpty -v |
| | | ``` |
| | | |
| | | Expected: PASS |
| | | |
| | | - [ ] **Step 5: 提交** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add internal/search/engine.go internal/search/engine_test.go |
| | | git commit -m "feat: extend SearchResult with Content and Links fields" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 3: 扩展 SearchOptions 并修改 Search 函数 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/search/engine.go:30-40, 80-120` |
| | | - Test: `internal/search/engine_test.go` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: `Store.GetNodeContent`, `Store.GetNodeLinks` (Task 1) |
| | | - Produces: 扩展的 `SearchOptions` 和增强的 `Search` 函数 |
| | | |
| | | - [ ] **Step 1: 编写失败测试** |
| | | |
| | | 在 `internal/search/engine_test.go` 添加: |
| | | |
| | | ```go |
| | | func TestSearch_WithContent(t *testing.T) { |
| | | store := setupTestStore(t) |
| | | defer store.Close() |
| | | |
| | | // 插入测试节点 |
| | | node := &graph.Node{ |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Content: "# 测试\n\n这是测试内容", |
| | | } |
| | | id, err := store.InsertNode(node) |
| | | require.NoError(t, err) |
| | | |
| | | err = store.CreateFTS() |
| | | require.NoError(t, err) |
| | | err = store.PopulateFTS() |
| | | require.NoError(t, err) |
| | | |
| | | // 测试带内容的搜索 |
| | | opts := SearchOptions{ |
| | | TopN: 5, |
| | | WithContent: true, |
| | | } |
| | | results, err := Search(store, []string{"测试"}, opts) |
| | | require.NoError(t, err) |
| | | require.Len(t, results, 1) |
| | | assert.Equal(t, id, results[0].ID) |
| | | assert.Equal(t, "# 测试\n\n这是测试内容", results[0].Content) |
| | | |
| | | // 测试不带内容的搜索 |
| | | opts.WithContent = false |
| | | results, err = Search(store, []string{"测试"}, opts) |
| | | require.NoError(t, err) |
| | | require.Len(t, results, 1) |
| | | assert.Empty(t, results[0].Content) |
| | | } |
| | | |
| | | func TestSearch_WithLinks(t *testing.T) { |
| | | store := setupTestStore(t) |
| | | defer store.Close() |
| | | |
| | | // 插入两个节点 |
| | | node1 := &graph.Node{ |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Content: "# 测试", |
| | | } |
| | | node2 := &graph.Node{ |
| | | Path: "FAQ/002-相关.md", |
| | | Title: "相关文档", |
| | | Section: "FAQ", |
| | | Content: "# 相关", |
| | | } |
| | | |
| | | id1, err := store.InsertNode(node1) |
| | | require.NoError(t, err) |
| | | id2, err := store.InsertNode(node2) |
| | | require.NoError(t, err) |
| | | |
| | | // 插入 wikilink 边 |
| | | edge := &graph.Edge{ |
| | | FromNode: id1, |
| | | ToNode: id2, |
| | | Relation: "wikilink", |
| | | Label: "相关文档", |
| | | } |
| | | err = store.InsertEdge(edge) |
| | | require.NoError(t, err) |
| | | |
| | | err = store.CreateFTS() |
| | | require.NoError(t, err) |
| | | err = store.PopulateFTS() |
| | | require.NoError(t, err) |
| | | |
| | | // 测试带链接的搜索 |
| | | opts := SearchOptions{ |
| | | TopN: 5, |
| | | WithLinks: true, |
| | | } |
| | | results, err := Search(store, []string{"测试"}, opts) |
| | | require.NoError(t, err) |
| | | require.Len(t, results, 1) |
| | | assert.Equal(t, id1, results[0].ID) |
| | | assert.Len(t, results[0].Links, 1) |
| | | assert.Equal(t, "FAQ/002-相关.md", results[0].Links[0]) |
| | | |
| | | // 测试不带链接的搜索 |
| | | opts.WithLinks = false |
| | | results, err = Search(store, []string{"测试"}, opts) |
| | | require.NoError(t, err) |
| | | require.Len(t, results, 1) |
| | | assert.Empty(t, results[0].Links) |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/search -run "TestSearch_WithContent|TestSearch_WithLinks" -v |
| | | ``` |
| | | |
| | | Expected: FAIL - "WithContent undefined" 或测试失败 |
| | | |
| | | - [ ] **Step 3: 扩展 SearchOptions 并修改 Search 函数** |
| | | |
| | | 修改 `internal/search/engine.go`: |
| | | |
| | | ```go |
| | | // SearchOptions 搜索选项 |
| | | type SearchOptions struct { |
| | | Expanded []string |
| | | Symptom []string |
| | | TopN int |
| | | WithContent bool // 新增:是否返回内容 |
| | | WithLinks bool // 新增:是否返回链接 |
| | | } |
| | | |
| | | // Search 执行搜索 |
| | | func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) { |
| | | // 合并所有关键词 |
| | | allKeywords := append(keywords, opts.Expanded...) |
| | | allKeywords = append(allKeywords, opts.Symptom...) |
| | | |
| | | // FTS5 搜索 |
| | | ftsResults, err := store.FTSSearch(allKeywords, 100) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | |
| | | // 评分 |
| | | scoreMap := make(map[int64]int) |
| | | for _, r := range ftsResults { |
| | | score := 0 |
| | | for _, kw := range keywords { |
| | | score += scoreResult(r, kw, ScoreNormal) |
| | | } |
| | | for _, kw := range opts.Expanded { |
| | | score += scoreResult(r, kw, ScoreExpanded) |
| | | } |
| | | for _, kw := range opts.Symptom { |
| | | score += scoreResult(r, kw, ScoreSymptom) |
| | | } |
| | | scoreMap[r.ID] = score |
| | | } |
| | | |
| | | // 转换为结果列表 |
| | | var results []SearchResult |
| | | for _, r := range ftsResults { |
| | | results = append(results, SearchResult{ |
| | | ID: r.ID, |
| | | Path: r.Path, |
| | | Title: r.Title, |
| | | Section: r.Section, |
| | | Score: scoreMap[r.ID], |
| | | }) |
| | | } |
| | | |
| | | // 按分数排序 |
| | | sort.Slice(results, func(i, j int) bool { |
| | | return results[i].Score > results[j].Score |
| | | }) |
| | | |
| | | // 限制返回数量 |
| | | if opts.TopN > 0 && len(results) > opts.TopN { |
| | | results = results[:opts.TopN] |
| | | } |
| | | |
| | | // 增强结果(新增) |
| | | for i := range results { |
| | | if opts.WithContent { |
| | | content, _, _, err := store.GetNodeContent(results[i].ID) |
| | | if err == nil { |
| | | results[i].Content = content |
| | | } |
| | | } |
| | | if opts.WithLinks { |
| | | links, err := store.GetNodeLinks(results[i].ID) |
| | | if err == nil { |
| | | results[i].Links = links |
| | | } |
| | | } |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 运行测试确认通过** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/search -run "TestSearch_WithContent|TestSearch_WithLinks" -v |
| | | ``` |
| | | |
| | | Expected: PASS |
| | | |
| | | - [ ] **Step 5: 运行所有测试确认无回归** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./... -v |
| | | ``` |
| | | |
| | | Expected: 所有测试通过 |
| | | |
| | | - [ ] **Step 6: 提交** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add internal/search/engine.go internal/search/engine_test.go |
| | | git commit -m "feat: add WithContent and WithLinks options to Search" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 4: 添加 CLI flags 并传递给 SearchOptions |
| | | |
| | | **Files:** |
| | | - Modify: `cmd/search.go:20-50` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: `SearchOptions.WithContent`, `SearchOptions.WithLinks` (Task 3) |
| | | - Produces: 新增 `--with-content` 和 `--with-links` flags |
| | | |
| | | - [ ] **Step 1: 添加 flag 变量** |
| | | |
| | | 在 `cmd/search.go` 的变量声明区域添加: |
| | | |
| | | ```go |
| | | var ( |
| | | withContent bool |
| | | withLinks bool |
| | | ) |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 注册 flags** |
| | | |
| | | 在 `init()` 函数中添加: |
| | | |
| | | ```go |
| | | func init() { |
| | | searchCmd.Flags().StringSliceVar(&expanded, "expanded", nil, "扩展词(提升相关实体权重)") |
| | | searchCmd.Flags().StringSliceVar(&symptom, "symptom", nil, "症状词(针对具体症状)") |
| | | searchCmd.Flags().IntVar(&topN, "top", 10, "返回前 N 条结果") |
| | | searchCmd.Flags().BoolVar(&jsonOut, "json", false, "JSON 格式输出") |
| | | |
| | | // 新增 flags |
| | | searchCmd.Flags().BoolVar(&withContent, "with-content", false, "返回完整文件内容") |
| | | searchCmd.Flags().BoolVar(&withLinks, "with-links", false, "显示关联文档链接") |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 3: 传递 flags 到 SearchOptions** |
| | | |
| | | 修改 `runSearch` 函数: |
| | | |
| | | ```go |
| | | func runSearch(cmd *cobra.Command, args []string) error { |
| | | // ... 现有代码 ... |
| | | |
| | | opts := search.SearchOptions{ |
| | | Expanded: expanded, |
| | | Symptom: symptom, |
| | | TopN: topN, |
| | | WithContent: withContent, |
| | | WithLinks: withLinks, |
| | | } |
| | | |
| | | results, err := search.Search(store, args, opts) |
| | | // ... 现有代码 ... |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 构建并测试 help 输出** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go build -o bin/kb . |
| | | ./bin/kb search --help |
| | | ``` |
| | | |
| | | Expected: 显示 `--with-content` 和 `--with-links` flags |
| | | |
| | | - [ ] **Step 5: 提交** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add cmd/search.go |
| | | git commit -m "feat: add --with-content and --with-links CLI flags" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 5: 修改输出格式化器支持新字段 |
| | | |
| | | **Files:** |
| | | - Modify: `internal/output/formatter.go` |
| | | - Test: `internal/output/formatter_test.go` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: `SearchResult.Content`, `SearchResult.Links` (Task 2) |
| | | - Produces: 增强的 `FormatTable` 和 `FormatJSON` 方法 |
| | | |
| | | - [ ] **Step 1: 编写失败测试** |
| | | |
| | | 在 `internal/output/formatter_test.go` 添加: |
| | | |
| | | ```go |
| | | func TestFormatTable_WithContent(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | { |
| | | ID: 1, |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Score: 10, |
| | | Content: "# 测试\n\n这是内容", |
| | | }, |
| | | } |
| | | |
| | | output := FormatTable(results) |
| | | assert.Contains(t, output, "FAQ/001-测试.md") |
| | | assert.Contains(t, output, "--- 内容 ---") |
| | | assert.Contains(t, output, "# 测试") |
| | | } |
| | | |
| | | func TestFormatTable_WithLinks(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | { |
| | | ID: 1, |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Score: 10, |
| | | Links: []string{"FAQ/002-相关.md"}, |
| | | }, |
| | | } |
| | | |
| | | output := FormatTable(results) |
| | | assert.Contains(t, output, "--- 关联文档 ---") |
| | | assert.Contains(t, output, "FAQ/002-相关.md") |
| | | } |
| | | |
| | | func TestFormatTable_WithBoth(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | { |
| | | ID: 1, |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Score: 10, |
| | | Content: "# 测试", |
| | | Links: []string{"FAQ/002-相关.md"}, |
| | | }, |
| | | } |
| | | |
| | | output := FormatTable(results) |
| | | assert.Contains(t, output, "--- 内容 ---") |
| | | assert.Contains(t, output, "--- 关联文档 ---") |
| | | } |
| | | |
| | | func TestFormatJSON_WithContentAndLinks(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | { |
| | | ID: 1, |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Score: 10, |
| | | Content: "# 测试", |
| | | Links: []string{"FAQ/002-相关.md"}, |
| | | }, |
| | | } |
| | | |
| | | output, err := FormatJSON(results) |
| | | require.NoError(t, err) |
| | | assert.Contains(t, output, `"content":"# 测试"`) |
| | | assert.Contains(t, output, `"links":["FAQ/002-相关.md"]`) |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行测试确认失败** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/output -run "TestFormatTable_With|TestFormatJSON_With" -v |
| | | ``` |
| | | |
| | | Expected: FAIL - 测试失败(输出不包含新字段) |
| | | |
| | | - [ ] **Step 3: 修改 FormatTable 方法** |
| | | |
| | | 修改 `internal/output/formatter.go` 中的 `FormatTable` 函数: |
| | | |
| | | ```go |
| | | // FormatTable 格式化表格输出 |
| | | func FormatTable(results []search.SearchResult) string { |
| | | if len(results) == 0 { |
| | | return "未找到匹配结果\n" |
| | | } |
| | | |
| | | var buf bytes.Buffer |
| | | |
| | | // 表头 |
| | | buf.WriteString(fmt.Sprintf("%-50s %-30s %-10s %s\n", "路径", "标题", "板块", "得分")) |
| | | buf.WriteString(strings.Repeat("-", 100) + "\n") |
| | | |
| | | // 每行结果 |
| | | for _, r := range results { |
| | | title := r.Title |
| | | if len(title) > 28 { |
| | | title = title[:28] + ".." |
| | | } |
| | | buf.WriteString(fmt.Sprintf("%-50s %-30s %-10s %d\n", r.Path, title, r.Section, r.Score)) |
| | | |
| | | // 输出内容(如果有) |
| | | if r.Content != "" { |
| | | buf.WriteString("\n--- 内容 ---\n") |
| | | buf.WriteString(r.Content) |
| | | buf.WriteString("\n") |
| | | } |
| | | |
| | | // 输出关联文档(如果有) |
| | | if len(r.Links) > 0 { |
| | | buf.WriteString("\n--- 关联文档 ---\n") |
| | | for _, link := range r.Links { |
| | | buf.WriteString(fmt.Sprintf("- %s\n", link)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | buf.WriteString(fmt.Sprintf("\n共 %d 条结果\n", len(results))) |
| | | return buf.String() |
| | | } |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 运行测试确认通过** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./internal/output -run "TestFormatTable_With|TestFormatJSON_With" -v |
| | | ``` |
| | | |
| | | Expected: PASS |
| | | |
| | | - [ ] **Step 5: 运行所有测试确认无回归** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./... -v |
| | | ``` |
| | | |
| | | Expected: 所有测试通过 |
| | | |
| | | - [ ] **Step 6: 提交** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add internal/output/formatter.go internal/output/formatter_test.go |
| | | git commit -m "feat: enhance output formatter with content and links support" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 6: 集成测试与性能验证 |
| | | |
| | | **Files:** |
| | | - 无新文件(使用现有测试数据) |
| | | |
| | | **Interfaces:** |
| | | - Consumes: 所有之前的任务 |
| | | - Produces: 验证完整功能链 |
| | | |
| | | - [ ] **Step 1: 构建最新版本** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go build -o bin/kb . |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 测试基础搜索(无增强)** |
| | | |
| | | ```bash |
| | | ./bin/kb search 充装 --top 3 |
| | | ``` |
| | | |
| | | Expected: 显示 3 条结果,不包含内容和链接 |
| | | |
| | | - [ ] **Step 3: 测试带内容搜索** |
| | | |
| | | ```bash |
| | | ./bin/kb search 充装 --with-content --top 3 |
| | | ``` |
| | | |
| | | Expected: 显示 3 条结果,每条后跟 `--- 内容 ---` 和文件内容 |
| | | |
| | | - [ ] **Step 4: 测试带链接搜索** |
| | | |
| | | ```bash |
| | | ./bin/kb search 充装 --with-links --top 3 |
| | | ``` |
| | | |
| | | Expected: 显示 3 条结果,有链接的显示 `--- 关联文档 ---` |
| | | |
| | | - [ ] **Step 5: 测试两者都有** |
| | | |
| | | ```bash |
| | | ./bin/kb search 充装 --with-content --with-links --top 3 |
| | | ``` |
| | | |
| | | Expected: 显示 3 条结果,同时包含内容和链接 |
| | | |
| | | - [ ] **Step 6: 测试 JSON 输出** |
| | | |
| | | ```bash |
| | | ./bin/kb search 充装 --with-content --with-links --json --top 3 |
| | | ``` |
| | | |
| | | Expected: JSON 格式输出,包含 `content` 和 `links` 字段 |
| | | |
| | | - [ ] **Step 7: 性能测试** |
| | | |
| | | ```bash |
| | | time ./bin/kb search 充装 --with-content --with-links --top 10 |
| | | ``` |
| | | |
| | | Expected: 总耗时 < 100ms |
| | | |
| | | - [ ] **Step 8: 提交(如有修复)** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add -A |
| | | git commit -m "test: integration tests for enhanced search" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 7: 更新 kb-search 技能文档 |
| | | |
| | | **Files:** |
| | | - Modify: `~/.hermes/skills/kb-knowledge/kb-search/SKILL.md` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: kb-cli 新功能 |
| | | - Produces: 更新的技能文档 |
| | | |
| | | - [ ] **Step 1: 备份现有文档** |
| | | |
| | | ```bash |
| | | cp ~/.hermes/skills/kb-knowledge/kb-search/SKILL.md ~/.hermes/skills/kb-knowledge/kb-search/SKILL.md.bak |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 修改核心工具部分** |
| | | |
| | | 将 `## 核心工具` 部分从: |
| | | |
| | | ```markdown |
| | | ## 核心工具 |
| | | |
| | | **kb-search.py**:知识库搜索 |
| | | ``` |
| | | |
| | | 改为: |
| | | |
| | | ```markdown |
| | | ## 核心工具 |
| | | |
| | | **kb-cli**:知识库搜索(优先使用) |
| | | |
| | | **完整路径**:`~/go/bin/kb` |
| | | |
| | | **基本用法**: |
| | | ```bash |
| | | # 基础搜索 |
| | | kb search "关键词" --top 5 |
| | | |
| | | # 带内容 |
| | | kb search "关键词" --with-content --top 3 |
| | | |
| | | # 带链接 |
| | | kb search "关键词" --with-links --top 3 |
| | | |
| | | # 带扩展词和症状词 |
| | | kb search "充装" --expanded "重量 规格" --symptom "报错" --with-content --top 3 |
| | | |
| | | # JSON 输出 |
| | | kb search "关键词" --json --top 5 |
| | | ``` |
| | | |
| | | **kb-search.py**:知识库搜索(降级方案,仅当 kb-cli 不可用时) |
| | | ``` |
| | | |
| | | - [ ] **Step 3: 修改执行流程部分** |
| | | |
| | | 将 `## 执行流程` 部分的命令示例从 `python3 kb-search.py` 改为 `kb`: |
| | | |
| | | ```bash |
| | | # 方式1: 基础搜索 |
| | | kb search "用户问题关键词" --top 3 |
| | | |
| | | # 方式2: 带内容(推荐) |
| | | kb search "用户问题关键词" --with-content --top 3 |
| | | |
| | | # 方式3: 带内容和链接 |
| | | kb search "用户问题关键词" --with-content --with-links --top 3 |
| | | |
| | | # 方式4: 带扩展词和症状词 |
| | | kb search "关键词" --expanded "扩展词" --symptom "症状词" --with-content --top 3 |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 添加降级方案章节** |
| | | |
| | | 在文档末尾添加: |
| | | |
| | | ```markdown |
| | | ## 降级方案 |
| | | |
| | | **仅当 kb-cli 不可用时**,使用 kb-search.py: |
| | | |
| | | ```bash |
| | | export KB_VAULT=/home/aisim-p/aisim/note/001/笔记001 |
| | | python3 ~/.hermes/skills/kb-knowledge/kb-search/scripts/kb-search.py search "关键词" --with-content --top 3 |
| | | ``` |
| | | |
| | | **注意**:kb-search.py 当前有索引问题,可能返回空结果。优先使用 kb-cli。 |
| | | ``` |
| | | |
| | | - [ ] **Step 5: 验证文档格式** |
| | | |
| | | ```bash |
| | | cat ~/.hermes/skills/kb-knowledge/kb-search/SKILL.md | head -50 |
| | | ``` |
| | | |
| | | Expected: 显示更新后的文档,核心工具为 kb-cli |
| | | |
| | | - [ ] **Step 6: 提交到知识库** |
| | | |
| | | ```bash |
| | | cd ~/aisim/note/001/笔记001 |
| | | git add . |
| | | git commit -m "docs: update kb-search skill to prioritize kb-cli" |
| | | git push |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## Task 8: 最终验证与文档更新 |
| | | |
| | | **Files:** |
| | | - Modify: `~/workspace/kb-cli/README.md` |
| | | |
| | | **Interfaces:** |
| | | - Consumes: 所有功能 |
| | | - Produces: 完整的文档和验证 |
| | | |
| | | - [ ] **Step 1: 更新 README** |
| | | |
| | | 在 `~/workspace/kb-cli/README.md` 的 `## 使用方法` 部分添加新功能示例: |
| | | |
| | | ```markdown |
| | | ### 高级搜索 |
| | | |
| | | ```bash |
| | | # 带文件内容 |
| | | kb search 充装规格 --with-content --top 3 |
| | | |
| | | # 带关联文档 |
| | | kb search 充装规格 --with-links --top 3 |
| | | |
| | | # 带内容和关联 |
| | | kb search 充装规格 --with-content --with-links --top 3 |
| | | |
| | | # 带扩展词和症状词 |
| | | kb search 充装 --expanded "重量 规格" --symptom "报错" --with-content --top 3 |
| | | ``` |
| | | ``` |
| | | |
| | | - [ ] **Step 2: 运行完整测试套件** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go test ./... -v |
| | | ``` |
| | | |
| | | Expected: 所有测试通过 |
| | | |
| | | - [ ] **Step 3: 构建并安装** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | go build -o bin/kb . |
| | | cp bin/kb ~/go/bin/ |
| | | ``` |
| | | |
| | | - [ ] **Step 4: 最终功能验证** |
| | | |
| | | ```bash |
| | | # 测试所有功能 |
| | | kb search 充装 --top 3 |
| | | kb search 充装 --with-content --top 3 |
| | | kb search 充装 --with-links --top 3 |
| | | kb search 充装 --with-content --with-links --top 3 |
| | | kb search 充装 --with-content --json --top 3 |
| | | ``` |
| | | |
| | | Expected: 所有命令正常工作 |
| | | |
| | | - [ ] **Step 5: 提交所有更改** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add README.md |
| | | git commit -m "docs: update README with new features" |
| | | git push |
| | | ``` |
| | | |
| | | - [ ] **Step 6: 创建发布说明** |
| | | |
| | | ```bash |
| | | cat > RELEASE_NOTES.md << 'EOF' |
| | | # kb-cli v1.1.0 发布说明 |
| | | |
| | | ## 新功能 |
| | | |
| | | - `--with-content`: 搜索时返回完整文件内容 |
| | | - `--with-links`: 搜索时显示关联文档链接 |
| | | |
| | | ## 使用示例 |
| | | |
| | | ```bash |
| | | # 带内容搜索 |
| | | kb search 充装规格 --with-content --top 3 |
| | | |
| | | # 带关联文档 |
| | | kb search 充装规格 --with-links --top 3 |
| | | |
| | | # 两者都有 |
| | | kb search 充装规格 --with-content --with-links --top 3 |
| | | ``` |
| | | |
| | | ## 性能 |
| | | |
| | | - 搜索 + 内容 + 链接:< 100ms |
| | | - 向后兼容:现有命令不受影响 |
| | | |
| | | ## 技能调整 |
| | | |
| | | - kb-search 技能现在优先使用 kb-cli |
| | | - kb-search.py 降级为备选方案 |
| | | EOF |
| | | ``` |
| | | |
| | | - [ ] **Step 7: 提交发布说明** |
| | | |
| | | ```bash |
| | | cd ~/workspace/kb-cli |
| | | git add RELEASE_NOTES.md |
| | | git commit -m "docs: add release notes for v1.1.0" |
| | | git push |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 完成标准 |
| | | |
| | | 所有任务完成后,必须满足: |
| | | |
| | | 1. ✅ 所有单元测试通过 |
| | | 2. ✅ 集成测试通过 |
| | | 3. ✅ 性能 < 100ms |
| | | 4. ✅ kb-search 技能文档已更新 |
| | | 5. ✅ README 已更新 |
| | | 6. ✅ 发布说明已创建 |
| | | 7. ✅ 代码已推送到远程仓库 |
| | | |
| | | --- |
| | | |
| | | ## 执行选项 |
| | | |
| | | **Plan complete and saved to `docs/superpowers/plans/2026-07-26-kb-cli-enhancement.md`. Two execution options:** |
| | | |
| | | **1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration |
| | | |
| | | **2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints |
| | | |
| | | **Which approach?** |
| New file |
| | |
| | | # kb-cli 草稿录入集成设计文档 |
| | | |
| | | **日期**: 2026-07-26 |
| | | **状态**: 设计中 |
| | | **版本**: v0.4.0 |
| | | |
| | | ## 背景 |
| | | |
| | | 当前知识库草稿录入依赖 Python 脚本 `draft-intake.py`,需要 Hermes Agent 手动调用。本次设计将草稿录入功能集成到 kb-cli,实现: |
| | | 1. AI 生成草稿标题和正文 |
| | | 2. kb-cli 自动提取 tags(调用本地 LLM) |
| | | 3. kb-cli 自动搜索相关文档并生成合并指示 |
| | | 4. 通过知识图谱创建 wikilinks |
| | | |
| | | ## 目标 |
| | | |
| | | - **简化流程**: 一条命令完成草稿创建 + tags 提取 + 合并指示生成 |
| | | - **智能标签**: 使用本地 LLM 提取问题匹配型 tags + 扩展词 |
| | | - **自动关联**: 基于 tags 搜索知识库,生成合并指示和 wikilinks |
| | | - **批量重建**: 支持为现有文档批量重建 tags |
| | | |
| | | ## 核心流程 |
| | | |
| | | ``` |
| | | 草稿内容 → LLM 生成 tags → kb-cli search(tags) → 搜索到 top3 文档 → LLM 生成合并指示 |
| | | ``` |
| | | |
| | | ### 详细步骤 |
| | | |
| | | 1. **LLM 提取 tags + related_docs** |
| | | - 输入:草稿内容 |
| | | - 输出:5-10 个 tags + 0-5 个相关文档标题 |
| | | - tags 类型:核心问题标签 + 扩展词 + 平台/设备标签 |
| | | |
| | | 2. **kb-cli search(tags)** |
| | | - 使用 tags 作为关键词搜索知识库 |
| | | - 返回 top3 相关文档 |
| | | |
| | | 3. **LLM 生成合并指示** |
| | | - 输入:草稿内容 + top3 文档 |
| | | - 输出:merge.md(包含合并建议 + 理由) |
| | | |
| | | 4. **创建 wikilinks** |
| | | - 通过 graph 匹配 related_docs 对应的节点 |
| | | - 创建 wikilink edges |
| | | |
| | | 5. **写入文件** |
| | | - draft.md:包含 frontmatter(tags)+ 正文 |
| | | - merge.md:合并指示 |
| | | |
| | | ## 命令设计 |
| | | |
| | | ### 1. draft create |
| | | |
| | | 创建草稿,自动生成 tags 和合并指示。 |
| | | |
| | | ```bash |
| | | kb-cli draft create --type 售后 --title "标题" --content-file /path/to/content.md |
| | | ``` |
| | | |
| | | **参数**: |
| | | - `--type`: 草稿类型(售后/产品/运营/行业/TAPD) |
| | | - `--title`: 草稿标题 |
| | | - `--content-file`: 草稿正文文件路径 |
| | | - `--source`: 来源标识(可选) |
| | | - `--force`: 强制创建,跳过重复检查 |
| | | |
| | | **流程**: |
| | | 1. 读取 content-file 内容 |
| | | 2. 调用 LLM 提取 tags + related_docs |
| | | 3. 使用 tags 搜索知识库,获取 top3 文档 |
| | | 4. 调用 LLM 生成合并指示(基于 top3 文档) |
| | | 5. 检查待审阅区是否已存在相同标题的草稿 |
| | | 6. 创建目录结构:`待审阅/{类型}/NNN-YYYYMMDD-标题/` |
| | | 7. 写入 draft.md(包含 frontmatter + 正文) |
| | | 8. 写入 merge.md(合并指示) |
| | | 9. 通过 graph 匹配 related_docs,创建 wikilink edges |
| | | |
| | | ### 2. tags rebuild |
| | | |
| | | 批量重建现有文档的 tags。 |
| | | |
| | | ```bash |
| | | kb-cli tags rebuild [--vault=<路径>] [--dry-run] [--limit=100] |
| | | ``` |
| | | |
| | | **参数**: |
| | | - `--vault`: 知识库路径(默认 ~/aisim/note/001/笔记001) |
| | | - `--dry-run`: 仅预览,不实际写入 |
| | | - `--limit`: 限制处理文档数量(默认 100) |
| | | |
| | | **流程**: |
| | | 1. 扫描知识库所有 .md 文件 |
| | | 2. 对每个文件: |
| | | - 读取内容 |
| | | - 调用 LLM 提取 tags |
| | | - 更新 frontmatter 中的 tags 字段 |
| | | - 重建索引(更新 nodes 表的 tags 字段) |
| | | 3. 输出统计:处理文件数、更新 tags 数 |
| | | |
| | | ## LLM 配置 |
| | | |
| | | 复用 `generate-merge-hint.py` 的配置: |
| | | - **API**: `http://192.168.3.246:1127/v1/chat/completions` |
| | | - **Model**: `qwen3.6-35b-a3b` |
| | | - **参数**: temperature=0.3, max_tokens=2000, enable_thinking=False |
| | | |
| | | ### Prompt 设计 |
| | | |
| | | #### 提取 tags + related_docs |
| | | |
| | | ``` |
| | | 分析以下知识库草稿,提取: |
| | | 1. tags(5-10个): |
| | | - 核心问题标签(如"充不进气"、"档案下载失败") |
| | | - 扩展词(不同人可能的描述,如"充气慢"、"进气不足") |
| | | - 平台/设备标签(如"电子秤平台"、"智能枪") |
| | | 2. related_docs(0-5个):相关文档标题(用于创建链接) |
| | | |
| | | 输出 JSON: |
| | | { |
| | | "tags": ["充不进气", "充气慢", "进气不足", "智能枪", "电子秤平台"], |
| | | "related_docs": ["智能枪通气杆卡住漏气", "角阀充装功率不足"] |
| | | } |
| | | |
| | | 草稿内容: |
| | | {content} |
| | | ``` |
| | | |
| | | #### 生成合并指示 |
| | | |
| | | ``` |
| | | 你是一个知识库管理助手。请判断以下草稿与哪些知识库文档相关。 |
| | | |
| | | ## 草稿内容 |
| | | {draft_content} |
| | | |
| | | ## 候选文档(Top 3) |
| | | 1. 标题: {title1}, 路径: {path1}, 相关度: {score1} |
| | | 2. 标题: {title2}, 路径: {path2}, 相关度: {score2} |
| | | 3. 标题: {title3}, 路径: {path3}, 相关度: {score3} |
| | | |
| | | ## 任务 |
| | | 请分析草稿与每个候选文档的相关性,输出 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,不要其他内容。 |
| | | ``` |
| | | |
| | | ## 文件结构 |
| | | |
| | | ``` |
| | | 待审阅/ |
| | | ├── 售后提取/ |
| | | │ ├── 001-20260726-充不进气问题/ |
| | | │ │ ├── draft.md # 草稿内容(包含 tags) |
| | | │ │ └── merge.md # 合并指示 |
| | | │ └── 002-20260726-档案下载失败/ |
| | | │ ├── draft.md |
| | | │ └── merge.md |
| | | ├── 产品提取/ |
| | | ├── 运营提取/ |
| | | ├── TAPD提取/ |
| | | └── counter.json # 编号计数器 |
| | | ``` |
| | | |
| | | ### draft.md 格式 |
| | | |
| | | ```markdown |
| | | --- |
| | | title: "充不进气问题" |
| | | type: "售后" |
| | | status: 待确认 |
| | | draft: true |
| | | source: "售后诊断" |
| | | tags: [充不进气, 充气慢, 进气不足, 智能枪, 电子秤平台] |
| | | created: 2026-07-26 10:30:00 |
| | | --- |
| | | |
| | | ## 问题描述 |
| | | |
| | | 客户反馈充装时充不进气,或充气速度很慢... |
| | | |
| | | ## 排查过程 |
| | | |
| | | 1. 检查智能枪是否正常连接 |
| | | 2. 检查角阀是否打开 |
| | | ... |
| | | |
| | | ## 解决方案 |
| | | |
| | | 1. 更换智能枪通气杆 |
| | | 2. 清理角阀滤网 |
| | | ... |
| | | ``` |
| | | |
| | | ### merge.md 格式 |
| | | |
| | | ```markdown |
| | | --- |
| | | draft_id: 001-20260726-充不进气问题 |
| | | generated_at: 2026-07-26 10:35:00 |
| | | confidence: high |
| | | --- |
| | | |
| | | ## 合并指示 |
| | | |
| | | **操作类型**: merge |
| | | **目标**: FAQ/充装类/001-智能枪通气杆卡住漏气.md |
| | | **理由**: 草稿描述的"充不进气"问题与现有 FAQ 001 高度相关,都是智能枪通气杆问题导致的充气异常 |
| | | |
| | | ## 依据 |
| | | |
| | | ### 搜索到的相关文档(Top 3) |
| | | |
| | | 1. **智能枪通气杆卡住漏气** (相似度: 0.92) |
| | | - 路径: `FAQ/充装类/001-智能枪通气杆卡住漏气.md` |
| | | - 摘要: 智能枪通气杆卡住导致漏气,影响充装... |
| | | |
| | | 2. **角阀充装功率不足** (相似度: 0.78) |
| | | - 路径: `FAQ/充装类/017-角阀充装功率不足.md` |
| | | - 摘要: 角阀供电不足导致充装功率低... |
| | | |
| | | 3. **充不进气与老瓶芯片问题** (相似度: 0.75) |
| | | - 路径: `FAQ/充装类/013-充不进气与老瓶芯片问题.md` |
| | | - 摘要: 老瓶芯片兼容性问题导致充不进气... |
| | | |
| | | ## LLM 判断 |
| | | |
| | | - **FAQ/充装类/001-智能枪通气杆卡住漏气.md**: high - 草稿描述的充气慢、充不进气现象与 FAQ 001 的通气杆卡住问题高度一致 |
| | | - **FAQ/充装类/017-角阀充装功率不足.md**: medium - 功率不足也会导致充气慢,但根因不同 |
| | | - **FAQ/充装类/013-充不进气与老瓶芯片问题.md**: medium - 都是充不进气问题,但老瓶芯片是识别问题,不是充气问题 |
| | | ``` |
| | | |
| | | ## 技术实现 |
| | | |
| | | ### 依赖 |
| | | |
| | | - `github.com/mattn/go-sqlite3` (需要 CGO + FTS5 标签) |
| | | - 本地 LLM API(OpenAI 兼容格式) |
| | | |
| | | ### 模块设计 |
| | | |
| | | #### 1. internal/llm/client.go |
| | | |
| | | LLM 客户端,封装 API 调用。 |
| | | |
| | | ```go |
| | | type Client struct { |
| | | apiBase string |
| | | apiKey string |
| | | model string |
| | | } |
| | | |
| | | func NewClient(apiBase, apiKey, model string) *Client |
| | | |
| | | func (c *Client) ExtractTags(content string) (*ExtractResult, error) |
| | | func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchResult) (*MergeHint, error) |
| | | ``` |
| | | |
| | | #### 2. internal/draft/intake.go |
| | | |
| | | 草稿录入逻辑。 |
| | | |
| | | ```go |
| | | type DraftIntake struct { |
| | | vaultPath string |
| | | llmClient *llm.Client |
| | | store *index.Store |
| | | } |
| | | |
| | | func NewDraftIntake(vaultPath string, llmClient *llm.Client, store *index.Store) *DraftIntake |
| | | |
| | | func (d *DraftIntake) CreateDraft(draftType, title, content, source string, force bool) error |
| | | func (d *DraftIntake) RebuildTags(limit int, dryRun bool) error |
| | | ``` |
| | | |
| | | #### 3. cmd/draft.go |
| | | |
| | | CLI 命令定义。 |
| | | |
| | | ```go |
| | | var draftCmd = &cobra.Command{ |
| | | Use: "draft", |
| | | Short: "草稿管理", |
| | | } |
| | | |
| | | var draftCreateCmd = &cobra.Command{ |
| | | Use: "create", |
| | | Short: "创建草稿", |
| | | RunE: runDraftCreate, |
| | | } |
| | | |
| | | var tagsRebuildCmd = &cobra.Command{ |
| | | Use: "rebuild", |
| | | Short: "重建 tags", |
| | | RunE: runTagsRebuild, |
| | | } |
| | | ``` |
| | | |
| | | ## 待解决问题 |
| | | |
| | | ### 1. FTS5 模块缺失 |
| | | |
| | | **问题**: go-sqlite3 编译时未启用 FTS5,导致 `kb-cli search` 报错 `no such module: fts5` |
| | | |
| | | **解决方案**: 编译时添加 CGO 标签 |
| | | ```bash |
| | | go build -tags fts5 -o ~/go/bin/kb-cli |
| | | ``` |
| | | |
| | | **验证**: |
| | | ```bash |
| | | kb-cli search 充装 |
| | | ``` |
| | | |
| | | ### 2. LLM API 配置 |
| | | |
| | | **问题**: 需要硬编码 API 地址和密钥,还是从配置文件读取? |
| | | |
| | | **方案**: 从环境变量读取,支持回退到默认值 |
| | | ```go |
| | | apiBase := os.Getenv("KB_LLM_API_BASE") |
| | | if apiBase == "" { |
| | | apiBase = "http://192.168.3.246:1127/v1" |
| | | } |
| | | ``` |
| | | |
| | | ## 实现计划 |
| | | |
| | | ### Phase 1: 修复 FTS5 问题 |
| | | 1. 重新编译 kb-cli,添加 `-tags fts5` |
| | | 2. 验证 `kb-cli search` 正常工作 |
| | | |
| | | ### Phase 2: 实现 LLM 客户端 |
| | | 1. 创建 `internal/llm/client.go` |
| | | 2. 实现 `ExtractTags` 和 `GenerateMergeHint` 方法 |
| | | 3. 编写单元测试 |
| | | |
| | | ### Phase 3: 实现 draft create 命令 |
| | | 1. 创建 `internal/draft/intake.go` |
| | | 2. 实现草稿创建逻辑 |
| | | 3. 创建 `cmd/draft.go`,定义 CLI 命令 |
| | | 4. 集成测试 |
| | | |
| | | ### Phase 4: 实现 tags rebuild 命令 |
| | | 1. 实现批量重建 tags 逻辑 |
| | | 2. 添加 `--dry-run` 支持 |
| | | 3. 集成测试 |
| | | |
| | | ### Phase 5: 文档与发布 |
| | | 1. 更新 README.md |
| | | 2. 更新技能文档 |
| | | 3. 发布 v0.4.0 |
| | | |
| | | ## 风险与缓解 |
| | | |
| | | ### 风险 1: LLM 输出不稳定 |
| | | |
| | | **缓解**: |
| | | - 使用 temperature=0.3 降低随机性 |
| | | - 添加 JSON 解析容错(尝试提取 ```json 代码块) |
| | | - 失败时回退到简单模式(只生成 tags,不生成 related_docs) |
| | | |
| | | ### 风险 2: tags 质量不高 |
| | | |
| | | **缓解**: |
| | | - Prompt 明确要求三类 tags(核心问题 + 扩展词 + 平台/设备) |
| | | - 提供示例,引导 LLM 输出高质量 tags |
| | | - 后续可通过 `tags rebuild` 批量优化 |
| | | |
| | | ### 风险 3: 合并指示不准确 |
| | | |
| | | **缓解**: |
| | | - 提供 top3 候选文档,让 LLM 有足够信息判断 |
| | | - 要求 LLM 输出 confidence 字段,低置信度时提示人工审核 |
| | | - 保留 merge.md 文件,方便人工修正 |
| | | |
| | | ## 成功标准 |
| | | |
| | | - `kb-cli draft create` 能成功创建草稿,包含 tags 和合并指示 |
| | | - `kb-cli tags rebuild` 能批量重建现有文档的 tags |
| | | - tags 质量满足搜索需求(能匹配用户描述的问题) |
| | | - 合并指示准确率达到 80% 以上(high + medium confidence) |
| | | |
| | | ## 后续优化 |
| | | |
| | | 1. **智能分类**: 根据内容自动判断草稿类型(售后/产品/运营) |
| | | 2. **增量更新**: 只重建 tags 变化的文档,减少 LLM 调用 |
| | | 3. **批量模式**: 支持从文件批量导入草稿(如 CSV、JSON) |
| | | 4. **Web UI**: 提供 Web 界面管理草稿和审阅流程 |
| New file |
| | |
| | | # kb-cli 增强与 kb-search 技能调整设计 |
| | | |
| | | **日期:** 2026-07-26 |
| | | **状态:** 已批准 |
| | | |
| | | --- |
| | | |
| | | ## 1. 背景与目标 |
| | | |
| | | ### 1.1 背景 |
| | | |
| | | kb-cli(Go 实现)已完成基础功能,与 Python 版 kb-search.py 对比测试显示: |
| | | - **kb-cli 优势:** 索引正常(768 文件),搜索快速(8ms),结果准确 |
| | | - **kb-search.py 问题:** 索引为空(update-index 返回 0 条),搜索返回空结果 |
| | | |
| | | 用户决策:优先使用 kb-cli,Python 脚本暂时保留作为备选。 |
| | | |
| | | ### 1.2 目标 |
| | | |
| | | 1. **给 kb-cli 增加两个功能:** |
| | | - `--with-content`:搜索时返回完整文件内容 |
| | | - `--with-links`:搜索时显示关联文档链接 |
| | | |
| | | 2. **调整 kb-search 技能:** |
| | | - 优先使用 kb-cli |
| | | - Python 脚本降级为备选方案 |
| | | |
| | | --- |
| | | |
| | | ## 2. 方案选择 |
| | | |
| | | ### 2.1 候选方案 |
| | | |
| | | | 方案 | 描述 | 优点 | 缺点 | |
| | | |------|------|------|------| |
| | | | **A. 最小改动** | 在 SearchResult 结构体上加可选字段 | 改动小,向后兼容,逻辑清晰 | 无 | |
| | | | B. 分层增强 | 创建 Enhancer 接口,搜索管道模式 | 扩展性强 | 过度设计 | |
| | | | C. 独立命令 | 新增 `kb search-detail` 子命令 | 职责清晰 | 重复代码多 | |
| | | |
| | | ### 2.2 决策 |
| | | |
| | | **选择方案 A:最小改动** |
| | | |
| | | 理由: |
| | | 1. 改动最小,只改 `search.go` 和 `output/formatter.go` |
| | | 2. 向后兼容,现有脚本不受影响 |
| | | 3. 逻辑简单,容易理解和维护 |
| | | 4. 不需要过度设计 |
| | | |
| | | --- |
| | | |
| | | ## 3. 数据结构改动 |
| | | |
| | | ### 3.1 SearchResult 结构体 |
| | | |
| | | **文件:** `internal/search/engine.go` |
| | | |
| | | ```go |
| | | type SearchResult struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Score int `json:"score"` |
| | | |
| | | // 新增可选字段 |
| | | Content string `json:"content,omitempty"` // --with-content 时填充 |
| | | Links []string `json:"links,omitempty"` // --with-links 时填充 |
| | | } |
| | | ``` |
| | | |
| | | ### 3.2 SearchOptions 结构体 |
| | | |
| | | **文件:** `internal/search/engine.go` |
| | | |
| | | ```go |
| | | type SearchOptions struct { |
| | | Expanded []string |
| | | Symptom []string |
| | | TopN int |
| | | WithContent bool // 新增 |
| | | WithLinks bool // 新增 |
| | | } |
| | | ``` |
| | | |
| | | ### 3.3 Search 函数逻辑 |
| | | |
| | | ```go |
| | | func Search(store *index.Store, keywords []string, opts SearchOptions) ([]SearchResult, error) { |
| | | // 1. 执行 FTS5 搜索 |
| | | ftsResults, err := store.FTSSearch(allKeywords, 100) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | |
| | | // 2. 评分和排序 |
| | | results := scoreAndSort(ftsResults, keywords, opts) |
| | | |
| | | // 3. 限制返回数量 |
| | | if opts.TopN > 0 && len(results) > opts.TopN { |
| | | results = results[:opts.TopN] |
| | | } |
| | | |
| | | // 4. 增强结果(新增) |
| | | for i := range results { |
| | | // 获取内容 |
| | | if opts.WithContent { |
| | | content, _, _, err := store.GetNodeContent(results[i].ID) |
| | | if err == nil { |
| | | results[i].Content = content |
| | | } |
| | | } |
| | | |
| | | // 获取关联链接 |
| | | if opts.WithLinks { |
| | | links, err := store.GetNodeLinks(results[i].ID) |
| | | if err == nil { |
| | | results[i].Links = links |
| | | } |
| | | } |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 4. 数据获取逻辑 |
| | | |
| | | ### 4.1 内容获取 |
| | | |
| | | **已有方法:** `store.GetNodeContent(id)` |
| | | |
| | | ```go |
| | | func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) |
| | | ``` |
| | | |
| | | 返回:`(content, tags, entities, error)` |
| | | |
| | | ### 4.2 关联链接获取 |
| | | |
| | | **新增方法:** `store.GetNodeLinks(nodeID)` |
| | | |
| | | ```go |
| | | func (s *Store) GetNodeLinks(nodeID int64) ([]string, error) { |
| | | query := ` |
| | | SELECT n.path |
| | | FROM edges e |
| | | JOIN nodes n ON n.id = e.to_node |
| | | WHERE e.from_node = ? AND e.relation = 'wikilink' |
| | | ` |
| | | rows, err := s.db.Query(query, nodeID) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var links []string |
| | | for rows.Next() { |
| | | var path string |
| | | if err := rows.Scan(&path); err != nil { |
| | | return nil, err |
| | | } |
| | | links = append(links, path) |
| | | } |
| | | return links, nil |
| | | } |
| | | ``` |
| | | |
| | | **查询逻辑:** |
| | | - 查询 `edges` 表,`relation='wikilink'` |
| | | - 返回目标节点的 `path`(即关联文档路径) |
| | | |
| | | --- |
| | | |
| | | ## 5. CLI 命令改动 |
| | | |
| | | ### 5.1 新增 flag |
| | | |
| | | **文件:** `cmd/search.go` |
| | | |
| | | ```go |
| | | var ( |
| | | withContent bool |
| | | withLinks bool |
| | | ) |
| | | |
| | | func init() { |
| | | // ... 现有 flag |
| | | searchCmd.Flags().BoolVar(&withContent, "with-content", false, "返回完整文件内容") |
| | | searchCmd.Flags().BoolVar(&withLinks, "with-links", false, "显示关联文档链接") |
| | | } |
| | | ``` |
| | | |
| | | ### 5.2 传递给 SearchOptions |
| | | |
| | | ```go |
| | | func runSearch(cmd *cobra.Command, args []string) error { |
| | | opts := search.SearchOptions{ |
| | | Expanded: expanded, |
| | | Symptom: symptom, |
| | | TopN: topN, |
| | | WithContent: withContent, |
| | | WithLinks: withLinks, |
| | | } |
| | | |
| | | results, err := search.Search(store, args, opts) |
| | | // ... |
| | | } |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 6. 输出格式化改动 |
| | | |
| | | ### 6.1 表格输出 |
| | | |
| | | **文件:** `internal/output/formatter.go` |
| | | |
| | | **基础输出(无增强):** |
| | | ``` |
| | | 路径 标题 板块 得分 |
| | | -------------------------------------------------- |
| | | FAQ/充装类/087-xxx.md 扫码验证气瓶充装 FAQ 6 |
| | | ``` |
| | | |
| | | **带内容(--with-content):** |
| | | ``` |
| | | 路径 标题 板块 得分 |
| | | -------------------------------------------------- |
| | | FAQ/充装类/087-xxx.md 扫码验证气瓶充装 FAQ 6 |
| | | |
| | | --- 内容 --- |
| | | # 扫码验证气瓶充装 |
| | | |
| | | ## 问题描述 |
| | | ... |
| | | ``` |
| | | |
| | | **带链接(--with-links):** |
| | | ``` |
| | | 路径 标题 板块 得分 |
| | | -------------------------------------------------- |
| | | FAQ/充装类/087-xxx.md 扫码验证气瓶充装 FAQ 6 |
| | | |
| | | --- 关联文档 --- |
| | | - 典型案例/充装异常案例.md |
| | | - 文档/电子秤平台/平台文档/023-充装统计与票据核对.md |
| | | ``` |
| | | |
| | | **两者都有(--with-content --with-links):** |
| | | ``` |
| | | 路径 标题 板块 得分 |
| | | -------------------------------------------------- |
| | | FAQ/充装类/087-xxx.md 扫码验证气瓶充装 FAQ 6 |
| | | |
| | | --- 内容 --- |
| | | # 扫码验证气瓶充装 |
| | | ... |
| | | |
| | | --- 关联文档 --- |
| | | - 典型案例/充装异常案例.md |
| | | ``` |
| | | |
| | | ### 6.2 JSON 输出 |
| | | |
| | | ```json |
| | | [ |
| | | { |
| | | "id": 1577, |
| | | "path": "FAQ/充装类/087-xxx.md", |
| | | "title": "扫码验证气瓶充装", |
| | | "section": "FAQ", |
| | | "score": 6, |
| | | "content": "# 扫码验证气瓶充装\n\n## 问题描述\n...", |
| | | "links": [ |
| | | "典型案例/充装异常案例.md", |
| | | "文档/电子秤平台/平台文档/023-充装统计与票据核对.md" |
| | | ] |
| | | } |
| | | ] |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 7. kb-search 技能调整 |
| | | |
| | | ### 7.1 优先级策略 |
| | | |
| | | 1. **优先使用 kb-cli:** `kb search "关键词" --with-content --top 3` |
| | | 2. **降级到 kb-search.py:** 仅当 kb-cli 不可用时 |
| | | |
| | | ### 7.2 技能文档改动 |
| | | |
| | | **核心工具:** |
| | | - 从 `kb-search.py` 改为 `kb` |
| | | |
| | | **命令示例:** |
| | | ```bash |
| | | # 基础搜索 |
| | | kb search "充装规格" --top 5 |
| | | |
| | | # 带内容 |
| | | kb search "充装规格" --with-content --top 3 |
| | | |
| | | # 带链接 |
| | | kb search "充装规格" --with-links --top 3 |
| | | |
| | | # 带扩展词和症状词 |
| | | kb search "充装" --expanded "重量 规格" --symptom "报错 无法启动" --with-content --top 3 |
| | | ``` |
| | | |
| | | **保留内容:** |
| | | - `--expanded`、`--symptom` 用法说明 |
| | | - 搜索策略(术语映射、关键词选择) |
| | | - 最佳实践(决策树、案例) |
| | | |
| | | **移到降级方案:** |
| | | - kb-search.py 完整用法移到"降级方案"章节 |
| | | |
| | | ### 7.3 降级方案 |
| | | |
| | | ```markdown |
| | | ## 降级方案 |
| | | |
| | | 仅当 kb-cli 不可用时,使用 kb-search.py: |
| | | |
| | | ```bash |
| | | export KB_VAULT=/home/aisim-p/aisim/note/001/笔记001 |
| | | python3 ~/.hermes/skills/kb-knowledge/kb-search/scripts/kb-search.py search "关键词" --with-content --top 3 |
| | | ``` |
| | | |
| | | 注意:kb-search.py 当前有索引问题,可能返回空结果。 |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 8. 测试计划 |
| | | |
| | | ### 8.1 单元测试 |
| | | |
| | | - 测试 `SearchResult` 结构体的 JSON 序列化(`omitempty` 行为) |
| | | - 测试 `GetNodeLinks` 方法 |
| | | |
| | | ### 8.2 集成测试 |
| | | |
| | | ```bash |
| | | # 测试基础搜索 |
| | | kb search "充装" --top 5 |
| | | |
| | | # 测试带内容 |
| | | kb search "充装" --with-content --top 3 |
| | | |
| | | # 测试带链接 |
| | | kb search "充装" --with-links --top 3 |
| | | |
| | | # 测试两者都有 |
| | | kb search "充装" --with-content --with-links --top 3 |
| | | |
| | | # 测试 JSON 输出 |
| | | kb search "充装" --with-content --json --top 3 |
| | | ``` |
| | | |
| | | ### 8.3 对比测试 |
| | | |
| | | 与 Python 版对比: |
| | | ```bash |
| | | # Go 版 |
| | | kb search "充装" --with-content --top 3 |
| | | |
| | | # Python 版 |
| | | python3 kb-search.py search "充装" --with-content --top 3 |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 9. 实施步骤 |
| | | |
| | | 1. **数据结构改动** |
| | | - 修改 `SearchResult` 结构体 |
| | | - 修改 `SearchOptions` 结构体 |
| | | - 实现 `GetNodeLinks` 方法 |
| | | |
| | | 2. **搜索逻辑改动** |
| | | - 修改 `Search` 函数,增加内容获取和链接获取逻辑 |
| | | |
| | | 3. **CLI 命令改动** |
| | | - 新增 `--with-content` 和 `--with-links` flag |
| | | - 传递给 `SearchOptions` |
| | | |
| | | 4. **输出格式化改动** |
| | | - 修改 `FormatTable` 和 `FormatJSON` 方法 |
| | | - 支持内容和链接的输出 |
| | | |
| | | 5. **测试** |
| | | - 单元测试 |
| | | - 集成测试 |
| | | - 对比测试 |
| | | |
| | | 6. **技能文档更新** |
| | | - 更新 kb-search 技能文档 |
| | | - 调整优先级策略 |
| | | |
| | | --- |
| | | |
| | | ## 10. 风险与缓解 |
| | | |
| | | | 风险 | 缓解措施 | |
| | | |------|----------| |
| | | | 内容获取慢(大量文件) | 限制 `--top N`,默认 10 | |
| | | | 链接查询慢 | 使用索引,查询 `edges` 表 | |
| | | | JSON 输出过大 | `omitempty` 标签,不填充时不输出 | |
| | | | 向后兼容 | 新增字段为可选,不影响现有脚本 | |
| | | |
| | | --- |
| | | |
| | | ## 11. 成功标准 |
| | | |
| | | 1. ✅ kb-cli 支持 `--with-content` 和 `--with-links` |
| | | 2. ✅ 输出格式正确(表格和 JSON) |
| | | 3. ✅ 性能可接受(< 100ms) |
| | | 4. ✅ kb-search 技能文档更新完成 |
| | | 5. ✅ 对比测试通过(优于 Python 版) |
| | | |
| | | --- |
| | | |
| | | ## 12. 附录 |
| | | |
| | | ### 12.1 相关文件 |
| | | |
| | | - `internal/search/engine.go` - 搜索逻辑 |
| | | - `internal/index/sqlite.go` - 数据库操作 |
| | | - `internal/output/formatter.go` - 输出格式化 |
| | | - `cmd/search.go` - CLI 命令 |
| | | - `~/.hermes/skills/kb-knowledge/kb-search/SKILL.md` - 技能文档 |
| | | |
| | | ### 12.2 参考 |
| | | |
| | | - Python 版 kb-search.py:`~/.hermes/skills/kb-knowledge/kb-search/scripts/kb-search.py` |
| | | - SQLite 数据模型:`docs/superpowers/specs/2026-07-26-kb-cli-design.md` |
| New file |
| | |
| | | package classify |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | ) |
| | | |
| | | // Entity 实体定义 |
| | | type Entity struct { |
| | | ID string `json:"id"` |
| | | Name string `json:"name"` |
| | | Aliases []string `json:"aliases"` |
| | | Platform string `json:"platform,omitempty"` |
| | | Description string `json:"description,omitempty"` |
| | | Users []string `json:"users,omitempty"` |
| | | URL string `json:"url,omitempty"` |
| | | } |
| | | |
| | | // Entities 实体集合 |
| | | type Entities struct { |
| | | Platforms []Entity `json:"platforms"` |
| | | Apps []Entity `json:"apps"` |
| | | Devices []Entity `json:"devices"` |
| | | } |
| | | |
| | | // Relation 实体关系 |
| | | type Relation struct { |
| | | From string `json:"from"` |
| | | To string `json:"to"` |
| | | Type string `json:"type"` |
| | | Description string `json:"description"` |
| | | } |
| | | |
| | | // Relations 关系集合 |
| | | type Relations struct { |
| | | Relations []Relation `json:"relations"` |
| | | } |
| | | |
| | | // ExtractEntities 从实体目录提取实体信息 |
| | | func ExtractEntities(vaultPath string) (*Entities, *Relations, error) { |
| | | entityDir := filepath.Join(vaultPath, "实体") |
| | | |
| | | // 检查目录是否存在 |
| | | if _, err := os.Stat(entityDir); os.IsNotExist(err) { |
| | | return nil, nil, fmt.Errorf("实体目录不存在: %s", entityDir) |
| | | } |
| | | |
| | | // 预定义实体(从设计文档) |
| | | entities := &Entities{ |
| | | Platforms: []Entity{ |
| | | { |
| | | ID: "elc", |
| | | Name: "电子秤平台", |
| | | Aliases: []string{"电子秤后台", "elc平台", "elc.zhiheiot.com"}, |
| | | URL: "https://elc.zhiheiot.com", |
| | | Description: "气站充装作业管理系统", |
| | | Users: []string{"气站站长", "充装员", "开票员"}, |
| | | }, |
| | | { |
| | | ID: "ops", |
| | | Name: "运营管理平台", |
| | | Aliases: []string{"运营平台", "运营后台", "rb.zhiheiot.com"}, |
| | | URL: "https://rb.zhiheiot.com", |
| | | Description: "气站运营管理系统", |
| | | Users: []string{"老板", "管理层", "客服"}, |
| | | }, |
| | | }, |
| | | Apps: []Entity{ |
| | | { |
| | | ID: "safety", |
| | | Name: "安全用气App", |
| | | Aliases: []string{"配送App", "电子秤配送App"}, |
| | | Platform: "elc", |
| | | Description: "二维码配送", |
| | | }, |
| | | { |
| | | ID: "delivery", |
| | | Name: "易配送App", |
| | | Aliases: []string{"配送App", "运营配送"}, |
| | | Platform: "ops", |
| | | Description: "NFC识别芯片配送", |
| | | }, |
| | | { |
| | | ID: "assistant", |
| | | Name: "艾信助手App", |
| | | Aliases: []string{"建档App"}, |
| | | Platform: "both", |
| | | Description: "融合两平台建档", |
| | | }, |
| | | }, |
| | | Devices: []Entity{ |
| | | { |
| | | ID: "scale", |
| | | Name: "电子秤", |
| | | Aliases: []string{"充装电子秤"}, |
| | | Platform: "elc", |
| | | Description: "充装作业设备", |
| | | }, |
| | | { |
| | | ID: "gun", |
| | | Name: "智能枪", |
| | | Aliases: []string{"充装枪", "智能充装枪"}, |
| | | Platform: "both", |
| | | Description: "共有硬件", |
| | | }, |
| | | { |
| | | ID: "valve", |
| | | Name: "智能阀", |
| | | Aliases: []string{"智能角阀"}, |
| | | Platform: "ops", |
| | | Description: "气瓶阀门", |
| | | }, |
| | | { |
| | | ID: "box", |
| | | Name: "艾信盒子", |
| | | Aliases: []string{"4G通信设备"}, |
| | | Platform: "both", |
| | | Description: "共有硬件", |
| | | }, |
| | | }, |
| | | } |
| | | |
| | | // 预定义关系 |
| | | relations := &Relations{ |
| | | Relations: []Relation{ |
| | | {From: "gun", To: "scale", Type: "connects", Description: "智能枪连接电子秤"}, |
| | | {From: "gun", To: "box", Type: "syncs", Description: "充装数据同步到艾信盒子"}, |
| | | {From: "box", To: "elc", Type: "uploads", Description: "上传充装记录"}, |
| | | {From: "box", To: "ops", Type: "uploads", Description: "上传充装记录"}, |
| | | {From: "valve", To: "delivery", Type: "identified_by", Description: "NFC识别"}, |
| | | {From: "safety", To: "elc", Type: "belongs_to", Description: "属于电子秤平台"}, |
| | | {From: "delivery", To: "ops", Type: "belongs_to", Description: "属于运营管理平台"}, |
| | | }, |
| | | } |
| | | |
| | | return entities, relations, nil |
| | | } |
| | | |
| | | // SaveEntities 保存实体和关系到 JSON 文件 |
| | | func SaveEntities(vaultPath string, entities *Entities, relations *Relations) error { |
| | | // 保存 entities.json |
| | | entitiesPath := filepath.Join(vaultPath, "entities.json") |
| | | entitiesData, err := json.MarshalIndent(entities, "", " ") |
| | | if err != nil { |
| | | return fmt.Errorf("序列化 entities 失败: %w", err) |
| | | } |
| | | if err := os.WriteFile(entitiesPath, entitiesData, 0644); err != nil { |
| | | return fmt.Errorf("写入 entities.json 失败: %w", err) |
| | | } |
| | | |
| | | // 保存 relations.json |
| | | relationsPath := filepath.Join(vaultPath, "relations.json") |
| | | relationsData, err := json.MarshalIndent(relations, "", " ") |
| | | if err != nil { |
| | | return fmt.Errorf("序列化 relations 失败: %w", err) |
| | | } |
| | | if err := os.WriteFile(relationsPath, relationsData, 0644); err != nil { |
| | | return fmt.Errorf("写入 relations.json 失败: %w", err) |
| | | } |
| | | |
| | | return nil |
| | | } |
| New file |
| | |
| | | package classify |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/llm" |
| | | ) |
| | | |
| | | // Document 文档信息 |
| | | type Document struct { |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Content string `json:"content"` |
| | | } |
| | | |
| | | // Classification 分类结果 |
| | | type Classification struct { |
| | | Path string `json:"path"` |
| | | Platform string `json:"platform"` |
| | | Device string `json:"device"` |
| | | Confidence float64 `json:"confidence"` |
| | | } |
| | | |
| | | // ClassificationResult 分类结果集合 |
| | | type ClassificationResult struct { |
| | | Total int `json:"total"` |
| | | Classified int `json:"classified"` |
| | | NeedsReview int `json:"needs_review"` |
| | | Items []Classification `json:"items"` |
| | | } |
| | | |
| | | // ScanDocuments 扫描知识库文档 |
| | | func ScanDocuments(vaultPath string) ([]Document, error) { |
| | | var docs []Document |
| | | |
| | | // 转换为绝对路径 |
| | | absPath, err := filepath.Abs(vaultPath) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("转换路径失败: %w", err) |
| | | } |
| | | |
| | | // 扫描整个知识库的所有 .md 文件 |
| | | err = filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error { |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | // 跳过隐藏目录和特殊目录 |
| | | if info.IsDir() { |
| | | name := info.Name() |
| | | if strings.HasPrefix(name, ".") || name == "node_modules" { |
| | | return filepath.SkipDir |
| | | } |
| | | // 跳过待审阅目录 |
| | | if name == "待审阅" { |
| | | return filepath.SkipDir |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | // 只处理 .md 文件 |
| | | if !strings.HasSuffix(path, ".md") { |
| | | return nil |
| | | } |
| | | |
| | | content, err := os.ReadFile(path) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | relPath, err := filepath.Rel(absPath, path) |
| | | if err != nil { |
| | | relPath = path |
| | | } |
| | | |
| | | title := extractTitle(string(content)) |
| | | summary := extractSummary(string(content), 500) |
| | | |
| | | docs = append(docs, Document{ |
| | | Path: relPath, |
| | | Title: title, |
| | | Content: summary, |
| | | }) |
| | | return nil |
| | | }) |
| | | |
| | | if err != nil { |
| | | return nil, fmt.Errorf("扫描知识库失败: %w", err) |
| | | } |
| | | |
| | | return docs, nil |
| | | } |
| | | |
| | | // extractTitle 从 frontmatter 或第一行 # 提取标题 |
| | | func extractTitle(content string) string { |
| | | lines := strings.Split(content, "\n") |
| | | inFrontmatter := false |
| | | |
| | | for _, line := range lines { |
| | | line = strings.TrimSpace(line) |
| | | if line == "---" { |
| | | inFrontmatter = !inFrontmatter |
| | | continue |
| | | } |
| | | if inFrontmatter { |
| | | if strings.HasPrefix(line, "title:") { |
| | | title := strings.TrimPrefix(line, "title:") |
| | | title = strings.TrimSpace(title) |
| | | title = strings.Trim(title, "\"'") |
| | | return title |
| | | } |
| | | } else if strings.HasPrefix(line, "# ") { |
| | | return strings.TrimPrefix(line, "# ") |
| | | } |
| | | } |
| | | return "未知标题" |
| | | } |
| | | |
| | | // extractSummary 提取摘要(前 maxLen 字) |
| | | func extractSummary(content string, maxLen int) string { |
| | | // 跳过 frontmatter |
| | | lines := strings.Split(content, "\n") |
| | | startIdx := 0 |
| | | inFrontmatter := false |
| | | |
| | | for i, line := range lines { |
| | | if strings.TrimSpace(line) == "---" { |
| | | if !inFrontmatter { |
| | | inFrontmatter = true |
| | | } else { |
| | | startIdx = i + 1 |
| | | break |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 提取正文 |
| | | summary := strings.Join(lines[startIdx:], "\n") |
| | | summary = strings.TrimSpace(summary) |
| | | |
| | | if len(summary) > maxLen { |
| | | summary = summary[:maxLen] + "..." |
| | | } |
| | | |
| | | return summary |
| | | } |
| | | |
| | | // ClassifyBatch 批量分类文档 |
| | | func ClassifyBatch(docs []Document, llmClient *llm.Client) ([]Classification, error) { |
| | | // 构建 prompt |
| | | prompt := buildClassifyPrompt(docs) |
| | | |
| | | // 调用 LLM |
| | | response, err := llmClient.ClassifyDocuments(prompt) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | |
| | | // 解析响应 |
| | | var results []Classification |
| | | if err := json.Unmarshal([]byte(response), &results); err != nil { |
| | | return nil, fmt.Errorf("解析 LLM 响应失败: %w", err) |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | |
| | | // buildClassifyPrompt 构建分类 prompt |
| | | func buildClassifyPrompt(docs []Document) string { |
| | | prompt := `你是知识库分类专家。分析以下文档,判断其所属平台、设备和内容类型。 |
| | | |
| | | ## 实体定义(必须严格遵守) |
| | | |
| | | ### 硬件设备 |
| | | | 设备 | 归属 | 说明 | |
| | | |------|------|------| |
| | | | 智能枪 | 运营管理平台配套 | 智能控制箱+智能枪头,独立4G网络,可独立工作或安装在电子秤中 | |
| | | | 电子秤 | 电子秤平台 | 含扫码枪(分防爆/非防爆),扫码枪是电子秤配件,**不是智能枪** | |
| | | | 智能阀 | 运营管理平台基础 | NFC识别芯片,是运营平台气瓶管理的基础 | |
| | | | 艾信盒子 | 两平台共用 | 4G通信设备,同步存储充装数据,控制上传第三方平台 | |
| | | |
| | | ### 软件平台 |
| | | | 平台 | 域名 | 用户 | 核心功能 | |
| | | |------|------|------|----------| |
| | | | 运营管理平台 | rb.zhiheiot.com | 老板/管理层/客服/配送调度 | 经营管理、售后、配送、工单、会员 | |
| | | | 电子秤平台 | elc.zhiheiot.com | 气站站长/充装员/开票员 | 充装作业(扫码、充装、称重、开票/补单)+ 终端配送(简化版) | |
| | | |
| | | ### App终端 |
| | | | App | 归属平台 | 功能 | |
| | | |-----|----------|------| |
| | | | 易配送App | 运营管理平台 | NFC芯片识别配送 | |
| | | | 安全用气App | 电子秤平台 | 二维码配送 | |
| | | | 艾信助手App | 融合两平台 | 建档 + 充前/充后检查 | |
| | | |
| | | ### 小程序 |
| | | | 小程序 | 归属 | 用户 | 功能 | |
| | | |--------|------|------|------| |
| | | | 艾信LPG物联网小程序 | 电子秤平台 | 气站管理员/配送员 | 报表/迎检/配送 | |
| | | | 艾信发货小程序 | 内部工具 | 艾信内部员工 | 设备总览、客户资料、气站资料 | |
| | | |
| | | ## 关键区分点(必须遵守) |
| | | |
| | | | 容易混淆 | 正确归属 | |
| | | |----------|----------| |
| | | | 智能枪 vs 扫码枪 | 智能枪是独立设备(4G网络);扫码枪是电子秤配件 | |
| | | | 开票/补单/充装 | 电子秤平台功能 | |
| | | | 主板版本过低 | 智能枪问题 | |
| | | | 艾信发货小程序 | 内部工具,可控制所有智能枪、电子秤 | |
| | | | 艾信LPG物联网小程序 | 电子秤平台气站管理员用 | |
| | | | 配送功能 | 两平台都有,运营平台用易配送App,电子秤平台用安全用气App | |
| | | |
| | | ## 分类规则 |
| | | |
| | | 1. **platform**(属于哪个平台,必须使用中文): |
| | | - 电子秤平台:充装作业、开票、电子秤设备、安全用气App、艾信LPG小程序 |
| | | - 运营管理平台:配送、档案、会员、工单、易配送App、智能枪、智能阀 |
| | | - 共有:两个平台都涉及(艾信盒子、艾信助手App、充装记录同步) |
| | | - 第三方平台:祥康、监管平台等 |
| | | - 通用:不涉及具体平台/内部工具 |
| | | |
| | | 2. **device**(涉及什么设备/App,必须使用中文): |
| | | - 电子秤:含扫码枪、电磁阀、称重传感器、显示屏、键盘 |
| | | - 智能枪:智能控制箱+智能枪头,独立4G网络 |
| | | - 智能阀:NFC识别智能阀的芯片 |
| | | - 艾信盒子:4G通信设备 |
| | | - 安全用气App:电子秤平台配送端 |
| | | - 易配送App:运营管理平台配送端 |
| | | - 艾信助手App:融合两平台建档 |
| | | - 艾信LPG小程序:电子秤平台管理+配送 |
| | | - 艾信发货小程序:内部工具 |
| | | - 无:不涉及具体设备 |
| | | |
| | | 3. **content_type**(内容类型): |
| | | - FAQ / PRD / 知识 / 案例 / 实体 / 其他 |
| | | |
| | | ## 输出格式 |
| | | |
| | | 输出 JSON 数组,每项包含: |
| | | - path: 文件路径(字符串) |
| | | - platform: 平台名称(中文,必须是上述枚举值之一) |
| | | - device: 设备名称(中文,必须是上述枚举值之一) |
| | | - content_type: 内容类型(字符串,必须是上述枚举值之一) |
| | | - confidence: 置信度(浮点数,范围 0.0-1.0,例如 0.95) |
| | | |
| | | 示例输出: |
| | | [ |
| | | { |
| | | "path": "FAQ/充装类/001-智能枪通气杆卡住漏气.md", |
| | | "platform": "运营管理平台", |
| | | "device": "智能枪", |
| | | "content_type": "FAQ", |
| | | "confidence": 0.95 |
| | | } |
| | | ] |
| | | |
| | | 文档列表: |
| | | ` |
| | | |
| | | for _, doc := range docs { |
| | | prompt += fmt.Sprintf("\n文件: %s\n标题: %s\n摘要: %s\n", doc.Path, doc.Title, doc.Content) |
| | | } |
| | | |
| | | prompt += "\n请输出 JSON 数组:" |
| | | |
| | | return prompt |
| | | } |
| | | |
| | | // SaveClassification 保存分类结果 |
| | | func SaveClassification(vaultPath string, results []Classification) error { |
| | | // 统计 |
| | | total := len(results) |
| | | classified := 0 |
| | | needsReview := 0 |
| | | |
| | | for _, r := range results { |
| | | if r.Confidence >= 0.8 { |
| | | classified++ |
| | | } else { |
| | | needsReview++ |
| | | } |
| | | } |
| | | |
| | | result := ClassificationResult{ |
| | | Total: total, |
| | | Classified: classified, |
| | | NeedsReview: needsReview, |
| | | Items: results, |
| | | } |
| | | |
| | | path := filepath.Join(vaultPath, "classification.json") |
| | | data, err := json.MarshalIndent(result, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | return os.WriteFile(path, data, 0644) |
| | | } |
| New file |
| | |
| | | package classify |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "os/exec" |
| | | "path/filepath" |
| | | "strings" |
| | | ) |
| | | |
| | | // Migration 单个迁移项 |
| | | type Migration struct { |
| | | Source string `json:"source"` |
| | | Target string `json:"target"` |
| | | } |
| | | |
| | | // MigrationPlan 迁移计划 |
| | | type MigrationPlan struct { |
| | | Total int `json:"total"` |
| | | Migrations []Migration `json:"migrations"` |
| | | } |
| | | |
| | | // LoadClassification 加载分类结果 |
| | | func LoadClassification(vaultPath, filename string) (*ClassificationResult, error) { |
| | | path := filepath.Join(vaultPath, filename) |
| | | data, err := os.ReadFile(path) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("读取文件失败: %w", err) |
| | | } |
| | | |
| | | var result ClassificationResult |
| | | if err := json.Unmarshal(data, &result); err != nil { |
| | | return nil, fmt.Errorf("解析 JSON 失败: %w", err) |
| | | } |
| | | |
| | | return &result, nil |
| | | } |
| | | |
| | | // GenerateMigrationPlan 生成迁移计划 |
| | | func GenerateMigrationPlan(vaultPath string, classification *ClassificationResult) (*MigrationPlan, error) { |
| | | plan := &MigrationPlan{ |
| | | Total: len(classification.Items), |
| | | Migrations: []Migration{}, |
| | | } |
| | | |
| | | // 跟踪已使用的目标路径 |
| | | usedTargets := make(map[string]bool) |
| | | |
| | | for _, item := range classification.Items { |
| | | // 根据 platform 和 device 生成目标路径 |
| | | targetDir := getTargetDir(item.Platform, item.Device) |
| | | baseName := filepath.Base(item.Path) |
| | | targetPath := filepath.Join(targetDir, baseName) |
| | | |
| | | // 如果目标路径已使用,添加数字后缀直到找到唯一路径 |
| | | counter := 1 |
| | | for usedTargets[targetPath] { |
| | | counter++ |
| | | ext := filepath.Ext(baseName) |
| | | nameWithoutExt := strings.TrimSuffix(baseName, ext) |
| | | targetPath = filepath.Join(targetDir, fmt.Sprintf("%s_%d%s", nameWithoutExt, counter, ext)) |
| | | } |
| | | usedTargets[targetPath] = true |
| | | |
| | | plan.Migrations = append(plan.Migrations, Migration{ |
| | | Source: item.Path, |
| | | Target: targetPath, |
| | | }) |
| | | } |
| | | |
| | | return plan, nil |
| | | } |
| | | |
| | | // getTargetDir 根据平台和设备获取目标目录 |
| | | func getTargetDir(platform, device string) string { |
| | | // 平台目录映射(支持中文和英文标识符) |
| | | platformDir := map[string]string{ |
| | | // 英文标识符 |
| | | "elc": "电子秤平台", |
| | | "ops": "运营管理平台", |
| | | "both": "共有硬件", |
| | | "third_party": "第三方平台", |
| | | "general": "通用", |
| | | // 中文标识符 |
| | | "电子秤平台": "电子秤平台", |
| | | "运营管理平台": "运营管理平台", |
| | | "共有": "共有硬件", |
| | | "第三方平台": "第三方平台", |
| | | "通用": "通用", |
| | | } |
| | | |
| | | // 设备目录映射(支持中文和英文标识符) |
| | | deviceDir := map[string]string{ |
| | | // 英文标识符 |
| | | "scale": "电子秤", |
| | | "gun": "智能枪", |
| | | "valve": "智能阀", |
| | | "box": "艾信盒子", |
| | | "app_safety": "安全用气App", |
| | | "app_delivery": "易配送App", |
| | | "app_assistant": "艾信助手App", |
| | | "app_lpg": "艾信LPG小程序", |
| | | "app_shipping": "艾信发货小程序", |
| | | "none": "通用", |
| | | // 中文标识符 |
| | | "电子秤": "电子秤", |
| | | "智能枪": "智能枪", |
| | | "智能阀": "智能阀", |
| | | "艾信盒子": "艾信盒子", |
| | | "安全用气App": "安全用气App", |
| | | "易配送App": "易配送App", |
| | | "艾信助手App": "艾信助手App", |
| | | "艾信LPG小程序": "艾信LPG小程序", |
| | | "艾信发货小程序": "艾信发货小程序", |
| | | "无": "通用", |
| | | "综合": "通用", |
| | | } |
| | | |
| | | pDir := platformDir[platform] |
| | | if pDir == "" { |
| | | pDir = "通用" |
| | | } |
| | | |
| | | dDir := deviceDir[device] |
| | | if dDir == "" { |
| | | dDir = "通用" |
| | | } |
| | | |
| | | return filepath.Join(pDir, dDir) |
| | | } |
| | | |
| | | // SaveMigrationPlan 保存迁移计划 |
| | | func SaveMigrationPlan(vaultPath string, plan *MigrationPlan) error { |
| | | path := filepath.Join(vaultPath, "migration-plan.json") |
| | | data, err := json.MarshalIndent(plan, "", " ") |
| | | if err != nil { |
| | | return err |
| | | } |
| | | |
| | | return os.WriteFile(path, data, 0644) |
| | | } |
| | | |
| | | // ExecuteMigration 执行迁移 |
| | | func ExecuteMigration(vaultPath string, plan *MigrationPlan) error { |
| | | for i, migration := range plan.Migrations { |
| | | fmt.Printf("[%d/%d] 移动 %s -> %s\n", i+1, len(plan.Migrations), migration.Source, migration.Target) |
| | | |
| | | sourcePath := filepath.Join(vaultPath, migration.Source) |
| | | targetPath := filepath.Join(vaultPath, migration.Target) |
| | | |
| | | // 检查源文件是否存在 |
| | | if _, err := os.Stat(sourcePath); os.IsNotExist(err) { |
| | | fmt.Printf(" 跳过(源文件不存在)\n") |
| | | continue |
| | | } |
| | | |
| | | // 创建目标目录 |
| | | targetDir := filepath.Dir(targetPath) |
| | | if err := os.MkdirAll(targetDir, 0755); err != nil { |
| | | return fmt.Errorf("创建目录失败: %w", err) |
| | | } |
| | | |
| | | // 使用 git mv 保留历史 |
| | | cmd := exec.Command("git", "mv", sourcePath, targetPath) |
| | | cmd.Dir = vaultPath |
| | | if output, err := cmd.CombinedOutput(); err != nil { |
| | | return fmt.Errorf("git mv 失败: %w\n%s", err, output) |
| | | } |
| | | } |
| | | |
| | | 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"` |
| | | 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"` |
| | | } |
| | | |
| | | // UnmarshalJSON 自定义反序列化,兼容 week 字段为数字或字符串 |
| | | func (c *Counter) UnmarshalJSON(data []byte) error { |
| | | type Alias Counter |
| | | aux := &struct { |
| | | Week interface{} `json:"week"` |
| | | *Alias |
| | | }{ |
| | | Alias: (*Alias)(c), |
| | | } |
| | | if err := json.Unmarshal(data, aux); err != nil { |
| | | return err |
| | | } |
| | | switch v := aux.Week.(type) { |
| | | case float64: |
| | | c.Week = fmt.Sprintf("%d", int(v)) |
| | | case string: |
| | | c.Week = v |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | // 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) |
| | | } |
| | | } else { |
| | | // 没有候选文档时,生成默认的 merge.md |
| | | if err := i.writeDefaultMergeHint(draftDir); 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 作为关键词搜索,使用 OR 逻辑(任意一个匹配即可) |
| | | // 这样即使某些 tag 匹配不到,其他 tag 也能找到结果 |
| | | opts := search.SearchOptions{ |
| | | TopN: 3, |
| | | } |
| | | |
| | | results, err := search.Search(i.store, tags, opts) |
| | | if err != nil { |
| | | // 如果搜索失败,尝试逐个 tag 搜索,返回第一个有结果的 |
| | | for _, tag := range tags { |
| | | singleResults, singleErr := search.Search(i.store, []string{tag}, opts) |
| | | if singleErr == nil && len(singleResults) > 0 { |
| | | results = singleResults |
| | | break |
| | | } |
| | | } |
| | | if len(results) == 0 { |
| | | return nil, nil // 所有 tag 都搜索失败,返回空而不是错误 |
| | | } |
| | | } |
| | | |
| | | 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 获取周数(返回纯数字周数,与 counter.json 格式一致) |
| | | func (i *Intake) getWeekNumber(t time.Time) string { |
| | | _, week := t.ISOWeek() |
| | | return fmt.Sprintf("%d", 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", |
| | | 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)) |
| | | 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) |
| | | } |
| | | |
| | | // writeDefaultMergeHint 写入默认的 merge.md(当没有候选文档时) |
| | | func (i *Intake) writeDefaultMergeHint(draftDir string) 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("confidence: \"low\"\n") |
| | | sb.WriteString("---\n\n") |
| | | |
| | | sb.WriteString("## 合并指示\n\n") |
| | | sb.WriteString("**操作类型**: new\n") |
| | | sb.WriteString("**目标**: 新建独立文档\n") |
| | | sb.WriteString("**理由**: 未找到相关文档,建议新建\n\n") |
| | | |
| | | sb.WriteString("## 依据\n\n") |
| | | sb.WriteString("### 搜索到的相关文档\n\n") |
| | | sb.WriteString("未找到相关文档。\n\n") |
| | | |
| | | sb.WriteString("## 建议\n\n") |
| | | sb.WriteString("审阅时请根据草稿内容确定合适的分类和存放位置。\n") |
| | | |
| | | 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) |
| | | } |
| | |
| | | package graph |
| | | |
| | | import ( |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | |
| | | } |
| | | // 匹配文件名(不含扩展名和编号前缀) |
| | | // 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md" |
| | | return false // 简化版,后续可扩展 |
| | | base := filepath.Base(node.Path) |
| | | base = strings.TrimSuffix(base, ".md") |
| | | // 去掉编号前缀(如 "002-") |
| | | if idx := strings.Index(base, "-"); idx >= 0 { |
| | | base = base[idx+1:] |
| | | } |
| | | return base == link || node.Title == link |
| | | } |
| | |
| | | import ( |
| | | "database/sql" |
| | | "encoding/json" |
| | | "strings" |
| | | "sort" |
| | | ) |
| | | |
| | | // FTSResult 全文搜索结果 |
| | | type FTSResult struct { |
| | | ID int64 |
| | |
| | | title, |
| | | content, |
| | | tags, |
| | | entities, |
| | | content='nodes', |
| | | content_rowid='id' |
| | | entities |
| | | ) |
| | | `) |
| | | return err |
| | |
| | | return nil, nil |
| | | } |
| | | |
| | | // 构建 FTS5 查询 |
| | | query := strings.Join(keywords, " OR ") |
| | | // 对每个关键词单独搜索,然后合并结果(去重) |
| | | // 这样即使某个关键词匹配不到,其他关键词也能找到结果 |
| | | seen := make(map[int64]bool) |
| | | var allResults []FTSResult |
| | | |
| | | rows, err := s.db.Query(` |
| | | SELECT n.id, n.path, n.title, n.section, fts.rank |
| | | FROM nodes_fts fts |
| | | JOIN nodes n ON n.id = fts.rowid |
| | | WHERE nodes_fts MATCH ? |
| | | ORDER BY fts.rank |
| | | LIMIT ? |
| | | `, query, limit) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var results []FTSResult |
| | | for rows.Next() { |
| | | var r FTSResult |
| | | if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil { |
| | | return nil, err |
| | | for _, kw := range keywords { |
| | | rows, err := s.db.Query(` |
| | | SELECT n.id, n.path, n.title, n.section, fts.rank |
| | | FROM nodes_fts fts |
| | | JOIN nodes n ON n.id = fts.rowid |
| | | WHERE nodes_fts MATCH ? |
| | | ORDER BY fts.rank |
| | | LIMIT ? |
| | | `, kw, limit) |
| | | if err != nil { |
| | | // 单个关键词搜索失败,跳过继续 |
| | | continue |
| | | } |
| | | results = append(results, r) |
| | | |
| | | for rows.Next() { |
| | | var r FTSResult |
| | | if err := rows.Scan(&r.ID, &r.Path, &r.Title, &r.Section, &r.Rank); err != nil { |
| | | rows.Close() |
| | | continue |
| | | } |
| | | // 去重 |
| | | if !seen[r.ID] { |
| | | seen[r.ID] = true |
| | | allResults = append(allResults, r) |
| | | } |
| | | } |
| | | rows.Close() |
| | | } |
| | | return results, nil |
| | | |
| | | // 按 rank 排序(FTS5 的 rank 越小越好) |
| | | sort.Slice(allResults, func(i, j int) bool { |
| | | return allResults[i].Rank < allResults[j].Rank |
| | | }) |
| | | |
| | | // 限制返回数量 |
| | | if limit > 0 && len(allResults) > limit { |
| | | allResults = allResults[:limit] |
| | | } |
| | | |
| | | return allResults, nil |
| | | } |
| | | |
| | | // GetNodeContent 获取节点内容 |
| | |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "sort" |
| | | "strings" |
| | | |
| | | _ "github.com/mattn/go-sqlite3" |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | |
| | | err := s.db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&count) |
| | | return count, err |
| | | } |
| | | |
| | | // GetNodeLinks 获取节点的关联链接(wikilink 目标) |
| | | func (s *Store) GetNodeLinks(nodeID int64) ([]string, error) { |
| | | query := ` |
| | | SELECT n.path |
| | | FROM edges e |
| | | JOIN nodes n ON n.id = e.to_node |
| | | WHERE e.from_node = ? AND e.relation = 'wikilink' |
| | | ` |
| | | rows, err := s.db.Query(query, nodeID) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询链接失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var links []string |
| | | for rows.Next() { |
| | | var path string |
| | | if err := rows.Scan(&path); err != nil { |
| | | return nil, fmt.Errorf("扫描链接失败: %w", err) |
| | | } |
| | | links = append(links, path) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历链接失败: %w", err) |
| | | } |
| | | |
| | | return links, nil |
| | | } |
| | | |
| | | // NodeInfo 节点基本信息(用于 GC) |
| | | type NodeInfo struct { |
| | | ID int64 |
| | | Path string |
| | | } |
| | | |
| | | // GetAllNodes 获取所有节点(用于 GC 检查) |
| | | func (s *Store) GetAllNodes() ([]NodeInfo, error) { |
| | | rows, err := s.db.Query("SELECT id, path FROM nodes") |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var nodes []NodeInfo |
| | | for rows.Next() { |
| | | var n NodeInfo |
| | | if err := rows.Scan(&n.ID, &n.Path); err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | nodes = append(nodes, n) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历节点失败: %w", err) |
| | | } |
| | | |
| | | return nodes, nil |
| | | } |
| | | |
| | | // DeleteNodesByPaths 删除指定路径的节点及其关联边 |
| | | func (s *Store) DeleteNodesByPaths(paths []string) (int, error) { |
| | | if len(paths) == 0 { |
| | | return 0, nil |
| | | } |
| | | |
| | | // 构建 IN 子句 |
| | | placeholders := make([]string, len(paths)) |
| | | args := make([]interface{}, len(paths)) |
| | | for i, path := range paths { |
| | | placeholders[i] = "?" |
| | | args[i] = path |
| | | } |
| | | |
| | | tx, err := s.db.Begin() |
| | | if err != nil { |
| | | return 0, fmt.Errorf("开始事务失败: %w", err) |
| | | } |
| | | |
| | | // 先删除关联边 |
| | | query := fmt.Sprintf(` |
| | | DELETE FROM edges |
| | | WHERE from_node IN (SELECT id FROM nodes WHERE path IN (%s)) |
| | | OR to_node IN (SELECT id FROM nodes WHERE path IN (%s)) |
| | | `, strings.Join(placeholders, ","), strings.Join(placeholders, ",")) |
| | | |
| | | // 参数需要重复两次 |
| | | allArgs := append(args, args...) |
| | | _, err = tx.Exec(query, allArgs...) |
| | | if err != nil { |
| | | tx.Rollback() |
| | | return 0, fmt.Errorf("删除边失败: %w", err) |
| | | } |
| | | |
| | | // 删除节点 |
| | | query = fmt.Sprintf("DELETE FROM nodes WHERE path IN (%s)", strings.Join(placeholders, ",")) |
| | | result, err := tx.Exec(query, args...) |
| | | if err != nil { |
| | | tx.Rollback() |
| | | return 0, fmt.Errorf("删除节点失败: %w", err) |
| | | } |
| | | |
| | | if err := tx.Commit(); err != nil { |
| | | return 0, fmt.Errorf("提交事务失败: %w", err) |
| | | } |
| | | |
| | | affected, _ := result.RowsAffected() |
| | | return int(affected), nil |
| | | } |
| | | |
| | | // GetRelationStats 获取关系类型分布统计 |
| | | func (s *Store) GetRelationStats() (map[string]int, error) { |
| | | rows, err := s.db.Query(` |
| | | SELECT relation, COUNT(*) as count |
| | | FROM edges |
| | | GROUP BY relation |
| | | ORDER BY count DESC |
| | | `) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询关系统计失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | stats := make(map[string]int) |
| | | for rows.Next() { |
| | | var relation string |
| | | var count int |
| | | if err := rows.Scan(&relation, &count); err != nil { |
| | | return nil, fmt.Errorf("扫描关系统计失败: %w", err) |
| | | } |
| | | stats[relation] = count |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历关系统计失败: %w", err) |
| | | } |
| | | |
| | | return stats, nil |
| | | } |
| | | |
| | | // FindNodesByKeyword 根据关键词查找节点(模糊匹配标题、路径、标签、实体) |
| | | func (s *Store) FindNodesByKeyword(keyword string) ([]*graph.Node, error) { |
| | | keyword = "%" + keyword + "%" |
| | | query := ` |
| | | SELECT id, path, title, section, tags, entities, wikilinks, content_fts |
| | | FROM nodes |
| | | WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ? |
| | | LIMIT 20 |
| | | ` |
| | | rows, err := s.db.Query(query, keyword, keyword, keyword, keyword) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var nodes []*graph.Node |
| | | for rows.Next() { |
| | | var n graph.Node |
| | | var tagsJSON, entitiesJSON, wikilinksJSON string |
| | | var content sql.NullString |
| | | |
| | | err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, |
| | | &tagsJSON, &entitiesJSON, &wikilinksJSON, &content) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | |
| | | json.Unmarshal([]byte(tagsJSON), &n.Tags) |
| | | json.Unmarshal([]byte(entitiesJSON), &n.Entities) |
| | | json.Unmarshal([]byte(wikilinksJSON), &n.Wikilinks) |
| | | if content.Valid { |
| | | n.Content = content.String |
| | | } |
| | | |
| | | nodes = append(nodes, &n) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历节点失败: %w", err) |
| | | } |
| | | |
| | | return nodes, nil |
| | | } |
| | | |
| | | // GetNodeEdges 获取节点的关联边(支持关系类型过滤和深度查询) |
| | | func (s *Store) GetNodeEdges(nodeID int64, relation string, depth int) ([]*graph.Edge, error) { |
| | | if depth < 1 { |
| | | depth = 1 |
| | | } |
| | | if depth > 3 { |
| | | depth = 3 |
| | | } |
| | | |
| | | var edges []*graph.Edge |
| | | visited := make(map[int64]bool) |
| | | currentLevel := []int64{nodeID} |
| | | |
| | | for d := 0; d < depth; d++ { |
| | | var nextLevel []int64 |
| | | |
| | | for _, id := range currentLevel { |
| | | if visited[id] && d > 0 { |
| | | continue |
| | | } |
| | | visited[id] = true |
| | | |
| | | query := ` |
| | | SELECT id, from_node, to_node, relation, label |
| | | FROM edges |
| | | WHERE from_node = ? |
| | | ` |
| | | args := []interface{}{id} |
| | | |
| | | if relation != "" { |
| | | query += " AND relation = ?" |
| | | args = append(args, relation) |
| | | } |
| | | |
| | | rows, err := s.db.Query(query, args...) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询边失败: %w", err) |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var e graph.Edge |
| | | var edgeID int64 |
| | | if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil { |
| | | rows.Close() |
| | | return nil, fmt.Errorf("扫描边失败: %w", err) |
| | | } |
| | | edges = append(edges, &e) |
| | | nextLevel = append(nextLevel, e.ToNode) |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | currentLevel = nextLevel |
| | | } |
| | | |
| | | return edges, nil |
| | | } |
| | | |
| | | // FindRelatedNodes 查找与关键词相关的所有节点(通过共同标签、实体、wikilink) |
| | | func (s *Store) FindRelatedNodes(keyword string, topN int) ([]RelatedNode, error) { |
| | | // 先找到匹配关键词的节点 |
| | | keyword = "%" + keyword + "%" |
| | | query := ` |
| | | SELECT id, path, title, section, tags, entities |
| | | FROM nodes |
| | | WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ? |
| | | LIMIT 5 |
| | | ` |
| | | rows, err := s.db.Query(query, keyword, keyword, keyword, keyword) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var seedNodes []*graph.Node |
| | | for rows.Next() { |
| | | var n graph.Node |
| | | var tagsJSON, entitiesJSON string |
| | | err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, &tagsJSON, &entitiesJSON) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | json.Unmarshal([]byte(tagsJSON), &n.Tags) |
| | | json.Unmarshal([]byte(entitiesJSON), &n.Entities) |
| | | seedNodes = append(seedNodes, &n) |
| | | } |
| | | |
| | | if len(seedNodes) == 0 { |
| | | return []RelatedNode{}, nil |
| | | } |
| | | |
| | | // 收集种子节点的标签和实体 |
| | | tagSet := make(map[string]bool) |
| | | entitySet := make(map[string]bool) |
| | | for _, n := range seedNodes { |
| | | for _, tag := range n.Tags { |
| | | tagSet[tag] = true |
| | | } |
| | | for _, entity := range n.Entities { |
| | | entitySet[entity] = true |
| | | } |
| | | } |
| | | |
| | | // 查找共享标签或实体的节点 |
| | | relevanceMap := make(map[int64]int) |
| | | pathMap := make(map[int64]string) |
| | | titleMap := make(map[int64]string) |
| | | sectionMap := make(map[int64]string) |
| | | tagsMap := make(map[int64][]string) |
| | | |
| | | // 通过标签查找 |
| | | for tag := range tagSet { |
| | | tagPattern := "%" + tag + "%" |
| | | rows, err := s.db.Query(` |
| | | SELECT id, path, title, section, tags |
| | | FROM nodes |
| | | WHERE tags LIKE ? |
| | | `, tagPattern) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var id int64 |
| | | var path, title, section, tagsJSON string |
| | | if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil { |
| | | rows.Close() |
| | | continue |
| | | } |
| | | relevanceMap[id]++ |
| | | pathMap[id] = path |
| | | titleMap[id] = title |
| | | sectionMap[id] = section |
| | | var tags []string |
| | | json.Unmarshal([]byte(tagsJSON), &tags) |
| | | tagsMap[id] = tags |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | // 通过实体查找 |
| | | for entity := range entitySet { |
| | | entityPattern := "%" + entity + "%" |
| | | rows, err := s.db.Query(` |
| | | SELECT id, path, title, section, tags |
| | | FROM nodes |
| | | WHERE entities LIKE ? |
| | | `, entityPattern) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var id int64 |
| | | var path, title, section, tagsJSON string |
| | | if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil { |
| | | rows.Close() |
| | | continue |
| | | } |
| | | relevanceMap[id]++ |
| | | pathMap[id] = path |
| | | titleMap[id] = title |
| | | sectionMap[id] = section |
| | | var tags []string |
| | | json.Unmarshal([]byte(tagsJSON), &tags) |
| | | tagsMap[id] = tags |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | // 转换为结果列表 |
| | | var results []RelatedNode |
| | | for id, relevance := range relevanceMap { |
| | | results = append(results, RelatedNode{ |
| | | ID: id, |
| | | Path: pathMap[id], |
| | | Title: titleMap[id], |
| | | Section: sectionMap[id], |
| | | Tags: tagsMap[id], |
| | | Relevance: relevance, |
| | | }) |
| | | } |
| | | |
| | | // 按相关度排序 |
| | | sort.Slice(results, func(i, j int) bool { |
| | | return results[i].Relevance > results[j].Relevance |
| | | }) |
| | | |
| | | // 限制返回数量 |
| | | if topN > 0 && len(results) > topN { |
| | | results = results[:topN] |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | |
| | | // RelatedNode 相关节点 |
| | | type RelatedNode struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Tags []string `json:"tags"` |
| | | Relevance int `json:"relevance"` |
| | | } |
| | |
| | | t.Errorf("node count = %d, want 1", count) |
| | | } |
| | | } |
| | | |
| | | func TestGetNodeLinks(t *testing.T) { |
| | | tmpDir := t.TempDir() |
| | | dbPath := filepath.Join(tmpDir, "test.db") |
| | | |
| | | store, err := Open(dbPath) |
| | | if err != nil { |
| | | t.Fatalf("Open failed: %v", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 插入测试节点 |
| | | node1 := &graph.Node{ |
| | | Path: "FAQ/001-测试.md", |
| | | Title: "测试文档", |
| | | Section: "FAQ", |
| | | Content: "# 测试\n\n内容", |
| | | } |
| | | node2 := &graph.Node{ |
| | | Path: "FAQ/002-相关.md", |
| | | Title: "相关文档", |
| | | Section: "FAQ", |
| | | Content: "# 相关\n\n内容", |
| | | } |
| | | |
| | | id1, err := store.InsertNode(node1) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode node1 failed: %v", err) |
| | | } |
| | | |
| | | id2, err := store.InsertNode(node2) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode node2 failed: %v", err) |
| | | } |
| | | |
| | | // 插入 wikilink 边 |
| | | edge := &graph.Edge{ |
| | | FromNode: id1, |
| | | ToNode: id2, |
| | | Relation: "wikilink", |
| | | Label: "相关文档", |
| | | } |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | t.Fatalf("InsertEdge failed: %v", err) |
| | | } |
| | | |
| | | // 测试获取链接 |
| | | links, err := store.GetNodeLinks(id1) |
| | | if err != nil { |
| | | t.Fatalf("GetNodeLinks failed: %v", err) |
| | | } |
| | | if len(links) != 1 { |
| | | t.Errorf("links count = %d, want 1", len(links)) |
| | | } |
| | | if len(links) > 0 && links[0] != "FAQ/002-相关.md" { |
| | | t.Errorf("links[0] = %q, want %q", links[0], "FAQ/002-相关.md") |
| | | } |
| | | |
| | | // 测试无链接的节点 |
| | | links, err = store.GetNodeLinks(id2) |
| | | if err != nil { |
| | | t.Fatalf("GetNodeLinks for node2 failed: %v", err) |
| | | } |
| | | if len(links) != 0 { |
| | | t.Errorf("links count = %d, want 0", len(links)) |
| | | } |
| | | } |
| 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 |
| | | } |
| | | |
| | | // ClassifyDocuments 分类文档 |
| | | func (c *Client) ClassifyDocuments(prompt string) (string, error) { |
| | | response, err := c.callLLMWithRetry(prompt) |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | |
| | | // 解析 JSON |
| | | var result []struct { |
| | | Path string `json:"path"` |
| | | Platform string `json:"platform"` |
| | | Device string `json:"device"` |
| | | ContentType string `json:"content_type"` |
| | | Confidence float64 `json:"confidence"` |
| | | } |
| | | |
| | | if err := parseJSON(response, &result); err != nil { |
| | | return "", fmt.Errorf("解析 LLM 响应失败: %w", err) |
| | | } |
| | | |
| | | // 转换回 JSON 字符串 |
| | | jsonBytes, err := json.Marshal(result) |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | |
| | | return string(jsonBytes), nil |
| | | } |
| | | |
| | | // ClassifyDocuments 分类文档 |
| | | |
| | | // 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 |
| | | } |
| | |
| | | title = title[:18] + ".." |
| | | } |
| | | sb.WriteString(fmt.Sprintf("%-50s %-20s %-8s %d\n", r.Path, title, r.Section, r.Score)) |
| | | |
| | | // 输出内容(如果有) |
| | | if r.Content != "" { |
| | | sb.WriteString("\n--- 内容 ---\n") |
| | | sb.WriteString(r.Content) |
| | | sb.WriteString("\n") |
| | | } |
| | | |
| | | // 输出关联文档(如果有) |
| | | if len(r.Links) > 0 { |
| | | sb.WriteString("\n--- 关联文档 ---\n") |
| | | for _, link := range r.Links { |
| | | sb.WriteString(fmt.Sprintf("- %s\n", link)) |
| | | } |
| | | } |
| | | } |
| | | |
| | | sb.WriteString(fmt.Sprintf("\n共 %d 条结果\n", len(results))) |
| | |
| | | |
| | | // SearchOptions 搜索选项 |
| | | type SearchOptions struct { |
| | | Expanded []string // 扩展词 |
| | | Symptom []string // 症状词 |
| | | TopN int // 返回前 N 条 |
| | | Expanded []string // 扩展词 |
| | | Symptom []string // 症状词 |
| | | TopN int // 返回前 N 条 |
| | | WithContent bool // 返回完整文件内容 |
| | | WithLinks bool // 返回关联文档链接 |
| | | } |
| | | |
| | | // SearchResult 搜索结果 |
| | | type SearchResult struct { |
| | | ID int64 |
| | | Path string |
| | | Title string |
| | | Section string |
| | | Score int |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Score int `json:"score"` |
| | | Content string `json:"content,omitempty"` // 文件内容(WithContent=true 时填充) |
| | | Links []string `json:"links,omitempty"` // 关联文档路径(WithLinks=true 时填充) |
| | | } |
| | | |
| | | // Search 执行搜索 |
| | |
| | | results = results[:opts.TopN] |
| | | } |
| | | |
| | | // 获取内容(如果请求) |
| | | if opts.WithContent { |
| | | for i := range results { |
| | | content, _, _, err := store.GetNodeContent(results[i].ID) |
| | | if err == nil { |
| | | results[i].Content = content |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 获取关联链接(如果请求) |
| | | if opts.WithLinks { |
| | | for i := range results { |
| | | links, err := store.GetNodeLinks(results[i].ID) |
| | | if err == nil { |
| | | results[i].Links = links |
| | | } |
| | | } |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | |
| | |
| | | "可以", "需要", "应该", "是否", "有没有", |
| | | } |
| | | |
| | | // EntityWords 实体词列表(进一步降低权重,因为这些词会匹配大量文件) |
| | | var EntityWords = []string{ |
| | | "智能枪", "电子秤", "智能阀", "艾信盒子", |
| | | "电子秤平台", "运营管理平台", "易配送", "lpg", |
| | | "安全用气", "艾信助手", "艾信发货", |
| | | } |
| | | |
| | | // IsGenericWord 判断是否为通用词 |
| | | func IsGenericWord(word string) bool { |
| | | word = strings.ToLower(word) |
| | | for _, g := range GenericWords { |
| | | if word == g { |
| | | return true |
| | | } |
| | | } |
| | | return false |
| | | } |
| | | |
| | | // IsEntityWord 判断是否为实体词 |
| | | func IsEntityWord(word string) bool { |
| | | wordLower := strings.ToLower(word) |
| | | for _, e := range EntityWords { |
| | | if wordLower == strings.ToLower(e) { |
| | | return true |
| | | } |
| | | } |
| | |
| | | if base == 0 { |
| | | return 0 |
| | | } |
| | | // 实体词降权(匹配太多文件),但保证至少 1 分 |
| | | if IsEntityWord(keyword) { |
| | | score := base / 5 |
| | | if score < 1 { |
| | | score = 1 |
| | | } |
| | | return score |
| | | } |
| | | // 通用词降权,但保证至少 1 分 |
| | | if IsGenericWord(keyword) { |
| | | return base / 10 // 通用词降权 |
| | | score := base / 3 |
| | | if score < 1 { |
| | | score = 1 |
| | | } |
| | | return score |
| | | } |
| | | return base |
| | | } |
| | |
| | | if strings.HasPrefix(info.Name(), ".") && info.Name() != "." { |
| | | return filepath.SkipDir |
| | | } |
| | | // 跳过待审阅目录 |
| | | if info.Name() == "待审阅" { |
| | | return filepath.SkipDir |
| | | } |
| | | return nil |
| | | } |
| | | if !strings.HasSuffix(path, ".md") { |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | 根据 classification.json 更新所有文档的 frontmatter |
| | | """ |
| | | |
| | | import json |
| | | import os |
| | | import re |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | def load_classification(vault_path): |
| | | """加载分类结果""" |
| | | with open(os.path.join(vault_path, 'classification.json'), 'r', encoding='utf-8') as f: |
| | | return json.load(f) |
| | | |
| | | def load_migration_plan(vault_path): |
| | | """加载迁移计划""" |
| | | with open(os.path.join(vault_path, 'migration-plan.json'), 'r', encoding='utf-8') as f: |
| | | return json.load(f) |
| | | |
| | | def update_frontmatter(file_path, platform, device): |
| | | """更新文件的 frontmatter""" |
| | | with open(file_path, 'r', encoding='utf-8') as f: |
| | | content = f.read() |
| | | |
| | | # 检查是否有 frontmatter |
| | | if not content.startswith('---'): |
| | | # 添加新的 frontmatter |
| | | new_content = f'---\nplatform: {platform}\ndevice: {device}\n---\n\n{content}' |
| | | with open(file_path, 'w', encoding='utf-8') as f: |
| | | f.write(new_content) |
| | | return True |
| | | |
| | | # 解析现有 frontmatter |
| | | parts = content.split('---', 2) |
| | | if len(parts) < 3: |
| | | return False |
| | | |
| | | frontmatter = parts[1] |
| | | body = parts[2] |
| | | |
| | | # 更新或添加 platform |
| | | if 'platform:' in frontmatter: |
| | | frontmatter = re.sub(r'platform:.*', f'platform: {platform}', frontmatter) |
| | | else: |
| | | frontmatter += f'\nplatform: {platform}' |
| | | |
| | | # 更新或添加 device |
| | | if 'device:' in frontmatter: |
| | | frontmatter = re.sub(r'device:.*', f'device: {device}', frontmatter) |
| | | else: |
| | | frontmatter += f'\ndevice: {device}' |
| | | |
| | | # 写回文件 |
| | | new_content = f'---{frontmatter}---{body}' |
| | | with open(file_path, 'w', encoding='utf-8') as f: |
| | | f.write(new_content) |
| | | |
| | | return True |
| | | |
| | | def main(): |
| | | vault_path = sys.argv[1] if len(sys.argv) > 1 else '.' |
| | | |
| | | # 加载分类结果和迁移计划 |
| | | classification = load_classification(vault_path) |
| | | migration_plan = load_migration_plan(vault_path) |
| | | |
| | | # 创建源路径到分类的映射 |
| | | source_to_class = {} |
| | | for item in classification['items']: |
| | | source_to_class[item['path']] = { |
| | | 'platform': item['platform'], |
| | | 'device': item['device'] |
| | | } |
| | | |
| | | # 更新所有迁移的文件 |
| | | updated = 0 |
| | | skipped = 0 |
| | | |
| | | for migration in migration_plan['migrations']: |
| | | target_path = os.path.join(vault_path, migration['target']) |
| | | source_path = migration['source'] |
| | | |
| | | if not os.path.exists(target_path): |
| | | print(f'跳过(文件不存在): {target_path}') |
| | | skipped += 1 |
| | | continue |
| | | |
| | | # 获取分类信息 |
| | | if source_path not in source_to_class: |
| | | print(f'跳过(无分类信息): {source_path}') |
| | | skipped += 1 |
| | | continue |
| | | |
| | | class_info = source_to_class[source_path] |
| | | |
| | | # 更新 frontmatter |
| | | if update_frontmatter(target_path, class_info['platform'], class_info['device']): |
| | | print(f'已更新: {migration["target"]}') |
| | | updated += 1 |
| | | else: |
| | | print(f'更新失败: {migration["target"]}') |
| | | skipped += 1 |
| | | |
| | | print(f'\n更新完成: {updated} 个文件已更新, {skipped} 个文件跳过') |
| | | |
| | | if __name__ == '__main__': |
| | | main() |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | 根据 migration-plan.json 更新所有文档的 wikilinks |
| | | """ |
| | | |
| | | import json |
| | | import os |
| | | import re |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | def load_migration_plan(vault_path): |
| | | """加载迁移计划""" |
| | | with open(os.path.join(vault_path, 'migration-plan.json'), 'r', encoding='utf-8') as f: |
| | | return json.load(f) |
| | | |
| | | def build_path_mapping(migration_plan): |
| | | """构建路径映射表(旧路径 -> 新路径)""" |
| | | mapping = {} |
| | | for migration in migration_plan['migrations']: |
| | | source = migration['source'] |
| | | target = migration['target'] |
| | | # 移除 .md 扩展名用于匹配 |
| | | source_base = os.path.splitext(source)[0] |
| | | target_base = os.path.splitext(target)[0] |
| | | mapping[source_base] = target_base |
| | | |
| | | # 也添加文件名到完整路径的映射 |
| | | source_name = os.path.basename(source_base) |
| | | mapping[source_name] = target_base |
| | | |
| | | return mapping |
| | | |
| | | def update_wikilinks(file_path, path_mapping): |
| | | """更新文件中的 wikilinks""" |
| | | with open(file_path, 'r', encoding='utf-8') as f: |
| | | content = f.read() |
| | | |
| | | original_content = content |
| | | |
| | | # 查找所有 [[...]] 格式的 wikilinks |
| | | # 支持 [[路径]] 和 [[路径|显示文本]] 格式 |
| | | pattern = r'\[\[([^\]|]+)(\|[^\]]+)?\]\]' |
| | | |
| | | def replace_wikilink(match): |
| | | link_path = match.group(1).strip() |
| | | display_text = match.group(2) or '' |
| | | |
| | | # 尝试在映射中查找 |
| | | if link_path in path_mapping: |
| | | new_path = path_mapping[link_path] |
| | | return f'[[{new_path}{display_text}]]' |
| | | |
| | | # 尝试匹配文件名(不含路径) |
| | | link_name = os.path.basename(link_path) |
| | | if link_name in path_mapping: |
| | | new_path = path_mapping[link_name] |
| | | return f'[[{new_path}{display_text}]]' |
| | | |
| | | # 未找到匹配,保持原样 |
| | | return match.group(0) |
| | | |
| | | content = re.sub(pattern, replace_wikilink, content) |
| | | |
| | | if content != original_content: |
| | | with open(file_path, 'w', encoding='utf-8') as f: |
| | | f.write(content) |
| | | return True |
| | | |
| | | return False |
| | | |
| | | def main(): |
| | | vault_path = sys.argv[1] if len(sys.argv) > 1 else '.' |
| | | |
| | | # 加载迁移计划 |
| | | migration_plan = load_migration_plan(vault_path) |
| | | path_mapping = build_path_mapping(migration_plan) |
| | | |
| | | print(f'已加载 {len(path_mapping)} 个路径映射') |
| | | |
| | | # 更新所有迁移后的文件 |
| | | updated = 0 |
| | | total = 0 |
| | | |
| | | # 遍历新目录结构 |
| | | for platform_dir in ['电子秤平台', '共有硬件', '通用', '第三方平台', '运营管理平台']: |
| | | platform_path = os.path.join(vault_path, platform_dir) |
| | | if not os.path.exists(platform_path): |
| | | continue |
| | | |
| | | for md_file in Path(platform_path).rglob('*.md'): |
| | | total += 1 |
| | | if update_wikilinks(str(md_file), path_mapping): |
| | | print(f'已更新 wikilinks: {md_file.relative_to(vault_path)}') |
| | | updated += 1 |
| | | |
| | | print(f'\n更新完成: {updated}/{total} 个文件的 wikilinks 已更新') |
| | | |
| | | if __name__ == '__main__': |
| | | main() |
| New file |
| | |
| | | #!/bin/bash |
| | | # 集成测试脚本 |
| | | |
| | | set -e |
| | | |
| | | echo "=== kb-cli 集成测试 ===" |
| | | echo |
| | | |
| | | # 清理旧数据 |
| | | echo "1. 清理测试环境..." |
| | | rm -f /tmp/test-kb.db |
| | | export KB_VAULT="/tmp/test-vault" |
| | | rm -rf "$KB_VAULT" |
| | | mkdir -p "$KB_VAULT" |
| | | |
| | | # 创建测试知识库 |
| | | echo "2. 创建测试知识库..." |
| | | cat > "$KB_VAULT/001-测试文档.md" << 'EOF' |
| | | --- |
| | | tags: [充装, 规格] |
| | | entities: [智能枪, 电子秤] |
| | | --- |
| | | |
| | | # 充装规格配置 |
| | | |
| | | 本配置适用于 [[002-常见问题]] 中提到的场景。 |
| | | |
| | | ## 充装参数 |
| | | |
| | | - YSP-12: 净重 5kg |
| | | - YSP-35: 净重 15kg |
| | | EOF |
| | | |
| | | cat > "$KB_VAULT/002-常见问题.md" << 'EOF' |
| | | --- |
| | | tags: [故障, 诊断] |
| | | entities: [充装枪] |
| | | --- |
| | | |
| | | # 常见问题 |
| | | |
| | | ## 充装失败 |
| | | |
| | | 可能原因: |
| | | 1. 智能枪未校准 |
| | | 2. 电子秤异常 |
| | | EOF |
| | | |
| | | # 构建索引 |
| | | echo "3. 构建索引..." |
| | | go run -tags fts5 main.go index build --vault "$KB_VAULT" --db /tmp/test-kb.db |
| | | |
| | | # 测试搜索 |
| | | echo |
| | | echo "4. 测试搜索功能..." |
| | | echo " 搜索关键词:充装" |
| | | go run -tags fts5 main.go search 充装 --vault "$KB_VAULT" --db /tmp/test-kb.db --top 5 |
| | | |
| | | echo |
| | | echo " 搜索关键词:智能枪" |
| | | go run -tags fts5 main.go search 智能枪 --vault "$KB_VAULT" --db /tmp/test-kb.db --top 5 |
| | | |
| | | echo |
| | | echo " JSON 格式输出:" |
| | | go run -tags fts5 main.go search 规格 --vault "$KB_VAULT" --db /tmp/test-kb.db --json |
| | | |
| | | # 查看索引状态 |
| | | echo |
| | | echo "5. 查看索引状态..." |
| | | go run -tags fts5 main.go index status --vault "$KB_VAULT" --db /tmp/test-kb.db |
| | | |
| | | echo |
| | | echo "=== 集成测试完成 ===" |