| | |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "sort" |
| | | "strings" |
| | | |
| | | _ "github.com/mattn/go-sqlite3" |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | |
| | | |
| | | // Store SQLite 存储层 |
| | | type Store struct { |
| | | db *sql.DB |
| | | db *sql.DB |
| | | dbPath string |
| | | } |
| | | |
| | | // Open 打开或创建数据库 |
| | |
| | | return nil, fmt.Errorf("打开数据库失败: %w", err) |
| | | } |
| | | |
| | | s := &Store{db: db} |
| | | s := &Store{db: db, dbPath: dbPath} |
| | | if err := s.initTables(); err != nil { |
| | | db.Close() |
| | | return nil, err |
| | | } |
| | | if err := s.migrate(); err != nil { |
| | | db.Close() |
| | | return nil, err |
| | | } |
| | | return s, nil |
| | | } |
| | | |
| | |
| | | return err |
| | | } |
| | | |
| | | // InsertNode 插入节点 |
| | | // 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) |
| | | aliasesJSON, _ := json.Marshal(n.Aliases) |
| | | |
| | | result, err := s.db.Exec(` |
| | | INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?) |
| | | INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status, |
| | | content_fts, size, mtime, content_hash) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | `, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON), |
| | | string(wikilinksJSON), n.Content) |
| | | string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, |
| | | n.Size, n.Mtime, n.ContentHash) |
| | | if err != nil { |
| | | return 0, err |
| | | } |
| | |
| | | // 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) |
| | | INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance) |
| | | VALUES (?, ?, ?, ?, ?) |
| | | `, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance) |
| | | return err |
| | | } |
| | | |
| | | // FileStat 索引中的文件指纹 |
| | | type FileStat struct { |
| | | Path string |
| | | Size int64 |
| | | Mtime int64 |
| | | ContentHash string |
| | | } |
| | | |
| | | // GetFileStats 返回所有已索引文件的指纹 |
| | | func (s *Store) GetFileStats() (map[string]FileStat, error) { |
| | | rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | defer rows.Close() |
| | | m := make(map[string]FileStat) |
| | | for rows.Next() { |
| | | var st FileStat |
| | | var hash sql.NullString |
| | | if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil { |
| | | return nil, err |
| | | } |
| | | st.ContentHash = hash.String |
| | | m[st.Path] = st |
| | | } |
| | | return m, rows.Err() |
| | | } |
| | | |
| | | // UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS) |
| | | func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error { |
| | | tagsJSON, _ := json.Marshal(n.Tags) |
| | | entitiesJSON, _ := json.Marshal(n.Entities) |
| | | wikilinksJSON, _ := json.Marshal(n.Wikilinks) |
| | | aliasesJSON, _ := json.Marshal(n.Aliases) |
| | | _, err := s.db.Exec(` |
| | | INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status, |
| | | content_fts, size, mtime, content_hash) |
| | | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | ON CONFLICT(path) DO UPDATE SET |
| | | title = excluded.title, |
| | | section = excluded.section, |
| | | tags = excluded.tags, |
| | | entities = excluded.entities, |
| | | wikilinks = excluded.wikilinks, |
| | | aliases = excluded.aliases, |
| | | status = excluded.status, |
| | | content_fts = excluded.content_fts, |
| | | size = excluded.size, |
| | | mtime = excluded.mtime, |
| | | content_hash = excluded.content_hash, |
| | | updated_at = datetime('now')`, |
| | | n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON), |
| | | string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash) |
| | | return err |
| | | } |
| | | |
| | | // DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links |
| | | func (s *Store) DeleteNode(path string) error { |
| | | tx, err := s.db.Begin() |
| | | if err != nil { |
| | | return err |
| | | } |
| | | var id int64 |
| | | if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil { |
| | | tx.Rollback() |
| | | return nil // 不存在,视为成功 |
| | | } |
| | | if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil { |
| | | tx.Rollback() |
| | | return err |
| | | } |
| | | if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil { |
| | | tx.Rollback() |
| | | return err |
| | | } |
| | | if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil { |
| | | tx.Rollback() |
| | | return err |
| | | } |
| | | return tx.Commit() |
| | | } |
| | | |
| | | // DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边) |
| | | func (s *Store) DeleteNodeEdges(nodeID int64) error { |
| | | _, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID) |
| | | return err |
| | | } |
| | | |
| | |
| | | err := s.db.QueryRow("SELECT COUNT(*) FROM edges").Scan(&count) |
| | | return count, err |
| | | } |
| | | |
| | | // GetNodeLinks 获取节点的关联链接(wikilink 目标) |
| | | func (s *Store) GetNodeLinks(nodeID int64) ([]string, error) { |
| | | query := ` |
| | | SELECT n.path |
| | | FROM edges e |
| | | JOIN nodes n ON n.id = e.to_node |
| | | WHERE e.from_node = ? AND e.relation = 'wikilink' |
| | | ` |
| | | rows, err := s.db.Query(query, nodeID) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询链接失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var links []string |
| | | for rows.Next() { |
| | | var path string |
| | | if err := rows.Scan(&path); err != nil { |
| | | return nil, fmt.Errorf("扫描链接失败: %w", err) |
| | | } |
| | | links = append(links, path) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历链接失败: %w", err) |
| | | } |
| | | |
| | | return links, nil |
| | | } |
| | | |
| | | // NodeInfo 节点基本信息(用于 GC) |
| | | type NodeInfo struct { |
| | | ID int64 |
| | | Path string |
| | | } |
| | | |
| | | // GetAllNodes 获取所有节点(用于 GC 检查) |
| | | func (s *Store) GetAllNodes() ([]NodeInfo, error) { |
| | | rows, err := s.db.Query("SELECT id, path FROM nodes") |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var nodes []NodeInfo |
| | | for rows.Next() { |
| | | var n NodeInfo |
| | | if err := rows.Scan(&n.ID, &n.Path); err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | nodes = append(nodes, n) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历节点失败: %w", err) |
| | | } |
| | | |
| | | return nodes, nil |
| | | } |
| | | |
| | | // DeleteNodesByPaths 删除指定路径的节点及其关联边 |
| | | func (s *Store) DeleteNodesByPaths(paths []string) (int, error) { |
| | | if len(paths) == 0 { |
| | | return 0, nil |
| | | } |
| | | |
| | | // 构建 IN 子句 |
| | | placeholders := make([]string, len(paths)) |
| | | args := make([]interface{}, len(paths)) |
| | | for i, path := range paths { |
| | | placeholders[i] = "?" |
| | | args[i] = path |
| | | } |
| | | |
| | | tx, err := s.db.Begin() |
| | | if err != nil { |
| | | return 0, fmt.Errorf("开始事务失败: %w", err) |
| | | } |
| | | |
| | | // 先删除关联边 |
| | | query := fmt.Sprintf(` |
| | | DELETE FROM edges |
| | | WHERE from_node IN (SELECT id FROM nodes WHERE path IN (%s)) |
| | | OR to_node IN (SELECT id FROM nodes WHERE path IN (%s)) |
| | | `, strings.Join(placeholders, ","), strings.Join(placeholders, ",")) |
| | | |
| | | // 参数需要重复两次 |
| | | allArgs := append(args, args...) |
| | | _, err = tx.Exec(query, allArgs...) |
| | | if err != nil { |
| | | tx.Rollback() |
| | | return 0, fmt.Errorf("删除边失败: %w", err) |
| | | } |
| | | |
| | | // 删除节点 |
| | | query = fmt.Sprintf("DELETE FROM nodes WHERE path IN (%s)", strings.Join(placeholders, ",")) |
| | | result, err := tx.Exec(query, args...) |
| | | if err != nil { |
| | | tx.Rollback() |
| | | return 0, fmt.Errorf("删除节点失败: %w", err) |
| | | } |
| | | |
| | | if err := tx.Commit(); err != nil { |
| | | return 0, fmt.Errorf("提交事务失败: %w", err) |
| | | } |
| | | |
| | | affected, _ := result.RowsAffected() |
| | | return int(affected), nil |
| | | } |
| | | |
| | | // GetRelationStats 获取关系类型分布统计 |
| | | func (s *Store) GetRelationStats() (map[string]int, error) { |
| | | rows, err := s.db.Query(` |
| | | SELECT relation, COUNT(*) as count |
| | | FROM edges |
| | | GROUP BY relation |
| | | ORDER BY count DESC |
| | | `) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询关系统计失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | stats := make(map[string]int) |
| | | for rows.Next() { |
| | | var relation string |
| | | var count int |
| | | if err := rows.Scan(&relation, &count); err != nil { |
| | | return nil, fmt.Errorf("扫描关系统计失败: %w", err) |
| | | } |
| | | stats[relation] = count |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历关系统计失败: %w", err) |
| | | } |
| | | |
| | | return stats, nil |
| | | } |
| | | |
| | | // FindNodesByKeyword 根据关键词查找节点(模糊匹配标题、路径、标签、实体) |
| | | func (s *Store) FindNodesByKeyword(keyword string) ([]*graph.Node, error) { |
| | | keyword = "%" + keyword + "%" |
| | | query := ` |
| | | SELECT id, path, title, section, tags, entities, wikilinks, content_fts |
| | | FROM nodes |
| | | WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ? |
| | | LIMIT 20 |
| | | ` |
| | | rows, err := s.db.Query(query, keyword, keyword, keyword, keyword) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var nodes []*graph.Node |
| | | for rows.Next() { |
| | | var n graph.Node |
| | | var tagsJSON, entitiesJSON, wikilinksJSON string |
| | | var content sql.NullString |
| | | |
| | | err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, |
| | | &tagsJSON, &entitiesJSON, &wikilinksJSON, &content) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | |
| | | json.Unmarshal([]byte(tagsJSON), &n.Tags) |
| | | json.Unmarshal([]byte(entitiesJSON), &n.Entities) |
| | | json.Unmarshal([]byte(wikilinksJSON), &n.Wikilinks) |
| | | if content.Valid { |
| | | n.Content = content.String |
| | | } |
| | | |
| | | nodes = append(nodes, &n) |
| | | } |
| | | |
| | | if err := rows.Err(); err != nil { |
| | | return nil, fmt.Errorf("遍历节点失败: %w", err) |
| | | } |
| | | |
| | | return nodes, nil |
| | | } |
| | | |
| | | // GetNodeEdges 获取节点的关联边(支持关系类型过滤和深度查询) |
| | | func (s *Store) GetNodeEdges(nodeID int64, relation string, depth int) ([]*graph.Edge, error) { |
| | | if depth < 1 { |
| | | depth = 1 |
| | | } |
| | | if depth > 3 { |
| | | depth = 3 |
| | | } |
| | | |
| | | var edges []*graph.Edge |
| | | visited := make(map[int64]bool) |
| | | currentLevel := []int64{nodeID} |
| | | |
| | | for d := 0; d < depth; d++ { |
| | | var nextLevel []int64 |
| | | |
| | | for _, id := range currentLevel { |
| | | if visited[id] && d > 0 { |
| | | continue |
| | | } |
| | | visited[id] = true |
| | | |
| | | query := ` |
| | | SELECT id, from_node, to_node, relation, label |
| | | FROM edges |
| | | WHERE from_node = ? |
| | | ` |
| | | args := []interface{}{id} |
| | | |
| | | if relation != "" { |
| | | query += " AND relation = ?" |
| | | args = append(args, relation) |
| | | } |
| | | |
| | | rows, err := s.db.Query(query, args...) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询边失败: %w", err) |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var e graph.Edge |
| | | var edgeID int64 |
| | | if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil { |
| | | rows.Close() |
| | | return nil, fmt.Errorf("扫描边失败: %w", err) |
| | | } |
| | | edges = append(edges, &e) |
| | | nextLevel = append(nextLevel, e.ToNode) |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | currentLevel = nextLevel |
| | | } |
| | | |
| | | return edges, nil |
| | | } |
| | | |
| | | // FindRelatedNodes 查找与关键词相关的所有节点(通过共同标签、实体、wikilink) |
| | | func (s *Store) FindRelatedNodes(keyword string, topN int) ([]RelatedNode, error) { |
| | | // 先找到匹配关键词的节点 |
| | | keyword = "%" + keyword + "%" |
| | | query := ` |
| | | SELECT id, path, title, section, tags, entities |
| | | FROM nodes |
| | | WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ? |
| | | LIMIT 5 |
| | | ` |
| | | rows, err := s.db.Query(query, keyword, keyword, keyword, keyword) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("查询节点失败: %w", err) |
| | | } |
| | | defer rows.Close() |
| | | |
| | | var seedNodes []*graph.Node |
| | | for rows.Next() { |
| | | var n graph.Node |
| | | var tagsJSON, entitiesJSON string |
| | | err := rows.Scan(&n.ID, &n.Path, &n.Title, &n.Section, &tagsJSON, &entitiesJSON) |
| | | if err != nil { |
| | | return nil, fmt.Errorf("扫描节点失败: %w", err) |
| | | } |
| | | json.Unmarshal([]byte(tagsJSON), &n.Tags) |
| | | json.Unmarshal([]byte(entitiesJSON), &n.Entities) |
| | | seedNodes = append(seedNodes, &n) |
| | | } |
| | | |
| | | if len(seedNodes) == 0 { |
| | | return []RelatedNode{}, nil |
| | | } |
| | | |
| | | // 收集种子节点的标签和实体 |
| | | tagSet := make(map[string]bool) |
| | | entitySet := make(map[string]bool) |
| | | for _, n := range seedNodes { |
| | | for _, tag := range n.Tags { |
| | | tagSet[tag] = true |
| | | } |
| | | for _, entity := range n.Entities { |
| | | entitySet[entity] = true |
| | | } |
| | | } |
| | | |
| | | // 查找共享标签或实体的节点 |
| | | relevanceMap := make(map[int64]int) |
| | | pathMap := make(map[int64]string) |
| | | titleMap := make(map[int64]string) |
| | | sectionMap := make(map[int64]string) |
| | | tagsMap := make(map[int64][]string) |
| | | |
| | | // 通过标签查找 |
| | | for tag := range tagSet { |
| | | tagPattern := "%" + tag + "%" |
| | | rows, err := s.db.Query(` |
| | | SELECT id, path, title, section, tags |
| | | FROM nodes |
| | | WHERE tags LIKE ? |
| | | `, tagPattern) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var id int64 |
| | | var path, title, section, tagsJSON string |
| | | if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil { |
| | | rows.Close() |
| | | continue |
| | | } |
| | | relevanceMap[id]++ |
| | | pathMap[id] = path |
| | | titleMap[id] = title |
| | | sectionMap[id] = section |
| | | var tags []string |
| | | json.Unmarshal([]byte(tagsJSON), &tags) |
| | | tagsMap[id] = tags |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | // 通过实体查找 |
| | | for entity := range entitySet { |
| | | entityPattern := "%" + entity + "%" |
| | | rows, err := s.db.Query(` |
| | | SELECT id, path, title, section, tags |
| | | FROM nodes |
| | | WHERE entities LIKE ? |
| | | `, entityPattern) |
| | | if err != nil { |
| | | continue |
| | | } |
| | | |
| | | for rows.Next() { |
| | | var id int64 |
| | | var path, title, section, tagsJSON string |
| | | if err := rows.Scan(&id, &path, &title, §ion, &tagsJSON); err != nil { |
| | | rows.Close() |
| | | continue |
| | | } |
| | | relevanceMap[id]++ |
| | | pathMap[id] = path |
| | | titleMap[id] = title |
| | | sectionMap[id] = section |
| | | var tags []string |
| | | json.Unmarshal([]byte(tagsJSON), &tags) |
| | | tagsMap[id] = tags |
| | | } |
| | | rows.Close() |
| | | } |
| | | |
| | | // 转换为结果列表 |
| | | var results []RelatedNode |
| | | for id, relevance := range relevanceMap { |
| | | results = append(results, RelatedNode{ |
| | | ID: id, |
| | | Path: pathMap[id], |
| | | Title: titleMap[id], |
| | | Section: sectionMap[id], |
| | | Tags: tagsMap[id], |
| | | Relevance: relevance, |
| | | }) |
| | | } |
| | | |
| | | // 按相关度排序 |
| | | sort.Slice(results, func(i, j int) bool { |
| | | return results[i].Relevance > results[j].Relevance |
| | | }) |
| | | |
| | | // 限制返回数量 |
| | | if topN > 0 && len(results) > topN { |
| | | results = results[:topN] |
| | | } |
| | | |
| | | return results, nil |
| | | } |
| | | |
| | | // RelatedNode 相关节点 |
| | | type RelatedNode struct { |
| | | ID int64 `json:"id"` |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Tags []string `json:"tags"` |
| | | Relevance int `json:"relevance"` |
| | | } |