From 46a01d99b5d15120ada95754c9effbdcb576d31e Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 08:19:23 +0800
Subject: [PATCH] Task 4: add CLI flags for with-content and with-links

---
 cmd/search.go                 |   87 +++++++
 internal/graph/builder.go     |   11 
 test-integration.sh           |   73 ++++++
 Makefile                      |   32 ++
 kb                            |    0 
 internal/search/engine.go     |   40 ++
 bin/kb                        |    0 
 internal/index/sqlite_test.go |   67 ++++++
 cmd/rebuild.go                |   65 +++++
 cmd/index.go                  |  117 ++++++++++
 internal/index/sqlite.go      |   30 ++
 README.md                     |  129 +++++++++++
 12 files changed, 642 insertions(+), 9 deletions(-)

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..31dd685
--- /dev/null
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ae844e
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/bin/kb b/bin/kb
new file mode 100755
index 0000000..fd5beb1
--- /dev/null
+++ b/bin/kb
Binary files differ
diff --git a/cmd/index.go b/cmd/index.go
new file mode 100644
index 0000000..c5399f6
--- /dev/null
+++ b/cmd/index.go
@@ -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
+}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
new file mode 100644
index 0000000..4c488cd
--- /dev/null
+++ b/cmd/rebuild.go
@@ -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
+}
diff --git a/cmd/search.go b/cmd/search.go
new file mode 100644
index 0000000..d2787a1
--- /dev/null
+++ b/cmd/search.go
@@ -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
+}
diff --git a/internal/graph/builder.go b/internal/graph/builder.go
index 9b5ca4e..428535b 100644
--- a/internal/graph/builder.go
+++ b/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
 }
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index e1086df..6b7839a 100644
--- a/internal/index/sqlite.go
+++ b/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
+}
diff --git a/internal/index/sqlite_test.go b/internal/index/sqlite_test.go
index f83b4bb..5ff1d40 100644
--- a/internal/index/sqlite_test.go
+++ b/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))
+	}
+}
diff --git a/internal/search/engine.go b/internal/search/engine.go
index 95d7c37..16fa86d 100644
--- a/internal/search/engine.go
+++ b/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
 }
 
diff --git a/kb b/kb
index 6db78e1..aa55456 100755
--- a/kb
+++ b/kb
Binary files differ
diff --git a/test-integration.sh b/test-integration.sh
new file mode 100755
index 0000000..0646d96
--- /dev/null
+++ b/test-integration.sh
@@ -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 "=== 集成测试完成 ==="

--
Gitblit v1.9.1