09c2a03cefa687832257d3b816da50db3cfcc0f2..ac966af3d126d59081d119e05ba02f946257f669
9 days ago ai_xiaopei
fix: 移除所有help中的等号格式
ac966a diff | tree
9 days ago ai_xiaopei
fix: 修正全局help参数格式,移除=号
6023f6 diff | tree
9 days ago ai_xiaopei
feat: 新增classify和reorg命令,修复help格式
4cd549 diff | tree
9 days ago ai_xiaopei
feat: 添加 classify 命令和实体提取功能
1ba86b diff | tree
9 days ago ai_xiaopei
fix: 参数不足时显示 help 后添加空行
54adcc diff | tree
9 days ago ai_xiaopei
fix: 参数不足时显示对应命令的 help
f83abc diff | tree
9 days ago ai_xiaopei
fix: 修复草稿创建时知识库搜索失败问题
9c9802 diff | tree
9 days ago ai_xiaopei
feat: 添加草稿创建功能
cf162a diff | tree
9 days ago ai_xiaopei
fix: 使用 Makefile 编译确保 FTS5 支持
b79475 diff | tree
9 days ago ai_xiaopei
feat: add graph command for knowledge graph queries
6b58a8 diff | tree
9 days ago ai_xiaopei
refactor: help 格式对齐 axin-cli 风格
495bfc diff | tree
9 days ago ai_xiaopei
docs: 为所有命令添加详细参数说明
4bb231 diff | tree
9 days ago ai_xiaopei
docs: 更新 README.md 中的命令为 kb-cli
3f880f diff | tree
9 days ago ai_xiaopei
refactor: 统一命令名为 kb-cli
c47c84 diff | tree
9 days ago ai_xiaopei
refactor: 统一命令名称为 kb-cli
93f4e3 diff | tree
9 days ago ai_xiaopei
fix: 统一编译输出为 kb-cli
2ef3df diff | tree
9 days ago ai_xiaopei
feat: add index gc and git sync commands
11aa9a diff | tree
9 days ago ai_xiaopei
feat: add git sync command
6078b8 diff | tree
9 days ago ai_xiaopei
feat: add index gc command for cleaning orphan nodes
8de9d0 diff | tree
9 days ago ai_xiaopei
docs: update README with new features
30b6be diff | tree
9 days ago ai_xiaopei
feat: add --with-content and --with-links flags
ec47bc diff | tree
9 days ago ai_xiaopei
Task 4: add CLI flags for with-content and with-links
46a01d diff | tree
9 days ago ai_xiaopei
docs: add kb-cli enhancement implementation plan
3d3216 diff | tree
9 days ago ai_xiaopei
docs: add kb-cli enhancement design spec
dc838c diff | tree
10 files modified
21 files added
5649 ■■■■■ changed files
Makefile 32 ●●●●● patch | view | raw | blame | history
README.md 150 ●●●●● patch | view | raw | blame | history
bin/kb-cli patch | view | raw | blame | history
cmd/classify.go 99 ●●●●● patch | view | raw | blame | history
cmd/draft.go 119 ●●●●● patch | view | raw | blame | history
cmd/graph.go 240 ●●●●● patch | view | raw | blame | history
cmd/index.go 249 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go 84 ●●●●● patch | view | raw | blame | history
cmd/reorg.go 71 ●●●●● patch | view | raw | blame | history
cmd/root.go 71 ●●●●● patch | view | raw | blame | history
cmd/search.go 104 ●●●●● patch | view | raw | blame | history
docs/superpowers/plans/2026-07-26-kb-cli-enhancement.md 1047 ●●●●● patch | view | raw | blame | history
docs/superpowers/specs/2026-07-26-kb-cli-draft-integration-design.md 414 ●●●●● patch | view | raw | blame | history
docs/superpowers/specs/2026-07-26-kb-cli-enhancement-design.md 441 ●●●●● patch | view | raw | blame | history
internal/classify/entities.go 164 ●●●●● patch | view | raw | blame | history
internal/classify/llm.go 301 ●●●●● patch | view | raw | blame | history
internal/classify/reorg.go 173 ●●●●● patch | view | raw | blame | history
internal/draft/intake.go 569 ●●●●● patch | view | raw | blame | history
internal/graph/builder.go 11 ●●●●● patch | view | raw | blame | history
internal/index/fts.go 71 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 392 ●●●●● patch | view | raw | blame | history
internal/index/sqlite_test.go 67 ●●●●● patch | view | raw | blame | history
internal/llm/client.go 406 ●●●●● patch | view | raw | blame | history
internal/output/formatter.go 15 ●●●●● patch | view | raw | blame | history
internal/search/engine.go 40 ●●●● patch | view | raw | blame | history
internal/search/scorer.go 33 ●●●●● patch | view | raw | blame | history
internal/vault/scanner.go 4 ●●●● patch | view | raw | blame | history
kb patch | view | raw | blame | history
scripts/update-frontmatter.py 109 ●●●●● patch | view | raw | blame | history
scripts/update-wikilinks.py 100 ●●●●● patch | view | raw | blame | history
test-integration.sh 73 ●●●●● patch | view | raw | blame | history
Makefile
New file
@@ -0,0 +1,32 @@
.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
README.md
New file
@@ -0,0 +1,150 @@
# 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
bin/kb-cli
Binary files differ
cmd/classify.go
New file
@@ -0,0 +1,99 @@
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
}
cmd/draft.go
New file
@@ -0,0 +1,119 @@
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
}
cmd/graph.go
New file
@@ -0,0 +1,240 @@
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"`
}
cmd/index.go
New file
@@ -0,0 +1,249 @@
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
}
cmd/rebuild.go
New file
@@ -0,0 +1,84 @@
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
}
cmd/reorg.go
New file
@@ -0,0 +1,71 @@
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
}
cmd/root.go
@@ -3,6 +3,7 @@
import (
    "fmt"
    "os"
    "strings"
    "github.com/spf13/cobra"
)
@@ -12,16 +13,68 @@
    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)
        }
    }
}
@@ -35,4 +88,12 @@
    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
}
cmd/search.go
New file
@@ -0,0 +1,104 @@
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
}
docs/superpowers/plans/2026-07-26-kb-cli-enhancement.md
New file
@@ -0,0 +1,1047 @@
# 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?**
docs/superpowers/specs/2026-07-26-kb-cli-draft-integration-design.md
New file
@@ -0,0 +1,414 @@
# 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 界面管理草稿和审阅流程
docs/superpowers/specs/2026-07-26-kb-cli-enhancement-design.md
New file
@@ -0,0 +1,441 @@
# 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`
internal/classify/entities.go
New file
@@ -0,0 +1,164 @@
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
}
internal/classify/llm.go
New file
@@ -0,0 +1,301 @@
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)
}
internal/classify/reorg.go
New file
@@ -0,0 +1,173 @@
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
}
internal/draft/intake.go
New file
@@ -0,0 +1,569 @@
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)
}
internal/graph/builder.go
@@ -1,6 +1,9 @@
package graph
import (
    "path/filepath"
    "strings"
    "github.com/aisim/kb-cli/internal/vault"
)
@@ -92,5 +95,11 @@
    }
    // 匹配文件名(不含扩展名和编号前缀)
    // 例如:[[充装规格配置]] 匹配 "知识/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
}
internal/index/fts.go
@@ -3,9 +3,8 @@
import (
    "database/sql"
    "encoding/json"
    "strings"
    "sort"
)
// FTSResult 全文搜索结果
type FTSResult struct {
    ID      int64
@@ -22,9 +21,7 @@
            title,
            content,
            tags,
            entities,
            content='nodes',
            content_rowid='id'
            entities
        )
    `)
    return err
@@ -45,31 +42,51 @@
        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 获取节点内容
internal/index/sqlite.go
@@ -6,6 +6,8 @@
    "fmt"
    "os"
    "path/filepath"
    "sort"
    "strings"
    _ "github.com/mattn/go-sqlite3"
    "github.com/aisim/kb-cli/internal/graph"
@@ -147,3 +149,393 @@
    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, &section, &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, &section, &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"`
}
internal/index/sqlite_test.go
@@ -64,3 +64,70 @@
        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))
    }
}
internal/llm/client.go
New file
@@ -0,0 +1,406 @@
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
}
internal/output/formatter.go
@@ -24,6 +24,21 @@
            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)))
internal/search/engine.go
@@ -9,18 +9,22 @@
// 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 执行搜索
@@ -76,6 +80,26 @@
        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
}
internal/search/scorer.go
@@ -11,11 +11,29 @@
    "可以", "需要", "应该", "是否", "有没有",
}
// 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
        }
    }
@@ -59,8 +77,21 @@
    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
}
internal/vault/scanner.go
@@ -19,6 +19,10 @@
            if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
                return filepath.SkipDir
            }
            // 跳过待审阅目录
            if info.Name() == "待审阅" {
                return filepath.SkipDir
            }
            return nil
        }
        if !strings.HasSuffix(path, ".md") {
kb
Binary files differ
scripts/update-frontmatter.py
New file
@@ -0,0 +1,109 @@
#!/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()
scripts/update-wikilinks.py
New file
@@ -0,0 +1,100 @@
#!/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()
test-integration.sh
New file
@@ -0,0 +1,73 @@
#!/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 "=== 集成测试完成 ==="