1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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
}