ax_rd
3 hours ago 62a9f6223745c0df42be1f4226f31dfb10c83ea8
internal/index/sqlite.go
@@ -6,6 +6,8 @@
   "fmt"
   "os"
   "path/filepath"
   "sort"
   "strings"
   _ "github.com/mattn/go-sqlite3"
   "github.com/aisim/kb-cli/internal/graph"
@@ -13,7 +15,8 @@
// Store SQLite 存储层
type Store struct {
   db *sql.DB
   db     *sql.DB
   dbPath string
}
// Open 打开或创建数据库
@@ -29,11 +32,15 @@
      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
}
@@ -86,21 +93,24 @@
// ClearData 清空数据(重建前调用)
func (s *Store) ClearData() error {
   _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
   _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;")
   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
   }
@@ -110,9 +120,108 @@
// 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
}
// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
   _, err := s.db.Exec(`
      INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
      SELECT ?, ?, ?, 'pending'
      WHERE NOT EXISTS (
         SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
      fromNode, linkText, nameTail, fromNode, linkText)
   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
}
@@ -177,3 +286,381 @@
   return links, nil
}
// GetUnresolvedLinks 某文档的悬空链接文本列表
func (s *Store) GetUnresolvedLinks(path string) ([]string, error) {
   rows, err := s.db.Query(`
      SELECT u.link_text FROM unresolved_links u
      JOIN nodes n ON n.id = u.from_node WHERE n.path = ?`, path)
   if err != nil {
      return nil, err
   }
   defer rows.Close()
   var links []string
   for rows.Next() {
      var l string
      rows.Scan(&l)
      links = append(links, l)
   }
   return links, rows.Err()
}
// 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, provenance
            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, &e.Provenance); 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, &section, &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, &section, &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"`
}