From cb72759b568baf34281bd15f821ac9834c17bbb9 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Thu, 03 Sep 2026 14:24:29 +0800
Subject: [PATCH] merge: 合并 origin/master schema v2 重构(RWR/增量对账/explore),保留 tag/entity 实验于 experiment 分支 + 本地 expandPath 修复与 draft list 过滤

---
 cmd/explore.go |  111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 111 insertions(+), 0 deletions(-)

diff --git a/cmd/explore.go b/cmd/explore.go
new file mode 100644
index 0000000..eb808c3
--- /dev/null
+++ b/cmd/explore.go
@@ -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
+}

--
Gitblit v1.10.0