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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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
}