feat: explore 命令(字节预算 + 段落级原文直出 + 关联清单 + 悬空链接提示)
3 files added
1 files modified
| New file |
| | |
| | | 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 |
| | | } |
| | | |
| | | res, err := search.Explore(store, allKeywords, search.ExploreOptions{Budget: budget, TopN: topN}) |
| | | 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 |
| | | } |
| | |
| | | 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 |
| New file |
| | |
| | | package search |
| | | |
| | | import ( |
| | | "regexp" |
| | | "strings" |
| | | |
| | | "github.com/aisim/kb-cli/internal/index" |
| | | ) |
| | | |
| | | // headingRe 1-4 级 Markdown 标题 |
| | | var headingRe = regexp.MustCompile(`^#{1,4} .+$`) |
| | | |
| | | // splitParagraphs 按 1-4 级标题切段。无标题的文档整体为一段。 |
| | | func splitParagraphs(content string) []string { |
| | | lines := strings.Split(content, "\n") |
| | | var paras []string |
| | | var cur []string |
| | | flush := func() { |
| | | if len(cur) > 0 { |
| | | paras = append(paras, strings.TrimRight(strings.Join(cur, "\n"), "\n")+"\n") |
| | | cur = nil |
| | | } |
| | | } |
| | | for _, l := range lines { |
| | | if headingRe.MatchString(l) { |
| | | flush() |
| | | } |
| | | cur = append(cur, l) |
| | | } |
| | | flush() |
| | | return paras |
| | | } |
| | | |
| | | // extractRelevantParagraphs 返回命中关键词的段落(整段不截半句)。 |
| | | // 文档总长 <= budget 时整篇输出;无命中段落时输出空串; |
| | | // 预算约束:累计超预算的段落丢弃(不截半段)。 |
| | | func extractRelevantParagraphs(content string, keywords []string, budget int) string { |
| | | if budget <= 0 || len(content) <= budget { |
| | | return content |
| | | } |
| | | var out []string |
| | | for _, p := range splitParagraphs(content) { |
| | | for _, kw := range keywords { |
| | | if strings.Contains(p, kw) { |
| | | out = append(out, p) |
| | | break |
| | | } |
| | | } |
| | | } |
| | | // 预算约束:累计超预算的段落丢弃(不截半段) |
| | | var total int |
| | | kept := []string{} |
| | | for _, p := range out { |
| | | if total+len(p) > budget { |
| | | break |
| | | } |
| | | total += len(p) |
| | | kept = append(kept, p) |
| | | } |
| | | return strings.Join(kept, "") |
| | | } |
| | | |
| | | // extractWithBudget extractRelevantParagraphs 的预算版(budget<=0 视为无预算) |
| | | func extractWithBudget(content string, keywords []string, budget int) string { |
| | | if budget <= 0 { |
| | | budget = 100000 |
| | | } |
| | | return extractRelevantParagraphs(content, keywords, budget) |
| | | } |
| | | |
| | | // containsCJK 是否含 CJK 统一表意文字 |
| | | func containsCJK(s string) bool { |
| | | for _, r := range s { |
| | | if r >= 0x4E00 && r <= 0x9FFF { |
| | | return true |
| | | } |
| | | } |
| | | return false |
| | | } |
| | | |
| | | // expandKeywords 关键词展开:CJK 长词(>3 字符)拆成 bigram 滑动窗口 |
| | | // (FTS5 unicode61 把连续中文当整串单 token,长复合词整串 LIKE 匹配不到; |
| | | // 文档里 "电子秤补气失败" 通常不是连续子串,bigram 才能命中)。 |
| | | // 原词保留(段落命中判断时原词更精确)。 |
| | | func expandKeywords(keywords []string) []string { |
| | | var out []string |
| | | for _, kw := range keywords { |
| | | out = append(out, kw) |
| | | if containsCJK(kw) && len([]rune(kw)) > 3 { |
| | | runes := []rune(kw) |
| | | for i := 0; i+1 < len(runes); i++ { |
| | | bg := string(runes[i : i+2]) |
| | | out = append(out, bg) |
| | | } |
| | | } |
| | | } |
| | | return out |
| | | } |
| | | |
| | | // ExploreOptions explore 参数 |
| | | type ExploreOptions struct { |
| | | Budget int // 字节预算(0 = 用配置默认) |
| | | TopN int // 0 = 用配置默认 |
| | | } |
| | | |
| | | // ExploredDoc 入选文档及其输出正文 |
| | | type ExploredDoc struct { |
| | | Path string `json:"path"` |
| | | Title string `json:"title"` |
| | | Section string `json:"section"` |
| | | Score int `json:"score"` |
| | | Body string `json:"body"` |
| | | } |
| | | |
| | | // ExploreResult explore 结果 |
| | | type ExploreResult struct { |
| | | Docs []ExploredDoc `json:"docs"` |
| | | Related map[string][]string `json:"related"` |
| | | UnresolvedLinks []string `json:"unresolved_links"` |
| | | } |
| | | |
| | | // Explore 一次调用返回相关文档原文 + 关联清单 + 悬空链接 |
| | | func Explore(store *index.Store, keywords []string, cfg ExploreOptions) (*ExploreResult, error) { |
| | | if len(keywords) == 0 { |
| | | return nil, nil |
| | | } |
| | | // 段落命中用原词(精确),FTS/LIKE 检索用展开后的 bigram |
| | | searchKeywords := expandKeywords(keywords) |
| | | budget := cfg.Budget |
| | | if budget <= 0 { |
| | | budget = 16000 // 代码缺省(config 读取在 cmd 层完成) |
| | | } |
| | | if budget > 32000 { |
| | | budget = 32000 // 硬上限 |
| | | } |
| | | topN := cfg.TopN |
| | | if topN <= 0 { |
| | | topN = 5 |
| | | } |
| | | |
| | | opts := SearchOptions{TopN: topN} |
| | | results, err := Search(store, searchKeywords, opts) |
| | | if err != nil { |
| | | return nil, err |
| | | } |
| | | if len(results) == 0 { |
| | | return &ExploreResult{Related: map[string][]string{}}, nil |
| | | } |
| | | |
| | | // 按分数降序分配预算:每文档至少 800 字节 |
| | | res := &ExploreResult{Related: map[string][]string{}} |
| | | perDoc := budget / len(results) |
| | | if perDoc < 800 { |
| | | perDoc = 800 |
| | | } |
| | | for _, r := range results { |
| | | content, _, _, err := store.GetNodeContent(r.ID) |
| | | if err != nil || content == "" { |
| | | continue |
| | | } |
| | | body := extractRelevantParagraphs(content, keywords, perDoc) |
| | | if body == "" { |
| | | // 无命中段落但文档入选 → 整篇(若放得下),否则跳过 |
| | | if len(content) <= perDoc { |
| | | body = content |
| | | } else { |
| | | continue |
| | | } |
| | | } |
| | | res.Docs = append(res.Docs, ExploredDoc{ |
| | | Path: r.Path, Title: r.Title, Section: r.Section, |
| | | Score: r.Score, Body: body, |
| | | }) |
| | | // 关联清单 |
| | | if links, err := store.GetNodeLinks(r.ID); err == nil { |
| | | res.Related[r.Path] = links |
| | | } |
| | | } |
| | | |
| | | // 悬空链接提示:入选文档的 wikilinks 中未解析的 |
| | | for _, d := range res.Docs { |
| | | links, err := store.GetUnresolvedLinks(d.Path) |
| | | if err == nil { |
| | | res.UnresolvedLinks = append(res.UnresolvedLinks, links...) |
| | | } |
| | | } |
| | | return res, nil |
| | | } |
| New file |
| | |
| | | package search |
| | | |
| | | import ( |
| | | "testing" |
| | | ) |
| | | |
| | | // TestExploreParagraphExtraction 段落截取:只输出命中关键词的段落,整段不截半句 |
| | | func TestExploreParagraphExtraction(t *testing.T) { |
| | | content := "# 标题\n\n第一段讲称重。\n\n## 补气流程\n\n补气失败时先检查阀门。\n\n## 其他\n\n无关内容。\n" |
| | | got := extractRelevantParagraphs(content, []string{"补气"}, 100) |
| | | want := "## 补气流程\n\n补气失败时先检查阀门。\n" |
| | | if got != want { |
| | | t.Errorf("段落截取:\n got=%q\nwant=%q", got, want) |
| | | } |
| | | } |
| | | |
| | | // TestExploreWholeDocWhenSmall 文档短于预算时整篇输出 |
| | | func TestExploreWholeDocWhenSmall(t *testing.T) { |
| | | got := extractRelevantParagraphs("短文档\n", []string{"不存在"}, 10000) |
| | | if got != "短文档\n" { |
| | | t.Errorf("应整篇输出: %q", got) |
| | | } |
| | | } |
| | | |
| | | // TestExploreKeywordExpansion 长 CJK 词拆 bigram(原词保留,ASCII/短词不拆) |
| | | func TestExploreKeywordExpansion(t *testing.T) { |
| | | got := expandKeywords([]string{"电子秤补气失败", "补气", "abc"}) |
| | | want := []string{"电子秤补气失败", "电子", "子秤", "秤补", "补气", "气失", "失败", "补气", "abc"} |
| | | if len(got) != len(want) { |
| | | t.Fatalf("展开数量: got=%d want=%d (%v)", len(got), len(want), got) |
| | | } |
| | | for i := range want { |
| | | if got[i] != want[i] { |
| | | t.Errorf("第 %d 项: got=%q want=%q", i, got[i], want[i]) |
| | | } |
| | | } |
| | | } |
| | | |
| | | func TestExploreBudget(t *testing.T) { |
| | | // 用 extractRelevantParagraphs 的预算版验证:预算 20 字节,命中段落 30 字节 → 输出空(宁缺毋滥,不截半段) |
| | | got := extractWithBudget("## 段落\n\n这是一段超过预算的内容啊\n", []string{"段落"}, 20) |
| | | if got != "" { |
| | | t.Errorf("超预算段落应跳过: %q", got) |
| | | } |
| | | } |