1 files modified
19 files added
1279 ■■■■■ changed files
internal/graph/builder.go 96 ●●●●● patch | view | raw | blame | history
internal/graph/builder_test.go 54 ●●●●● patch | view | raw | blame | history
internal/graph/model.go 27 ●●●●● patch | view | raw | blame | history
internal/index/cache.go 48 ●●●●● patch | view | raw | blame | history
internal/index/cache_test.go 35 ●●●●● patch | view | raw | blame | history
internal/index/fts.go 101 ●●●●● patch | view | raw | blame | history
internal/index/fts_test.go 54 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 149 ●●●●● patch | view | raw | blame | history
internal/index/sqlite_test.go 66 ●●●●● patch | view | raw | blame | history
internal/output/formatter.go 40 ●●●●● patch | view | raw | blame | history
internal/output/formatter_test.go 41 ●●●●● patch | view | raw | blame | history
internal/search/engine.go 106 ●●●●● patch | view | raw | blame | history
internal/search/engine_test.go 98 ●●●●● patch | view | raw | blame | history
internal/search/scorer.go 66 ●●●●● patch | view | raw | blame | history
internal/vault/parser.go 96 ●●●●● patch | view | raw | blame | history
internal/vault/parser_test.go 71 ●●●●● patch | view | raw | blame | history
internal/vault/scanner.go 38 ●●●●● patch | view | raw | blame | history
internal/vault/scanner_test.go 40 ●●●●● patch | view | raw | blame | history
internal/vault/sections.go 53 ●●●●● patch | view | raw | blame | history
kb patch | view | raw | blame | history
internal/graph/builder.go
New file
@@ -0,0 +1,96 @@
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 // 简化版,后续可扩展
}
internal/graph/builder_test.go
New file
@@ -0,0 +1,54 @@
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)
    }
}
internal/graph/model.go
New file
@@ -0,0 +1,27 @@
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
}
internal/index/cache.go
New file
@@ -0,0 +1,48 @@
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
}
internal/index/cache_test.go
New file
@@ -0,0 +1,35 @@
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")
    }
}
internal/index/fts.go
New file
@@ -0,0 +1,101 @@
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
}
internal/index/fts_test.go
New file
@@ -0,0 +1,54 @@
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))
    }
}
internal/index/sqlite.go
New file
@@ -0,0 +1,149 @@
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
}
internal/index/sqlite_test.go
New file
@@ -0,0 +1,66 @@
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)
    }
}
internal/output/formatter.go
New file
@@ -0,0 +1,40 @@
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
}
internal/output/formatter_test.go
New file
@@ -0,0 +1,41 @@
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")
    }
}
internal/search/engine.go
New file
@@ -0,0 +1,106 @@
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
}
internal/search/engine_test.go
New file
@@ -0,0 +1,98 @@
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)
        }
    }
}
internal/search/scorer.go
New file
@@ -0,0 +1,66 @@
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
}
internal/vault/parser.go
New file
@@ -0,0 +1,96 @@
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
}
internal/vault/parser_test.go
New file
@@ -0,0 +1,71 @@
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)
    }
}
internal/vault/scanner.go
New file
@@ -0,0 +1,38 @@
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
}
internal/vault/scanner_test.go
New file
@@ -0,0 +1,40 @@
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))
    }
}
internal/vault/sections.go
New file
@@ -0,0 +1,53 @@
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 "其他"
}
kb
Binary files differ