cmd/explore.go
New file
@@ -0,0 +1,111 @@
package cmd
import (
   "encoding/json"
   "fmt"
   "os"
   "strings"
   "github.com/aisim/kb-cli/internal/index"
   "github.com/aisim/kb-cli/internal/llm"
   "github.com/aisim/kb-cli/internal/search"
   "github.com/spf13/cobra"
)
var (
   exploreBudget int
   exploreTopN   int
   exploreJSON   bool
)
var exploreCmd = &cobra.Command{
   Use:   "explore <问题>",
   Short: "一次调用获取相关文档原文 + 关联清单(供 agent 使用)",
   Long: `# explore - 精准上下文
kb-cli explore <问题> [--budget 字节] [--top N] [--json] # 按字节预算返回相关文档原文、关联文档、悬空链接`,
   Args: cobra.MinimumNArgs(1),
   RunE: runExplore,
}
func init() {
   rootCmd.AddCommand(exploreCmd)
   exploreCmd.Flags().IntVar(&exploreBudget, "budget", 0, "字节预算(0=配置默认 16000)")
   exploreCmd.Flags().IntVar(&exploreTopN, "top", 0, "文档数(0=配置默认 5)")
   exploreCmd.Flags().BoolVar(&exploreJSON, "json", false, "JSON 输出")
   // 参数不足时显示 help(与 search 命令同款模式)
   exploreCmd.SetUsageTemplate(exploreCmd.Long)
}
func runExplore(cmd *cobra.Command, args []string) error {
   // 打开索引
   store, err := index.Open(dbPath)
   if err != nil {
      return fmt.Errorf("打开索引失败: %w", err)
   }
   defer store.Close()
   // pre-flight:快速检查索引是否有变更(只 stat 比对,不读内容)
   dirty, err := index.QuickCheck(store, vaultPath)
   if err != nil {
      return fmt.Errorf("检查索引状态失败: %w", err)
   }
   if dirty {
      fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
      if err := syncIndex(store); err != nil {
         return fmt.Errorf("增量同步失败: %w", err)
      }
   }
   // 关键词按词拆分(与 search 一致):长复合词走 LIKE 整串匹配,文档里
   // "电子秤补气失败" 通常不是连续子串,拆成 电子秤/补气失败 才能命中
   var allKeywords []string
   for _, arg := range args {
      allKeywords = append(allKeywords, strings.Fields(arg)...)
   }
   if len(allKeywords) == 0 {
      return fmt.Errorf("请提供至少一个关键词")
   }
   // 默认值优先读配置(config.yaml 的 explore 节),读不到再用代码缺省
   budget := exploreBudget
   topN := exploreTopN
   cfg := llm.LoadConfig()
   if budget <= 0 && cfg.Explore.DefaultBudget > 0 {
      budget = cfg.Explore.DefaultBudget
   }
   if topN <= 0 && cfg.Explore.TopN > 0 {
      topN = cfg.Explore.TopN
   }
   hardBudget := cfg.Explore.HardBudget // 0 = Explore 内部用缺省 32000
   res, err := search.Explore(store, allKeywords, search.ExploreOptions{Budget: budget, TopN: topN, HardBudget: hardBudget})
   if err != nil {
      return fmt.Errorf("explore 失败: %w", err)
   }
   if len(res.Docs) == 0 {
      fmt.Println("未找到相关文档")
      return nil
   }
   if exploreJSON {
      b, _ := json.MarshalIndent(res, "", "  ")
      fmt.Println(string(b))
      return nil
   }
   for _, d := range res.Docs {
      fmt.Printf("## %s(%s,score %d)\n", d.Title, d.Path, d.Score)
      fmt.Print(d.Body)
      fmt.Println()
      if rel, ok := res.Related[d.Path]; ok && len(rel) > 0 {
         fmt.Printf("关联: %s\n", strings.Join(rel, ", "))
      }
      fmt.Println()
   }
   if len(res.UnresolvedLinks) > 0 {
      fmt.Printf("⚠️ 悬空链接: %s\n", strings.Join(res.UnresolvedLinks, ", "))
   }
   fmt.Println("以上为文档原文直出,agent 无需再读文件")
   return nil
}