ai_xiaopei
9 days ago 46a01d99b5d15120ada95754c9effbdcb576d31e
Task 4: add CLI flags for with-content and with-links
5 files modified
7 files added
651 ■■■■■ changed files
Makefile 32 ●●●●● patch | view | raw | blame | history
README.md 129 ●●●●● patch | view | raw | blame | history
bin/kb patch | view | raw | blame | history
cmd/index.go 117 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go 65 ●●●●● patch | view | raw | blame | history
cmd/search.go 87 ●●●●● patch | view | raw | blame | history
internal/graph/builder.go 11 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 30 ●●●●● patch | view | raw | blame | history
internal/index/sqlite_test.go 67 ●●●●● patch | view | raw | blame | history
internal/search/engine.go 40 ●●●● patch | view | raw | blame | history
kb 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 .
# 安装到 ~/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 search 充装 --top 5
run-index:
    ./bin/kb index build
run-status:
    ./bin/kb index status
README.md
New file
@@ -0,0 +1,129 @@
# kb-cli
知识库 CLI 工具,支持 Obsidian 风格 Markdown 文件的图谱搜索。
## 功能特性
- ✅ **Vault 解析器**:解析 Obsidian frontmatter、标签、实体、wikilinks
- ✅ **知识图谱构建**:文件 → 节点,标签/实体/wikilinks → 边
- ✅ **SQLite 存储**:高效持久化,支持增量更新
- ✅ **FTS5 全文搜索**:基于 SQLite FTS5 的快速搜索
- ✅ **图谱评分算法**:考虑标签、实体、wikilinks 权重
- ✅ **多种输出格式**:表格、JSON
## 安装
```bash
# 编译
make build
# 安装到 ~/go/bin
make install
# 运行测试
make test
```
**注意**:需要 CGO 和 FTS5 支持。
## 使用方法
### 搜索
```bash
# 基础搜索
kb search 充装规格
# 带扩展词
kb search 充装 --expanded 重量,规格
# 带症状词
kb search 充装失败 --symptom 报错,无法启动
# JSON 输出
kb search 充装 --json
# 限制结果数
kb search 充装 --top 5
```
### 索引管理
```bash
# 构建/重建索引
kb index build
# 查看索引状态
kb index status
```
### 全局选项
```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
Binary files differ
cmd/index.go
New file
@@ -0,0 +1,117 @@
package cmd
import (
    "fmt"
    "os"
    "github.com/aisim/kb-cli/internal/index"
    "github.com/spf13/cobra"
)
var indexCmd = &cobra.Command{
    Use:   "index",
    Short: "索引管理",
    Long:  `管理知识库索引`,
}
var indexBuildCmd = &cobra.Command{
    Use:   "build",
    Short: "构建/重建索引",
    Long:  `构建或重建知识库索引`,
    RunE:  runIndexBuild,
}
var indexStatusCmd = &cobra.Command{
    Use:   "status",
    Short: "查看索引状态",
    Long:  `查看索引状态信息`,
    RunE:  runIndexStatus,
}
func init() {
    rootCmd.AddCommand(indexCmd)
    indexCmd.AddCommand(indexBuildCmd)
    indexCmd.AddCommand(indexStatusCmd)
}
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
}
cmd/rebuild.go
New file
@@ -0,0 +1,65 @@
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)
    // 写入节点
    for _, n := range g.Nodes {
        if _, err := store.InsertNode(n); err != nil {
            return fmt.Errorf("插入节点失败 [%s]: %w", n.Path, err)
        }
    }
    // 写入边
    for _, e := range g.Edges {
        if err := store.InsertEdge(e); 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/search.go
New file
@@ -0,0 +1,87 @@
package cmd
import (
    "fmt"
    "os"
    "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:  `搜索知识库,支持关键词、扩展词、症状词`,
    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, "显示关联文档链接")
}
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)
        }
    }
    // 执行搜索
    opts := search.SearchOptions{
        Expanded:    expanded,
        Symptom:     symptom,
        TopN:        topN,
        WithContent: withContent,
        WithLinks:   withLinks,
    }
    results, err := search.Search(store, args, 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
}
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/sqlite.go
@@ -147,3 +147,33 @@
    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
}
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/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
}
kb
Binary files differ
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 "=== 集成测试完成 ==="