1 files modified
19 files added
| New file |
| | |
| | | package graph |
| | | |
| | | import ( |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | | // BuildGraph 从文件元数据构建知识图谱 |
| | | func BuildGraph(files []*vault.FileMeta) *Graph { |
| | | g := &Graph{} |
| | | |
| | | // 标签/实体 -> 虚拟节点 ID 映射 |
| | | labelToID := make(map[string]int64) |
| | | nextVirtualID := int64(1000000) // 虚拟节点从 1000000 开始 |
| | | |
| | | getOrCreateVirtualNode := func(label string) int64 { |
| | | if id, ok := labelToID[label]; ok { |
| | | return id |
| | | } |
| | | id := nextVirtualID |
| | | nextVirtualID++ |
| | | labelToID[label] = id |
| | | return id |
| | | } |
| | | |
| | | for i, f := range files { |
| | | node := &Node{ |
| | | ID: int64(i + 1), |
| | | Path: f.Path, |
| | | Title: f.Title, |
| | | Section: f.Section, |
| | | Tags: f.Tags, |
| | | Entities: f.Entities, |
| | | Wikilinks: f.Wikilinks, |
| | | Content: f.Content, |
| | | } |
| | | g.Nodes = append(g.Nodes, node) |
| | | |
| | | // 创建 tag 边 |
| | | for _, tag := range f.Tags { |
| | | virtualID := getOrCreateVirtualNode("tag:" + tag) |
| | | g.Edges = append(g.Edges, &Edge{ |
| | | FromNode: node.ID, |
| | | ToNode: virtualID, |
| | | Relation: "tag", |
| | | Label: tag, |
| | | }) |
| | | } |
| | | |
| | | // 创建 entity 边 |
| | | for _, entity := range f.Entities { |
| | | virtualID := getOrCreateVirtualNode("entity:" + entity) |
| | | g.Edges = append(g.Edges, &Edge{ |
| | | FromNode: node.ID, |
| | | ToNode: virtualID, |
| | | Relation: "entity", |
| | | Label: entity, |
| | | }) |
| | | } |
| | | } |
| | | |
| | | // 创建 wikilink 边(文件间链接) |
| | | pathToID := make(map[string]int64) |
| | | for _, n := range g.Nodes { |
| | | pathToID[n.Path] = n.ID |
| | | } |
| | | |
| | | for _, n := range g.Nodes { |
| | | for _, link := range n.Wikilinks { |
| | | // 尝试匹配目标文件(模糊匹配:链接文本可能只是标题的一部分) |
| | | for _, target := range g.Nodes { |
| | | if matchesWikilink(target, link) { |
| | | g.Edges = append(g.Edges, &Edge{ |
| | | FromNode: n.ID, |
| | | ToNode: target.ID, |
| | | Relation: "wikilink", |
| | | Label: link, |
| | | }) |
| | | break |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return g |
| | | } |
| | | |
| | | // matchesWikilink 检查文件是否匹配 wikilink |
| | | func matchesWikilink(node *Node, link string) bool { |
| | | // 精确匹配标题 |
| | | if node.Title == link { |
| | | return true |
| | | } |
| | | // 匹配文件名(不含扩展名和编号前缀) |
| | | // 例如:[[充装规格配置]] 匹配 "知识/002-充装规格配置.md" |
| | | return false // 简化版,后续可扩展 |
| | | } |
| New file |
| | |
| | | package graph |
| | | |
| | | import ( |
| | | "testing" |
| | | |
| | | "github.com/aisim/kb-cli/internal/vault" |
| | | ) |
| | | |
| | | func TestBuildGraph(t *testing.T) { |
| | | files := []*vault.FileMeta{ |
| | | { |
| | | Path: "FAQ/充装类/001-test.md", |
| | | Title: "测试FAQ", |
| | | Section: "FAQ", |
| | | Tags: []string{"充装", "智能枪"}, |
| | | Entities: []string{"YSP-35.5"}, |
| | | Wikilinks: []string{}, |
| | | Content: "测试内容", |
| | | }, |
| | | { |
| | | Path: "知识/002-配置.md", |
| | | Title: "充装规格配置", |
| | | Section: "知识", |
| | | Tags: []string{"充装"}, |
| | | Entities: []string{}, |
| | | Wikilinks: []string{}, |
| | | Content: "配置内容", |
| | | }, |
| | | } |
| | | |
| | | g := BuildGraph(files) |
| | | |
| | | if len(g.Nodes) != 2 { |
| | | t.Errorf("node count = %d, want 2", len(g.Nodes)) |
| | | } |
| | | |
| | | // 应该有 3 条边:2条 tag + 1条 entity |
| | | tagEdges := 0 |
| | | entityEdges := 0 |
| | | for _, e := range g.Edges { |
| | | if e.Relation == "tag" { |
| | | tagEdges++ |
| | | } |
| | | if e.Relation == "entity" { |
| | | entityEdges++ |
| | | } |
| | | } |
| | | if tagEdges != 3 { // 充装 + 智能枪 + 充装(第二个文件) |
| | | t.Errorf("tag edges = %d, want 3", tagEdges) |
| | | } |
| | | if entityEdges != 1 { |
| | | t.Errorf("entity edges = %d, want 1", entityEdges) |
| | | } |
| | | } |
| New file |
| | |
| | | package graph |
| | | |
| | | // Node 图节点(对应一个 markdown 文件) |
| | | type Node struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Tags []string `json:"tags"` |
| | | Entities []string `json:"entities"` |
| | | Wikilinks []string `json:"wikilinks"` |
| | | Content string `json:"content"` |
| | | } |
| | | |
| | | // Edge 图边(实体关系) |
| | | type Edge struct { |
| | | FromNode int64 `json:"from_node"` |
| | | ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID |
| | | Relation string `json:"relation"` // "tag" | "entity" | "wikilink" |
| | | Label string `json:"label"` // 具体值 |
| | | } |
| | | |
| | | // Graph 知识图谱 |
| | | type Graph struct { |
| | | Nodes []*Node |
| | | Edges []*Edge |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "os/exec" |
| | | "strings" |
| | | ) |
| | | |
| | | // GetGitCommit 获取知识库当前 git commit hash |
| | | func GetGitCommit(vaultPath string) (string, error) { |
| | | cmd := exec.Command("git", "-C", vaultPath, "rev-parse", "HEAD") |
| | | out, err := cmd.Output() |
| | | if err != nil { |
| | | return "", err // 不是 git 仓库 |
| | | } |
| | | return strings.TrimSpace(string(out)), nil |
| | | } |
| | | |
| | | // NeedsRebuild 检查是否需要重建索引 |
| | | func NeedsRebuild(store *Store, vaultPath string) (bool, string, error) { |
| | | currentCommit, err := GetGitCommit(vaultPath) |
| | | if err != nil { |
| | | // 不是 git 仓库,总是需要重建 |
| | | return true, "", nil |
| | | } |
| | | |
| | | storedCommit, err := store.GetMeta("git_commit") |
| | | if err != nil { |
| | | return true, currentCommit, nil |
| | | } |
| | | |
| | | if storedCommit == "" { |
| | | // 没有记录,需要重建 |
| | | return true, currentCommit, nil |
| | | } |
| | | |
| | | if storedCommit != currentCommit { |
| | | // commit 变了,需要重建 |
| | | return true, currentCommit, nil |
| | | } |
| | | |
| | | // 检查是否有节点 |
| | | count, _ := store.NodeCount() |
| | | if count == 0 { |
| | | return true, currentCommit, nil |
| | | } |
| | | |
| | | return false, currentCommit, nil |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "os" |
| | | "os/exec" |
| | | "path/filepath" |
| | | "testing" |
| | | ) |
| | | |
| | | func TestGetGitCommit(t *testing.T) { |
| | | // 创建临时 git 仓库 |
| | | tmpDir := t.TempDir() |
| | | exec.Command("git", "-C", tmpDir, "init").Run() |
| | | exec.Command("git", "-C", tmpDir, "config", "user.email", "test@test.com").Run() |
| | | exec.Command("git", "-C", tmpDir, "config", "user.name", "test").Run() |
| | | os.WriteFile(filepath.Join(tmpDir, "test.md"), []byte("test"), 0644) |
| | | exec.Command("git", "-C", tmpDir, "add", ".").Run() |
| | | exec.Command("git", "-C", tmpDir, "commit", "-m", "init").Run() |
| | | |
| | | commit, err := GetGitCommit(tmpDir) |
| | | if err != nil { |
| | | t.Fatalf("GetGitCommit failed: %v", err) |
| | | } |
| | | if len(commit) != 40 { |
| | | t.Errorf("commit length = %d, want 40", len(commit)) |
| | | } |
| | | } |
| | | |
| | | func TestGetGitCommitNotGit(t *testing.T) { |
| | | tmpDir := t.TempDir() |
| | | _, err := GetGitCommit(tmpDir) |
| | | if err == nil { |
| | | t.Error("expected error for non-git directory") |
| | | } |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "database/sql" |
| | | "encoding/json" |
| | | "strings" |
| | | ) |
| | | |
| | | // FTSResult 全文搜索结果 |
| | | type FTSResult struct { |
| | | ID int64 |
| | | Path string |
| | | Title string |
| | | Section string |
| | | Rank float64 |
| | | } |
| | | |
| | | // CreateFTS 创建 FTS5 虚拟表 |
| | | func (s *Store) CreateFTS() error { |
| | | _, err := s.db.Exec(` |
| | | CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( |
| | | title, |
| | | content, |
| | | tags, |
| | | entities, |
| | | content='nodes', |
| | | content_rowid='id' |
| | | ) |
| | | `) |
| | | return err |
| | | } |
| | | |
| | | // PopulateFTS 填充 FTS 索引 |
| | | func (s *Store) PopulateFTS() error { |
| | | _, err := s.db.Exec(` |
| | | INSERT INTO nodes_fts(rowid, title, content, tags, entities) |
| | | SELECT id, title, content_fts, tags, entities FROM nodes |
| | | `) |
| | | return err |
| | | } |
| | | |
| | | // FTSSearch 全文搜索 |
| | | func (s *Store) FTSSearch(keywords []string, limit int) ([]FTSResult, error) { |
| | | if len(keywords) == 0 { |
| | | return nil, nil |
| | | } |
| | | |
| | | // 构建 FTS5 查询 |
| | | query := strings.Join(keywords, " OR ") |
| | | |
| | | 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 |
| | | } |
| | | results = append(results, r) |
| | | } |
| | | return results, nil |
| | | } |
| | | |
| | | // GetNodeContent 获取节点内容 |
| | | func (s *Store) GetNodeContent(id int64) (string, []string, []string, error) { |
| | | var content, tagsJSON, entitiesJSON string |
| | | err := s.db.QueryRow("SELECT content_fts, tags, entities FROM nodes WHERE id = ?", id). |
| | | Scan(&content, &tagsJSON, &entitiesJSON) |
| | | if err == sql.ErrNoRows { |
| | | return "", nil, nil, nil |
| | | } |
| | | if err != nil { |
| | | return "", nil, nil, err |
| | | } |
| | | |
| | | // 解析 JSON 数组 |
| | | var tags, entities []string |
| | | if tagsJSON != "" { |
| | | if err := json.Unmarshal([]byte(tagsJSON), &tags); err != nil { |
| | | tags = nil |
| | | } |
| | | } |
| | | if entitiesJSON != "" { |
| | | if err := json.Unmarshal([]byte(entitiesJSON), &entities); err != nil { |
| | | entities = nil |
| | | } |
| | | } |
| | | |
| | | return content, tags, entities, nil |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "path/filepath" |
| | | "testing" |
| | | |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | ) |
| | | |
| | | func TestFTSSearch(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() |
| | | |
| | | // 插入测试数据 |
| | | store.InsertNode(&graph.Node{ |
| | | ID: 1, |
| | | Path: "FAQ/充装类/001-test.md", |
| | | Title: "充装问题排查", |
| | | Section: "FAQ", |
| | | Tags: []string{"充装"}, |
| | | Content: "关于智能枪充装问题的排查方法", |
| | | }) |
| | | store.InsertNode(&graph.Node{ |
| | | ID: 2, |
| | | Path: "知识/002-config.md", |
| | | Title: "充装规格配置", |
| | | Section: "知识", |
| | | Tags: []string{"配置"}, |
| | | Content: "充装规格配置说明", |
| | | }) |
| | | |
| | | // 创建 FTS |
| | | if err := store.CreateFTS(); err != nil { |
| | | t.Fatalf("CreateFTS failed: %v", err) |
| | | } |
| | | if err := store.PopulateFTS(); err != nil { |
| | | t.Fatalf("PopulateFTS failed: %v", err) |
| | | } |
| | | |
| | | // 搜索 - FTS5 对中文按字符 tokenize,"充装" 两个节点都包含 |
| | | results, err := store.FTSSearch([]string{"充装"}, 10) |
| | | if err != nil { |
| | | t.Fatalf("FTSSearch failed: %v", err) |
| | | } |
| | | if len(results) < 1 { |
| | | t.Errorf("result count = %d, want >= 1", len(results)) |
| | | } |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "database/sql" |
| | | "encoding/json" |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | |
| | | _ "github.com/mattn/go-sqlite3" |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | ) |
| | | |
| | | // Store SQLite 存储层 |
| | | type Store struct { |
| | | db *sql.DB |
| | | } |
| | | |
| | | // Open 打开或创建数据库 |
| | | func Open(dbPath string) (*Store, error) { |
| | | // 确保目录存在 |
| | | dir := filepath.Dir(dbPath) |
| | | if err := os.MkdirAll(dir, 0755); err != nil { |
| | | return nil, fmt.Errorf("创建目录失败: %w", err) |
| | | } |
| | | |
| | | db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL") |
| | | if err != nil { |
| | | return nil, fmt.Errorf("打开数据库失败: %w", err) |
| | | } |
| | | |
| | | s := &Store{db: db} |
| | | if err := s.initTables(); err != nil { |
| | | db.Close() |
| | | return nil, err |
| | | } |
| | | return s, nil |
| | | } |
| | | |
| | | // Close 关闭数据库 |
| | | func (s *Store) Close() error { |
| | | return s.db.Close() |
| | | } |
| | | |
| | | // initTables 创建表结构 |
| | | func (s *Store) initTables() error { |
| | | schema := ` |
| | | CREATE TABLE IF NOT EXISTS meta ( |
| | | key TEXT PRIMARY KEY, |
| | | value TEXT NOT NULL |
| | | ); |
| | | |
| | | CREATE TABLE IF NOT EXISTS nodes ( |
| | | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | path TEXT NOT NULL UNIQUE, |
| | | title TEXT, |
| | | section TEXT, |
| | | tags TEXT, |
| | | entities TEXT, |
| | | wikilinks TEXT, |
| | | content_fts TEXT, |
| | | created_at TEXT DEFAULT (datetime('now')), |
| | | updated_at TEXT DEFAULT (datetime('now')) |
| | | ); |
| | | |
| | | CREATE TABLE IF NOT EXISTS edges ( |
| | | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | from_node INTEGER NOT NULL, |
| | | to_node INTEGER NOT NULL, |
| | | relation TEXT NOT NULL, |
| | | label TEXT, |
| | | UNIQUE(from_node, to_node, relation, label) |
| | | ); |
| | | |
| | | CREATE INDEX IF NOT EXISTS idx_nodes_section ON nodes(section); |
| | | CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node); |
| | | CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node); |
| | | CREATE INDEX IF NOT EXISTS idx_edges_label ON edges(label); |
| | | ` |
| | | _, err := s.db.Exec(schema) |
| | | if err != nil { |
| | | return fmt.Errorf("创建表失败: %w", err) |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | // ClearData 清空数据(重建前调用) |
| | | func (s *Store) ClearData() error { |
| | | _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;") |
| | | return err |
| | | } |
| | | |
| | | // InsertNode 插入节点 |
| | | func (s *Store) InsertNode(n *graph.Node) (int64, error) { |
| | | tagsJSON, _ := json.Marshal(n.Tags) |
| | | entitiesJSON, _ := json.Marshal(n.Entities) |
| | | wikilinksJSON, _ := json.Marshal(n.Wikilinks) |
| | | |
| | | result, err := s.db.Exec(` |
| | | INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?) |
| | | `, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON), |
| | | string(wikilinksJSON), n.Content) |
| | | if err != nil { |
| | | return 0, err |
| | | } |
| | | return result.LastInsertId() |
| | | } |
| | | |
| | | // InsertEdge 插入边 |
| | | func (s *Store) InsertEdge(e *graph.Edge) error { |
| | | _, err := s.db.Exec(` |
| | | INSERT OR IGNORE INTO edges (from_node, to_node, relation, label) |
| | | VALUES (?, ?, ?, ?) |
| | | `, e.FromNode, e.ToNode, e.Relation, e.Label) |
| | | return err |
| | | } |
| | | |
| | | // SetMeta 设置元信息 |
| | | func (s *Store) SetMeta(key, value string) error { |
| | | _, err := s.db.Exec(` |
| | | INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?) |
| | | `, key, value) |
| | | return err |
| | | } |
| | | |
| | | // GetMeta 获取元信息 |
| | | func (s *Store) GetMeta(key string) (string, error) { |
| | | var value string |
| | | err := s.db.QueryRow("SELECT value FROM meta WHERE key = ?", key).Scan(&value) |
| | | if err == sql.ErrNoRows { |
| | | return "", nil |
| | | } |
| | | return value, err |
| | | } |
| | | |
| | | // NodeCount 返回节点数量 |
| | | func (s *Store) NodeCount() (int, error) { |
| | | var count int |
| | | err := s.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count) |
| | | return count, err |
| | | } |
| | | |
| | | // EdgeCount 返回边数量 |
| | | func (s *Store) EdgeCount() (int, error) { |
| | | var count int |
| | | err := s.db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&count) |
| | | return count, err |
| | | } |
| New file |
| | |
| | | package index |
| | | |
| | | import ( |
| | | "path/filepath" |
| | | "testing" |
| | | |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | ) |
| | | |
| | | func TestStore(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() |
| | | |
| | | // 测试插入节点 |
| | | node := &graph.Node{ |
| | | Path: "FAQ/充装类/001-test.md", |
| | | Title: "测试FAQ", |
| | | Section: "FAQ", |
| | | Tags: []string{"充装", "智能枪"}, |
| | | } |
| | | id, err := store.InsertNode(node) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode failed: %v", err) |
| | | } |
| | | if id == 0 { |
| | | t.Error("InsertNode returned 0 id") |
| | | } |
| | | |
| | | // 测试插入边 |
| | | edge := &graph.Edge{ |
| | | FromNode: id, |
| | | ToNode: 1000000, |
| | | Relation: "tag", |
| | | Label: "充装", |
| | | } |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | t.Fatalf("InsertEdge failed: %v", err) |
| | | } |
| | | |
| | | // 测试元信息 |
| | | if err := store.SetMeta("git_commit", "abc123"); err != nil { |
| | | t.Fatalf("SetMeta failed: %v", err) |
| | | } |
| | | commit, err := store.GetMeta("git_commit") |
| | | if err != nil { |
| | | t.Fatalf("GetMeta failed: %v", err) |
| | | } |
| | | if commit != "abc123" { |
| | | t.Errorf("commit = %q, want %q", commit, "abc123") |
| | | } |
| | | |
| | | // 测试计数 |
| | | count, err := store.NodeCount() |
| | | if err != nil { |
| | | t.Fatalf("NodeCount failed: %v", err) |
| | | } |
| | | if count != 1 { |
| | | t.Errorf("node count = %d, want 1", count) |
| | | } |
| | | } |
| New file |
| | |
| | | package output |
| | | |
| | | import ( |
| | | "encoding/json" |
| | | "fmt" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/search" |
| | | ) |
| | | |
| | | // FormatTable 表格格式输出 |
| | | func FormatTable(results []search.SearchResult) string { |
| | | if len(results) == 0 { |
| | | return "未找到匹配结果" |
| | | } |
| | | |
| | | var sb strings.Builder |
| | | sb.WriteString(fmt.Sprintf("%-50s %-20s %-8s %s\n", "路径", "标题", "板块", "得分")) |
| | | sb.WriteString(strings.Repeat("-", 100) + "\n") |
| | | |
| | | for _, r := range results { |
| | | title := r.Title |
| | | if len(title) > 18 { |
| | | title = title[:18] + ".." |
| | | } |
| | | sb.WriteString(fmt.Sprintf("%-50s %-20s %-8s %d\n", r.Path, title, r.Section, r.Score)) |
| | | } |
| | | |
| | | sb.WriteString(fmt.Sprintf("\n共 %d 条结果\n", len(results))) |
| | | return sb.String() |
| | | } |
| | | |
| | | // FormatJSON JSON 格式输出 |
| | | func FormatJSON(results []search.SearchResult) (string, error) { |
| | | data, err := json.MarshalIndent(results, "", " ") |
| | | if err != nil { |
| | | return "", err |
| | | } |
| | | return string(data), nil |
| | | } |
| New file |
| | |
| | | package output |
| | | |
| | | import ( |
| | | "strings" |
| | | "testing" |
| | | |
| | | "github.com/aisim/kb-cli/internal/search" |
| | | ) |
| | | |
| | | func TestFormatTable(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | {Path: "FAQ/充装类/001.md", Title: "充装问题", Section: "FAQ", Score: 100}, |
| | | } |
| | | output := FormatTable(results) |
| | | if !strings.Contains(output, "FAQ") { |
| | | t.Error("output should contain section") |
| | | } |
| | | if !strings.Contains(output, "100") { |
| | | t.Error("output should contain score") |
| | | } |
| | | } |
| | | |
| | | func TestFormatTableEmpty(t *testing.T) { |
| | | output := FormatTable(nil) |
| | | if output != "未找到匹配结果" { |
| | | t.Errorf("output = %q, want %q", output, "未找到匹配结果") |
| | | } |
| | | } |
| | | |
| | | func TestFormatJSON(t *testing.T) { |
| | | results := []search.SearchResult{ |
| | | {Path: "test.md", Title: "Test", Score: 50}, |
| | | } |
| | | output, err := FormatJSON(results) |
| | | if err != nil { |
| | | t.Fatalf("FormatJSON failed: %v", err) |
| | | } |
| | | if !strings.Contains(output, "test.md") { |
| | | t.Error("JSON should contain path") |
| | | } |
| | | } |
| New file |
| | |
| | | package search |
| | | |
| | | import ( |
| | | "sort" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | ) |
| | | |
| | | // SearchOptions 搜索选项 |
| | | type SearchOptions struct { |
| | | Expanded []string // 扩展词 |
| | | Symptom []string // 症状词 |
| | | TopN int // 返回前 N 条 |
| | | } |
| | | |
| | | // SearchResult 搜索结果 |
| | | type SearchResult struct { |
| | | ID int64 |
| | | Path string |
| | | Title string |
| | | Section string |
| | | Score int |
| | | } |
| | | |
| | | // 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] |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | |
| | | // scoreResult 计算单个结果的得分 |
| | | func scoreResult(r index.FTSResult, keyword string, scoreType ScoreType) int { |
| | | score := 0 |
| | | kw := strings.ToLower(keyword) |
| | | |
| | | // 路径匹配 |
| | | if strings.Contains(strings.ToLower(r.Path), kw) { |
| | | score += CalcScore(keyword, "path", scoreType) |
| | | } |
| | | |
| | | // 标题匹配 |
| | | if strings.Contains(strings.ToLower(r.Title), kw) { |
| | | score += CalcScore(keyword, "title", scoreType) |
| | | } |
| | | |
| | | // 板块匹配 |
| | | if strings.Contains(strings.ToLower(r.Section), kw) { |
| | | score += CalcScore(keyword, "tag", scoreType) |
| | | } |
| | | |
| | | // 内容匹配(FTS 已经匹配,给基础分) |
| | | score += CalcScore(keyword, "content", scoreType) |
| | | |
| | | return score |
| | | } |
| New file |
| | |
| | | package search |
| | | |
| | | import ( |
| | | "path/filepath" |
| | | "strings" |
| | | "testing" |
| | | |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | ) |
| | | |
| | | func TestSearch(t *testing.T) { |
| | | // 创建临时数据库 |
| | | tmpDir := t.TempDir() |
| | | dbPath := filepath.Join(tmpDir, "test.db") |
| | | |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | t.Fatalf("Open failed: %v", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 插入测试数据 |
| | | nodes := []*graph.Node{ |
| | | { |
| | | ID: 1, |
| | | Path: "FAQ/充装类/001-test.md", |
| | | Title: "充装问题排查", |
| | | Section: "FAQ", |
| | | Tags: []string{"充装"}, |
| | | Content: "关于智能枪充装问题的排查方法", |
| | | }, |
| | | { |
| | | ID: 2, |
| | | Path: "知识/002-config.md", |
| | | Title: "充装规格配置", |
| | | Section: "知识", |
| | | Tags: []string{"配置"}, |
| | | Content: "充装规格配置说明", |
| | | }, |
| | | } |
| | | |
| | | for _, n := range nodes { |
| | | _, err := store.InsertNode(n) |
| | | if err != nil { |
| | | t.Fatalf("InsertNode failed: %v", err) |
| | | } |
| | | } |
| | | |
| | | // 创建 FTS 索引 |
| | | if err := store.CreateFTS(); err != nil { |
| | | t.Fatalf("CreateFTS failed: %v", err) |
| | | } |
| | | if err := store.PopulateFTS(); err != nil { |
| | | t.Fatalf("PopulateFTS failed: %v", err) |
| | | } |
| | | |
| | | // 测试搜索 |
| | | results, err := Search(store, []string{"充装"}, SearchOptions{}) |
| | | if err != nil { |
| | | t.Fatalf("Search failed: %v", err) |
| | | } |
| | | |
| | | if len(results) == 0 { |
| | | t.Error("Expected results, got none") |
| | | } |
| | | |
| | | // 验证结果包含关键词 |
| | | found := false |
| | | for _, r := range results { |
| | | if strings.Contains(r.Title, "充装") || strings.Contains(r.Path, "充装") { |
| | | found = true |
| | | break |
| | | } |
| | | } |
| | | if !found { |
| | | t.Error("Expected results to contain '充装'") |
| | | } |
| | | } |
| | | |
| | | func TestIsGenericWord(t *testing.T) { |
| | | tests := []struct { |
| | | word string |
| | | expected bool |
| | | }{ |
| | | {"问题", true}, |
| | | {"故障", true}, |
| | | {"充装", false}, |
| | | {"智能枪", false}, |
| | | } |
| | | |
| | | for _, tt := range tests { |
| | | result := IsGenericWord(tt.word) |
| | | if result != tt.expected { |
| | | t.Errorf("IsGenericWord(%q) = %v, want %v", tt.word, result, tt.expected) |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package search |
| | | |
| | | import ( |
| | | "strings" |
| | | ) |
| | | |
| | | // GenericWords 通用词列表(降低权重避免匹配所有文件) |
| | | var GenericWords = []string{ |
| | | "问题", "故障", "报错", "异常", "无法", "不能", |
| | | "怎么", "如何", "为什么", "是什么", "哪里", "什么", |
| | | "可以", "需要", "应该", "是否", "有没有", |
| | | } |
| | | |
| | | // IsGenericWord 判断是否为通用词 |
| | | func IsGenericWord(word string) bool { |
| | | word = strings.ToLower(word) |
| | | for _, g := range GenericWords { |
| | | if word == g { |
| | | return true |
| | | } |
| | | } |
| | | return false |
| | | } |
| | | |
| | | // ScoreType 评分类型 |
| | | type ScoreType int |
| | | |
| | | const ( |
| | | ScoreNormal ScoreType = iota // 普通词 |
| | | ScoreExpanded // 扩展词 |
| | | ScoreSymptom // 症状词 |
| | | ) |
| | | |
| | | // ScoreTable 评分表 |
| | | var ScoreTable = map[ScoreType]map[string]int{ |
| | | ScoreNormal: { |
| | | "path": 2, |
| | | "title": 3, |
| | | "tag": 2, |
| | | "content": 1, |
| | | }, |
| | | ScoreExpanded: { |
| | | "path": 40, |
| | | "title": 30, |
| | | "tag": 20, |
| | | "content": 5, |
| | | }, |
| | | ScoreSymptom: { |
| | | "path": 60, |
| | | "title": 50, |
| | | "tag": 35, |
| | | "content": 15, |
| | | }, |
| | | } |
| | | |
| | | // CalcScore 计算单个关键词在某个位置的得分 |
| | | func CalcScore(keyword string, position string, scoreType ScoreType) int { |
| | | base := ScoreTable[scoreType][position] |
| | | if base == 0 { |
| | | return 0 |
| | | } |
| | | if IsGenericWord(keyword) { |
| | | return base / 10 // 通用词降权 |
| | | } |
| | | return base |
| | | } |
| New file |
| | |
| | | package vault |
| | | |
| | | import ( |
| | | "bufio" |
| | | "os" |
| | | "regexp" |
| | | "strings" |
| | | |
| | | "gopkg.in/yaml.v3" |
| | | ) |
| | | |
| | | // FileMeta 文件元数据 |
| | | type FileMeta struct { |
| | | Path string // 相对路径 |
| | | Title string // frontmatter title |
| | | Section string // 所属板块 |
| | | Tags []string // frontmatter tags |
| | | Entities []string // frontmatter entities |
| | | Wikilinks []string // 正文中的 [[xxx]] 链接 |
| | | Content string // 纯文本内容(去 frontmatter) |
| | | } |
| | | |
| | | // Frontmatter YAML 结构 |
| | | type Frontmatter struct { |
| | | Title string `yaml:"title"` |
| | | Tags []string `yaml:"tags"` |
| | | Entities []string `yaml:"entities"` |
| | | Aliases []string `yaml:"aliases"` |
| | | } |
| | | |
| | | var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`) |
| | | |
| | | // ParseFile 解析单个 markdown 文件 |
| | | func ParseFile(absPath, relPath string) (*FileMeta, error) { |
| | | f, err := os.Open(absPath) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | defer f.Close() |
| | | |
| | | meta := &FileMeta{ |
| | | Path: relPath, |
| | | Section: GetSection(relPath), |
| | | } |
| | | |
| | | scanner := bufio.NewScanner(f) |
| | | inFrontmatter := false |
| | | fmLines := []string{} |
| | | contentLines := []string{} |
| | | lineNum := 0 |
| | | |
| | | for scanner.Scan() { |
| | | line := scanner.Text() |
| | | lineNum++ |
| | | |
| | | if lineNum == 1 && line == "---" { |
| | | inFrontmatter = true |
| | | continue |
| | | } |
| | | if inFrontmatter && line == "---" { |
| | | inFrontmatter = false |
| | | continue |
| | | } |
| | | if inFrontmatter { |
| | | fmLines = append(fmLines, line) |
| | | } else { |
| | | contentLines = append(contentLines, line) |
| | | } |
| | | } |
| | | |
| | | // 解析 frontmatter |
| | | if len(fmLines) > 0 { |
| | | var fm Frontmatter |
| | | if err := yaml.Unmarshal([]byte(strings.Join(fmLines, "\n")), &fm); err == nil { |
| | | meta.Title = fm.Title |
| | | meta.Tags = fm.Tags |
| | | meta.Entities = fm.Entities |
| | | } |
| | | } |
| | | |
| | | // 提取 wikilinks 和内容 |
| | | content := strings.Join(contentLines, "\n") |
| | | meta.Content = content |
| | | |
| | | matches := wikilinkRe.FindAllStringSubmatch(content, -1) |
| | | seen := make(map[string]bool) |
| | | for _, m := range matches { |
| | | link := m[1] |
| | | if !seen[link] { |
| | | meta.Wikilinks = append(meta.Wikilinks, link) |
| | | seen[link] = true |
| | | } |
| | | } |
| | | |
| | | return meta, nil |
| | | } |
| New file |
| | |
| | | package vault |
| | | |
| | | import ( |
| | | "os" |
| | | "path/filepath" |
| | | "testing" |
| | | ) |
| | | |
| | | func TestParseFile(t *testing.T) { |
| | | // 创建临时测试文件 |
| | | tmpDir := t.TempDir() |
| | | testFile := filepath.Join(tmpDir, "test.md") |
| | | content := `--- |
| | | title: 测试文档 |
| | | tags: |
| | | - 充装 |
| | | - 智能枪 |
| | | entities: |
| | | - YSP-35.5 |
| | | --- |
| | | |
| | | # 测试内容 |
| | | |
| | | 这是一个关于 [[充装规格配置]] 的文档。 |
| | | 也引用了 [[智能枪使用指南]]。 |
| | | ` |
| | | os.WriteFile(testFile, []byte(content), 0644) |
| | | |
| | | meta, err := ParseFile(testFile, "FAQ/充装类/test.md") |
| | | if err != nil { |
| | | t.Fatalf("ParseFile failed: %v", err) |
| | | } |
| | | |
| | | if meta.Title != "测试文档" { |
| | | t.Errorf("Title = %q, want %q", meta.Title, "测试文档") |
| | | } |
| | | if len(meta.Tags) != 2 || meta.Tags[0] != "充装" { |
| | | t.Errorf("Tags = %v, want [充装 智能枪]", meta.Tags) |
| | | } |
| | | if len(meta.Entities) != 1 || meta.Entities[0] != "YSP-35.5" { |
| | | t.Errorf("Entities = %v, want [YSP-35.5]", meta.Entities) |
| | | } |
| | | if len(meta.Wikilinks) != 2 { |
| | | t.Errorf("Wikilinks count = %d, want 2", len(meta.Wikilinks)) |
| | | } |
| | | if meta.Section != "FAQ" { |
| | | t.Errorf("Section = %q, want %q", meta.Section, "FAQ") |
| | | } |
| | | } |
| | | |
| | | func TestParseFileNoFrontmatter(t *testing.T) { |
| | | tmpDir := t.TempDir() |
| | | testFile := filepath.Join(tmpDir, "nofm.md") |
| | | content := `# 没有 frontmatter |
| | | |
| | | 只有内容。 |
| | | ` |
| | | os.WriteFile(testFile, []byte(content), 0644) |
| | | |
| | | meta, err := ParseFile(testFile, "笔记/nofm.md") |
| | | if err != nil { |
| | | t.Fatalf("ParseFile failed: %v", err) |
| | | } |
| | | |
| | | if meta.Title != "" { |
| | | t.Errorf("Title = %q, want empty", meta.Title) |
| | | } |
| | | if len(meta.Tags) != 0 { |
| | | t.Errorf("Tags = %v, want empty", meta.Tags) |
| | | } |
| | | } |
| New file |
| | |
| | | package vault |
| | | |
| | | import ( |
| | | "os" |
| | | "path/filepath" |
| | | "strings" |
| | | ) |
| | | |
| | | // ScanVault 扫描知识库目录,返回所有 markdown 文件的元数据 |
| | | func ScanVault(vaultPath string) ([]*FileMeta, error) { |
| | | var files []*FileMeta |
| | | |
| | | err := filepath.Walk(vaultPath, func(path string, info os.FileInfo, err error) error { |
| | | if err != nil { |
| | | return nil // 跳过错误文件 |
| | | } |
| | | if info.IsDir() { |
| | | // 跳过隐藏目录 |
| | | if strings.HasPrefix(info.Name(), ".") && info.Name() != "." { |
| | | return filepath.SkipDir |
| | | } |
| | | return nil |
| | | } |
| | | if !strings.HasSuffix(path, ".md") { |
| | | return nil |
| | | } |
| | | |
| | | relPath, _ := filepath.Rel(vaultPath, path) |
| | | meta, err := ParseFile(path, relPath) |
| | | if err != nil { |
| | | return nil // 跳过解析失败的文件 |
| | | } |
| | | files = append(files, meta) |
| | | return nil |
| | | }) |
| | | |
| | | return files, err |
| | | } |
| New file |
| | |
| | | package vault |
| | | |
| | | import ( |
| | | "os" |
| | | "path/filepath" |
| | | "testing" |
| | | ) |
| | | |
| | | func TestScanVault(t *testing.T) { |
| | | tmpDir := t.TempDir() |
| | | |
| | | // 创建测试目录结构 |
| | | os.MkdirAll(filepath.Join(tmpDir, "FAQ", "充装类"), 0755) |
| | | os.MkdirAll(filepath.Join(tmpDir, "笔记"), 0755) |
| | | os.MkdirAll(filepath.Join(tmpDir, ".obsidian"), 0755) // 应被跳过 |
| | | |
| | | os.WriteFile(filepath.Join(tmpDir, "FAQ", "充装类", "001-test.md"), []byte(`--- |
| | | title: 测试FAQ |
| | | tags: [充装] |
| | | --- |
| | | 内容 |
| | | `), 0644) |
| | | |
| | | os.WriteFile(filepath.Join(tmpDir, "笔记", "note.md"), []byte(`--- |
| | | title: 笔记 |
| | | --- |
| | | 笔记内容 |
| | | `), 0644) |
| | | |
| | | os.WriteFile(filepath.Join(tmpDir, ".obsidian", "config.json"), []byte(`{}`), 0644) |
| | | |
| | | files, err := ScanVault(tmpDir) |
| | | if err != nil { |
| | | t.Fatalf("ScanVault failed: %v", err) |
| | | } |
| | | |
| | | if len(files) != 2 { |
| | | t.Errorf("file count = %d, want 2", len(files)) |
| | | } |
| | | } |
| New file |
| | |
| | | package vault |
| | | |
| | | // SectionConfig 定义板块配置 |
| | | type SectionConfig struct { |
| | | Name string |
| | | SubPaths map[string]string // 子板块名 -> 路径 |
| | | } |
| | | |
| | | // RootSections 板块配置(对应 Python 版 ROOT_SECTIONS) |
| | | var RootSections = map[string]*SectionConfig{ |
| | | "实体": {Name: "实体", SubPaths: map[string]string{"_root": "实体"}}, |
| | | "知识": {Name: "知识", SubPaths: map[string]string{ |
| | | "平台知识": "知识/平台知识", |
| | | "典型案例": "知识/典型案例", |
| | | "行业知识": "知识/行业知识", |
| | | "疑难问题": "知识/疑难问题", |
| | | "FAQ": "知识/FAQ", |
| | | "技术运维": "知识/技术运维", |
| | | }}, |
| | | "笔记": {Name: "笔记", SubPaths: map[string]string{"_root": "笔记"}}, |
| | | "FAQ": {Name: "FAQ", SubPaths: map[string]string{ |
| | | "充装类": "FAQ/充装类", |
| | | "配送类": "FAQ/配送类", |
| | | "平台操作类": "FAQ/平台操作类", |
| | | "数据同步类": "FAQ/数据同步类", |
| | | "硬件类": "FAQ/硬件类", |
| | | "网络类": "FAQ/网络类", |
| | | "综合类": "FAQ/综合类", |
| | | }}, |
| | | "案例": {Name: "案例", SubPaths: map[string]string{"_root": "案例"}}, |
| | | "文档": {Name: "文档", SubPaths: map[string]string{"_root": "文档"}}, |
| | | "行业": {Name: "行业", SubPaths: map[string]string{"_root": "行业"}}, |
| | | "内部工具": {Name: "内部工具", SubPaths: map[string]string{"_root": "内部工具"}}, |
| | | "设备": {Name: "设备", SubPaths: map[string]string{"_root": "设备"}}, |
| | | } |
| | | |
| | | // GetSection 根据文件路径判断所属板块 |
| | | func GetSection(relPath string) string { |
| | | for section, cfg := range RootSections { |
| | | for _, subPath := range cfg.SubPaths { |
| | | if subPath == "." || subPath == section { |
| | | continue |
| | | } |
| | | if len(relPath) >= len(subPath) && relPath[:len(subPath)] == subPath { |
| | | return section |
| | | } |
| | | } |
| | | if len(relPath) >= len(section) && relPath[:len(section)] == section { |
| | | return section |
| | | } |
| | | } |
| | | return "其他" |
| | | } |