feat: add index gc command for cleaning orphan nodes
- Add GetAllNodes() and DeleteNodesByPaths() to sqlite.go
- Implement runIndexGc() with path expansion for ~
- Update help format to match axin-cli style
- Fix duplicate init() functions in root.go
| | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | "github.com/spf13/cobra" |
| | |
| | | var indexCmd = &cobra.Command{ |
| | | Use: "index", |
| | | Short: "索引管理", |
| | | Long: `管理知识库索引`, |
| | | } |
| | | |
| | | var indexBuildCmd = &cobra.Command{ |
| | | Use: "build", |
| | | Short: "构建/重建索引", |
| | | Long: `构建或重建知识库索引`, |
| | | Short: "构建或重建知识库索引", |
| | | RunE: runIndexBuild, |
| | | } |
| | | |
| | | var indexStatusCmd = &cobra.Command{ |
| | | Use: "status", |
| | | Short: "查看索引状态", |
| | | Long: `查看索引状态信息`, |
| | | Short: "查看索引状态信息", |
| | | RunE: runIndexStatus, |
| | | } |
| | | |
| | | var indexGcCmd = &cobra.Command{ |
| | | Use: "gc", |
| | | Short: "清理孤立节点和边", |
| | | RunE: runIndexGc, |
| | | } |
| | | |
| | | func init() { |
| | | rootCmd.AddCommand(indexCmd) |
| | | indexCmd.AddCommand(indexBuildCmd) |
| | | indexCmd.AddCommand(indexStatusCmd) |
| | | indexCmd.AddCommand(indexGcCmd) |
| | | } |
| | | |
| | | func runIndexBuild(cmd *cobra.Command, args []string) error { |
| | |
| | | |
| | | return nil |
| | | } |
| | | |
| | | func runIndexGc(cmd *cobra.Command, args []string) error { |
| | | // 展开 ~ 为实际路径 |
| | | expandedVaultPath := vaultPath |
| | | if strings.HasPrefix(vaultPath, "~/") { |
| | | home, err := os.UserHomeDir() |
| | | if err != nil { |
| | | return fmt.Errorf("获取用户目录失败: %w", err) |
| | | } |
| | | expandedVaultPath = filepath.Join(home, vaultPath[2:]) |
| | | } |
| | | |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | | if err != nil { |
| | | return fmt.Errorf("打开索引失败: %w", err) |
| | | } |
| | | defer store.Close() |
| | | |
| | | fmt.Fprintln(os.Stderr, "正在清理孤立节点和边...") |
| | | |
| | | // 获取所有节点路径 |
| | | nodes, err := store.GetAllNodes() |
| | | if err != nil { |
| | | return fmt.Errorf("获取节点失败: %w", err) |
| | | } |
| | | |
| | | // 检查文件是否存在 |
| | | var orphanPaths []string |
| | | for _, node := range nodes { |
| | | fullPath := filepath.Join(expandedVaultPath, node.Path) |
| | | if _, err := os.Stat(fullPath); os.IsNotExist(err) { |
| | | orphanPaths = append(orphanPaths, node.Path) |
| | | } |
| | | } |
| | | |
| | | if len(orphanPaths) == 0 { |
| | | fmt.Fprintln(os.Stderr, "没有发现孤立节点") |
| | | return nil |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "发现 %d 个孤立节点,正在清理...\n", len(orphanPaths)) |
| | | |
| | | // 删除孤立节点 |
| | | deletedCount, err := store.DeleteNodesByPaths(orphanPaths) |
| | | if err != nil { |
| | | return fmt.Errorf("删除节点失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintf(os.Stderr, "已清理 %d 个孤立节点\n", deletedCount) |
| | | return nil |
| | | } |
| | |
| | | // 构建图 |
| | | g := graph.BuildGraph(files) |
| | | |
| | | // 写入节点 |
| | | // 写入节点,并记录 BuildGraph ID -> SQLite ID 的映射 |
| | | idMap := make(map[int64]int64) // BuildGraph ID -> SQLite ID |
| | | for _, n := range g.Nodes { |
| | | if _, err := store.InsertNode(n); err != nil { |
| | | sqliteID, err := store.InsertNode(n) |
| | | if err != nil { |
| | | return fmt.Errorf("插入节点失败 [%s]: %w", n.Path, err) |
| | | } |
| | | idMap[n.ID] = sqliteID |
| | | } |
| | | |
| | | // 写入边 |
| | | // 写入边,将 BuildGraph ID 转换为 SQLite ID |
| | | for _, e := range g.Edges { |
| | | if err := store.InsertEdge(e); err != nil { |
| | | fromID, ok := idMap[e.FromNode] |
| | | if !ok { |
| | | // 可能是虚拟节点(tag/entity),跳过 |
| | | continue |
| | | } |
| | | toID, ok := idMap[e.ToNode] |
| | | if !ok { |
| | | // 目标节点不存在,跳过 |
| | | continue |
| | | } |
| | | edge := &graph.Edge{ |
| | | FromNode: fromID, |
| | | ToNode: toID, |
| | | Relation: e.Relation, |
| | | Label: e.Label, |
| | | } |
| | | if err := store.InsertEdge(edge); err != nil { |
| | | return fmt.Errorf("插入边失败: %w", err) |
| | | } |
| | | } |
| | |
| | | import ( |
| | | "fmt" |
| | | "os" |
| | | "strings" |
| | | |
| | | "github.com/spf13/cobra" |
| | | ) |
| | |
| | | dbPath string |
| | | ) |
| | | |
| | | // buildLong 为父命令动态生成 Long,格式:# cmd - Short\n子命令详细用法 |
| | | func buildLong(cmd *cobra.Command) string { |
| | | var sb strings.Builder |
| | | sb.WriteString(fmt.Sprintf("# %s - %s\n", cmd.Name(), cmd.Short)) |
| | | for _, sub := range cmd.Commands() { |
| | | if sub.Hidden { |
| | | continue |
| | | } |
| | | if sub.Long != "" { |
| | | sb.WriteString(sub.Long) |
| | | // 确保 Long 末尾有换行 |
| | | if !strings.HasSuffix(sub.Long, "\n") { |
| | | sb.WriteString("\n") |
| | | } |
| | | } else { |
| | | sb.WriteString(fmt.Sprintf("%s # %s\n", sub.CommandPath(), sub.Short)) |
| | | } |
| | | } |
| | | return sb.String() |
| | | } |
| | | |
| | | var rootCmd = &cobra.Command{ |
| | | Use: "kb", |
| | | Short: "知识库 CLI 工具", |
| | | Long: "kb-cli: 知识库搜索与管理工具,支持知识图谱搜索", |
| | | Short: "知识库搜索与管理工具", |
| | | Long: `kb - 知识库搜索与管理工具 |
| | | |
| | | Global: [--vault=<路径>] [--db=<路径>]`, |
| | | } |
| | | |
| | | func Execute() { |
| | | // 为每个父命令动态生成 Long(包含子命令详细用法) |
| | | for _, cmd := range rootCmd.Commands() { |
| | | if cmd.HasAvailableSubCommands() && !cmd.Hidden { |
| | | cmd.Long = buildLong(cmd) |
| | | } |
| | | } |
| | | |
| | | // root Long = 头部 + 所有一级子命令的 Long |
| | | var sb strings.Builder |
| | | sb.WriteString(rootCmd.Long) |
| | | for _, cmd := range rootCmd.Commands() { |
| | | if cmd.Hidden { |
| | | continue |
| | | } |
| | | sb.WriteString("\n") |
| | | sb.WriteString(cmd.Long) |
| | | } |
| | | rootCmd.Long = sb.String() |
| | | |
| | | if err := rootCmd.Execute(); err != nil { |
| | | fmt.Fprintln(os.Stderr, err) |
| | | os.Exit(1) |
| | | cobra.CheckErr(err) |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | rootCmd.PersistentFlags().StringVar(&vaultPath, "vault", defaultVault, "知识库根目录") |
| | | rootCmd.PersistentFlags().StringVar(&dbPath, "db", defaultDB, "索引数据库路径") |
| | | |
| | | // 自定义 help 模板:只输出 Long |
| | | helpTemplate := `{{.Long}}` |
| | | rootCmd.SetUsageTemplate(helpTemplate) |
| | | rootCmd.SetHelpTemplate(helpTemplate) |
| | | |
| | | rootCmd.SilenceUsage = true |
| | | rootCmd.SilenceErrors = true |
| | | } |
| | |
| | | var searchCmd = &cobra.Command{ |
| | | Use: "search [keywords...]", |
| | | Short: "搜索知识库", |
| | | Long: `搜索知识库,支持关键词、扩展词、症状词`, |
| | | Args: cobra.MinimumNArgs(1), |
| | | RunE: runSearch, |
| | | Long: `# search - 搜索 |
| | | kb search <关键词> [--top N] [--expanded <词>] [--symptom <词>] [--with-content] [--with-links] [--json] # 搜索知识库,支持关键词、扩展词、症状词`, |
| | | Args: cobra.MinimumNArgs(1), |
| | | RunE: runSearch, |
| | | } |
| | | |
| | | func init() { |
| | |
| | | "fmt" |
| | | "os" |
| | | "path/filepath" |
| | | "strings" |
| | | |
| | | _ "github.com/mattn/go-sqlite3" |
| | | "github.com/aisim/kb-cli/internal/graph" |
| | |
| | | |
| | | 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 |
| | | } |