ax_rd
3 hours ago 62a9f6223745c0df42be1f4226f31dfb10c83ea8
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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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)
}
 
// ExploreOptions explore 参数
type ExploreOptions struct {
    Budget     int // 字节预算(0 = 用配置默认)
    TopN       int // 0 = 用配置默认
    HardBudget int // 字节硬上限(0 = 用代码缺省 32000)
}
 
// 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 := ExpandCJKKeywords(keywords)
    budget := cfg.Budget
    if budget <= 0 {
        budget = 16000 // 代码缺省(config 读取在 cmd 层完成)
    }
    if budget > 32000 {
        budget = 32000 // 硬上限缺省
    }
    // 配置显式给出的硬上限优先(cmd 层读 config.yaml 的 explore.hard_budget)
    if cfg.HardBudget > 0 && budget > cfg.HardBudget {
        budget = cfg.HardBudget
    }
    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
        }
        // 两级回退:先用原词提取(精确);原词在任何段落都不连续出现时
        //(复合 CJK 词常见:bigram 召回了文档,但原词不连续)回退到展开词提取,
        // 避免大文档被 continue 静默丢弃、最终输出「未找到相关文档」。
        body := extractRelevantParagraphs(content, keywords, perDoc)
        if body == "" {
            body = extractRelevantParagraphs(content, searchKeywords, 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
}