webctx 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CONTRIBUTORS.md +5 -16
- package/README.md +10 -3
- package/internal/app/alexandria.go +102 -0
- package/internal/app/app.go +4 -3
- package/internal/app/firecrawl_fallback.go +17 -4
- package/internal/app/research.go +526 -0
- package/internal/app/research_excerpt.go +149 -0
- package/internal/app/research_excerpt_test.go +47 -0
- package/internal/app/research_openapi.go +289 -0
- package/internal/app/research_openapi_test.go +140 -0
- package/internal/app/research_test.go +150 -0
- package/internal/app/structured_source.go +249 -0
- package/internal/app/structured_source_test.go +70 -0
- package/internal/app/tools.go +20 -2
- package/package.json +1 -1
- package/pkg/origo/server.go +10 -3
- package/pkg/origo/server_test.go +15 -0
- package/pkg/retrieval/retrieval.go +6 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/url"
|
|
7
|
+
"regexp"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Research deliberately works with source text, not generated answers. The
|
|
15
|
+
// coding agent gets exact excerpts and URLs it can independently inspect.
|
|
16
|
+
// One follow-up level and fixed budgets prevent uncontrolled crawls.
|
|
17
|
+
const (
|
|
18
|
+
// Coding agents value complete code and request contracts more than tiny
|
|
19
|
+
// snippets. Keep ordinary documentation pages whole when reasonably sized.
|
|
20
|
+
focusedPrimaryBudget = 22000
|
|
21
|
+
focusedRelatedBudget = 6500
|
|
22
|
+
searchSourceBudget = 4000
|
|
23
|
+
maxRelatedSources = 2
|
|
24
|
+
maxSearchSources = 3
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
var (
|
|
28
|
+
mdLinkPattern = regexp.MustCompile(`\[([^\]\n]{2,120})\]\(([^\s)]+)(?:\s+"[^"]*")?\)`)
|
|
29
|
+
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_-]{2,}`)
|
|
30
|
+
sourceWordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]{1,}`)
|
|
31
|
+
headingPattern = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
type researchSection struct {
|
|
35
|
+
index int
|
|
36
|
+
heading string
|
|
37
|
+
body string
|
|
38
|
+
score int
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type sourceCandidate struct {
|
|
42
|
+
url string
|
|
43
|
+
label string
|
|
44
|
+
score int
|
|
45
|
+
order int
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type sourceRead struct {
|
|
49
|
+
url string
|
|
50
|
+
content string
|
|
51
|
+
err error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ReadLinkFocused is backwards-compatible: URL-only calls still return the
|
|
55
|
+
// complete original source. A question makes the engine select excerpts and
|
|
56
|
+
// automatically inspect related documentation on the same host.
|
|
57
|
+
func ReadLinkFocused(rawURL, question string) (string, error) {
|
|
58
|
+
return readLinkFocused(rawURL, question, ReadLink)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func readLinkFocused(rawURL, question string, reader func(string) (string, error)) (string, error) {
|
|
62
|
+
if strings.TrimSpace(question) == "" {
|
|
63
|
+
return reader(rawURL)
|
|
64
|
+
}
|
|
65
|
+
if err := validateSourceURL(rawURL); err != nil {
|
|
66
|
+
return "", err
|
|
67
|
+
}
|
|
68
|
+
question = strings.TrimSpace(question)
|
|
69
|
+
// REST contract discovery is independent of the page scrape. Start it
|
|
70
|
+
// immediately so a slow Firecrawl read and a large OpenAPI download overlap
|
|
71
|
+
// instead of approaching the hosted MCP function timeout sequentially.
|
|
72
|
+
var openAPIDone chan openAPIContext
|
|
73
|
+
if asksForAPIContracts(question) {
|
|
74
|
+
openAPIDone = make(chan openAPIContext, 1)
|
|
75
|
+
go func() { openAPIDone <- discoverOpenAPIContract(rawURL, question) }()
|
|
76
|
+
}
|
|
77
|
+
primary, err := reader(rawURL)
|
|
78
|
+
if err != nil {
|
|
79
|
+
return "", err
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
links := relatedSourceLinks(primary, rawURL, question, maxRelatedSources)
|
|
83
|
+
if len(links) < maxRelatedSources {
|
|
84
|
+
// Many doc systems publish llms.txt as their navigation index. It is a
|
|
85
|
+
// cheap way to find exact API reference pages not linked from this page.
|
|
86
|
+
for _, candidate := range linksFromDocsIndex(rawURL, question, maxRelatedSources) {
|
|
87
|
+
if len(links) == maxRelatedSources {
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
duplicate := candidate.url == rawURL
|
|
91
|
+
for _, chosen := range links {
|
|
92
|
+
duplicate = duplicate || chosen.url == candidate.url
|
|
93
|
+
}
|
|
94
|
+
if !duplicate {
|
|
95
|
+
links = append(links, candidate)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
structuredDone := make(chan string, 1)
|
|
100
|
+
go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
|
|
101
|
+
more := readSources(links, reader)
|
|
102
|
+
parts := []string{
|
|
103
|
+
"# Source-grounded context",
|
|
104
|
+
"",
|
|
105
|
+
"**Question:** " + question,
|
|
106
|
+
"",
|
|
107
|
+
"## Primary source",
|
|
108
|
+
"**Source:** " + rawURL,
|
|
109
|
+
"",
|
|
110
|
+
focusExcerpt(primary, question, focusedPrimaryBudget),
|
|
111
|
+
}
|
|
112
|
+
successful := 1
|
|
113
|
+
for _, source := range more {
|
|
114
|
+
if source.err != nil {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
successful++
|
|
118
|
+
parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", focusExcerpt(source.content, question, focusedRelatedBudget))
|
|
119
|
+
}
|
|
120
|
+
if openAPIDone != nil {
|
|
121
|
+
if contract := <-openAPIDone; contract.Content != "" {
|
|
122
|
+
successful++
|
|
123
|
+
parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if structured := <-structuredDone; structured != "" {
|
|
127
|
+
parts = append(parts, "", structured)
|
|
128
|
+
}
|
|
129
|
+
parts = append(parts, "", fmt.Sprintf("**Coverage:** %d source document(s) inspected. Extracts are source-grounded, not an independently verified synthesis. REST commands assembled from an OpenAPI operation and its published example are labeled as such.", successful))
|
|
130
|
+
if failed := len(more) - (successful - 1); failed > 0 {
|
|
131
|
+
parts = append(parts, fmt.Sprintf("%d related page(s) could not be retrieved; the primary source is preserved.", failed))
|
|
132
|
+
}
|
|
133
|
+
return strings.Join(parts, "\n"), nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// enrichSearch gives agents real documentation content in their first search
|
|
137
|
+
// response, with the original ranked source list retained for navigation.
|
|
138
|
+
func enrichSearch(query string, ranked []SearchDoc) string {
|
|
139
|
+
return enrichSearchWithReader(query, ranked, ReadLink)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func enrichSearchWithReader(query string, ranked []SearchDoc, reader func(string) (string, error)) string {
|
|
143
|
+
chosen := chooseSearchSources(query, ranked, maxSearchSources)
|
|
144
|
+
if len(chosen) == 0 {
|
|
145
|
+
return ""
|
|
146
|
+
}
|
|
147
|
+
more := readSources(chosen, reader)
|
|
148
|
+
parts := []string{}
|
|
149
|
+
seen := make(map[string]bool)
|
|
150
|
+
for _, source := range more {
|
|
151
|
+
if source.err != nil {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
seen[source.url] = true
|
|
155
|
+
parts = append(parts, "### "+source.url, "", focusExcerpt(source.content, query, searchSourceBudget), "")
|
|
156
|
+
}
|
|
157
|
+
if len(parts) == 0 {
|
|
158
|
+
return ""
|
|
159
|
+
}
|
|
160
|
+
// Expand high-relevance documentation references from retrieved results,
|
|
161
|
+
// not arbitrary websites or unlimited recursive link trees.
|
|
162
|
+
var follow []sourceCandidate
|
|
163
|
+
for _, source := range more {
|
|
164
|
+
if source.err != nil || len(follow) >= 2 {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, query, 2) {
|
|
168
|
+
if seen[candidate.url] {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
seen[candidate.url] = true
|
|
172
|
+
follow = append(follow, candidate)
|
|
173
|
+
if len(follow) >= 2 {
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for _, source := range readSources(follow, reader) {
|
|
179
|
+
if source.err == nil {
|
|
180
|
+
parts = append(parts, "### Supporting reference: "+source.url, "", focusExcerpt(source.content, query, focusedRelatedBudget), "")
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return "## Retrieved source context\n\nThe following are excerpts from source pages, not an AI-generated answer. Compare versions and follow the URLs for full documents.\n\n" + strings.Join(parts, "\n")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func linksFromDocsIndex(rawURL, question string, limit int) []sourceCandidate {
|
|
187
|
+
u, err := url.Parse(rawURL)
|
|
188
|
+
if err != nil || u.Hostname() == "github.com" || directSourceLanguage(rawURL) != "" {
|
|
189
|
+
return nil
|
|
190
|
+
}
|
|
191
|
+
if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") && !strings.Contains(u.Path, "/reference/") {
|
|
192
|
+
return nil
|
|
193
|
+
}
|
|
194
|
+
indexURL := u.Scheme + "://" + u.Host + "/llms.txt"
|
|
195
|
+
if rawURL == indexURL {
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
199
|
+
defer cancel()
|
|
200
|
+
index, err := fetchPublicText(ctx, indexURL, 300000)
|
|
201
|
+
if err != nil || !strings.Contains(index, "](") {
|
|
202
|
+
return nil
|
|
203
|
+
}
|
|
204
|
+
return relatedSourceLinks(index, indexURL, question, limit)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func readSources(candidates []sourceCandidate, reader func(string) (string, error)) []sourceRead {
|
|
208
|
+
result := make([]sourceRead, len(candidates))
|
|
209
|
+
var wg sync.WaitGroup
|
|
210
|
+
for i, candidate := range candidates {
|
|
211
|
+
wg.Add(1)
|
|
212
|
+
go func(i int, candidate sourceCandidate) {
|
|
213
|
+
defer wg.Done()
|
|
214
|
+
content, err := reader(candidate.url)
|
|
215
|
+
result[i] = sourceRead{url: candidate.url, content: content, err: err}
|
|
216
|
+
}(i, candidate)
|
|
217
|
+
}
|
|
218
|
+
wg.Wait()
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func chooseSearchSources(query string, ranked []SearchDoc, limit int) []sourceCandidate {
|
|
223
|
+
items := make([]sourceCandidate, 0, limit)
|
|
224
|
+
terms := queryTerms(query)
|
|
225
|
+
for i, source := range ranked {
|
|
226
|
+
if i >= 18 {
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
if err := validateSourceURL(source.URL); err != nil || disallowedSourcePath(source.URL) {
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
u, _ := url.Parse(source.URL)
|
|
233
|
+
score := 18 - i + termScore(source.Title, terms)*2 + termScore(u.Hostname()+" "+u.Path, terms)
|
|
234
|
+
if strings.HasPrefix(u.Hostname(), "docs.") || strings.Contains(u.Path, "/docs/") || strings.Contains(u.Path, "/reference/") {
|
|
235
|
+
score += 7
|
|
236
|
+
}
|
|
237
|
+
if strings.Contains(u.Path, "/specification/") || strings.Contains(u.Path, "/api-reference/") || strings.Contains(u.Path, "/sdk/") {
|
|
238
|
+
score += 7
|
|
239
|
+
}
|
|
240
|
+
if u.Hostname() == "github.com" || strings.Contains(u.Path, "openapi") {
|
|
241
|
+
score += 5
|
|
242
|
+
}
|
|
243
|
+
if strings.Contains(u.Path, "/community/") || strings.Contains(u.Path, "/interest-groups/") {
|
|
244
|
+
score -= 16 // governance pages rarely contain usable API contracts
|
|
245
|
+
}
|
|
246
|
+
if strings.Contains(u.Path, "/blog/") || strings.HasPrefix(u.Hostname(), "blog.") {
|
|
247
|
+
score -= 5
|
|
248
|
+
}
|
|
249
|
+
if strings.Contains(u.Path, "/internal/") && u.Hostname() == "pkg.go.dev" {
|
|
250
|
+
score -= 12 // internal Go packages aren't public SDK contracts
|
|
251
|
+
}
|
|
252
|
+
if strings.Contains(strings.ToLower(query), "go sdk") && strings.Contains(strings.ToLower(source.Title+" "+u.Path), "go-sdk") {
|
|
253
|
+
score += 7
|
|
254
|
+
}
|
|
255
|
+
items = append(items, sourceCandidate{url: source.URL, label: source.Title, score: score, order: i})
|
|
256
|
+
}
|
|
257
|
+
sort.SliceStable(items, func(i, j int) bool { return items[i].score > items[j].score })
|
|
258
|
+
selected := make([]sourceCandidate, 0, limit)
|
|
259
|
+
seenHosts := make(map[string]int)
|
|
260
|
+
for _, item := range items {
|
|
261
|
+
if len(selected) == limit {
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
u, _ := url.Parse(item.url)
|
|
265
|
+
if seenHosts[u.Hostname()] >= 2 { // diversify while allowing two relevant docs pages
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
seenHosts[u.Hostname()]++
|
|
269
|
+
selected = append(selected, item)
|
|
270
|
+
}
|
|
271
|
+
return selected
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func relatedSourceLinks(markdown, baseURL, question string, limit int) []sourceCandidate {
|
|
275
|
+
base, err := url.Parse(baseURL)
|
|
276
|
+
if err != nil {
|
|
277
|
+
return nil
|
|
278
|
+
}
|
|
279
|
+
terms := queryTerms(question)
|
|
280
|
+
seen := map[string]bool{canonicalSourceURL(base): true}
|
|
281
|
+
var links []sourceCandidate
|
|
282
|
+
for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 300) {
|
|
283
|
+
destination := strings.TrimSpace(match[2])
|
|
284
|
+
if strings.Contains(destination, "\\") { // malformed Markdown-escaped URLs
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
ref, err := url.Parse(destination)
|
|
288
|
+
if err != nil {
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
u := base.ResolveReference(ref)
|
|
292
|
+
if !strings.EqualFold(base.Hostname(), u.Hostname()) || base.Port() != u.Port() || u.User != nil {
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if err := validateSourceURL(u.String()); err != nil || disallowedSourcePath(u.String()) || sensitiveURL(u) {
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
key := canonicalSourceURL(u)
|
|
299
|
+
if seen[key] {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
seen[key] = true
|
|
303
|
+
label := strings.TrimSpace(match[1])
|
|
304
|
+
score := termScore(label, terms)*4 + termScore(u.Path, terms)*3
|
|
305
|
+
if score == 0 { // no relevant relationship to the user's question
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
if len(terms) >= 4 && len(matchedLinkTerms(label+" "+u.Path, terms)) == 1 {
|
|
309
|
+
term := matchedLinkTerms(label+" "+u.Path, terms)[0]
|
|
310
|
+
if len(term) <= 5 { // don't expand generic links matching only 'MCP' or 'tools'
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
links = append(links, sourceCandidate{url: key, label: label, score: score, order: i})
|
|
315
|
+
}
|
|
316
|
+
sort.SliceStable(links, func(i, j int) bool {
|
|
317
|
+
if links[i].score == links[j].score {
|
|
318
|
+
return links[i].order < links[j].order
|
|
319
|
+
}
|
|
320
|
+
return links[i].score > links[j].score
|
|
321
|
+
})
|
|
322
|
+
if len(links) > limit {
|
|
323
|
+
links = links[:limit]
|
|
324
|
+
}
|
|
325
|
+
return links
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func matchedLinkTerms(text string, terms []string) []string {
|
|
329
|
+
var matched []string
|
|
330
|
+
for _, term := range terms {
|
|
331
|
+
if termScore(text, []string{term}) > 0 {
|
|
332
|
+
matched = append(matched, term)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return matched
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
func canonicalSourceURL(u *url.URL) string {
|
|
339
|
+
copy := *u
|
|
340
|
+
copy.Fragment = ""
|
|
341
|
+
copy.RawFragment = ""
|
|
342
|
+
return copy.String()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func sensitiveURL(u *url.URL) bool {
|
|
346
|
+
for name := range u.Query() {
|
|
347
|
+
name = strings.ToLower(name)
|
|
348
|
+
if strings.Contains(name, "token") || strings.Contains(name, "secret") || strings.Contains(name, "key") || strings.Contains(name, "auth") || strings.Contains(name, "signature") {
|
|
349
|
+
return true
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return false
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func disallowedSourcePath(raw string) bool {
|
|
356
|
+
u, err := url.Parse(raw)
|
|
357
|
+
if err != nil {
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
path := strings.ToLower(u.Path)
|
|
361
|
+
for _, suffix := range []string{".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".zip", ".mp4", ".mov", ".mp3", ".woff", ".woff2", ".pdf", ".dmg", ".exe"} {
|
|
362
|
+
if strings.HasSuffix(path, suffix) {
|
|
363
|
+
return true
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
func queryTerms(query string) []string {
|
|
370
|
+
stop := map[string]bool{"the": true, "and": true, "for": true, "from": true, "with": true, "that": true, "this": true, "what": true, "how": true, "when": true, "where": true, "does": true, "which": true, "using": true, "about": true, "into": true, "can": true, "are": true, "get": true, "use": true, "you": true, "your": true, "its": true, "have": true}
|
|
371
|
+
seen := make(map[string]bool)
|
|
372
|
+
var terms []string
|
|
373
|
+
for _, term := range sourceWordPattern.FindAllString(strings.ToLower(query), 40) {
|
|
374
|
+
if len(term) < 3 {
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if stop[term] || seen[term] {
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
seen[term] = true
|
|
381
|
+
terms = append(terms, term)
|
|
382
|
+
}
|
|
383
|
+
return terms
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func termScore(text string, terms []string) int {
|
|
387
|
+
// Compare words instead of substrings. Without this, an "api" search
|
|
388
|
+
// incorrectly matches "scraping" and chooses unrelated pages.
|
|
389
|
+
words := sourceWordPattern.FindAllString(strings.ToLower(text), -1)
|
|
390
|
+
count := 0
|
|
391
|
+
for _, term := range terms {
|
|
392
|
+
// Approximate inflection matching: pagination / paginate, authenticated /
|
|
393
|
+
// authentication, etc. Exact matches are weighted slightly higher.
|
|
394
|
+
root := term
|
|
395
|
+
if len(root) > 5 {
|
|
396
|
+
root = root[:5]
|
|
397
|
+
}
|
|
398
|
+
for _, word := range words {
|
|
399
|
+
if word == term {
|
|
400
|
+
count += 2
|
|
401
|
+
break
|
|
402
|
+
}
|
|
403
|
+
if len(root) >= 4 && strings.HasPrefix(word, root) {
|
|
404
|
+
count++
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return count
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
func splitResearchSections(markdown string, terms []string) []researchSection {
|
|
413
|
+
lines := strings.Split(markdown, "\n")
|
|
414
|
+
sections := make([]researchSection, 0, 20)
|
|
415
|
+
current := researchSection{heading: "Introduction"}
|
|
416
|
+
var body strings.Builder
|
|
417
|
+
inFence := false
|
|
418
|
+
flush := func() {
|
|
419
|
+
current.body = strings.TrimSpace(body.String())
|
|
420
|
+
if current.body != "" {
|
|
421
|
+
current.index = len(sections)
|
|
422
|
+
current.score = 5*termScore(current.heading, terms) + termScore(current.body, terms)
|
|
423
|
+
if strings.Contains(current.body, "```") && current.score > 0 {
|
|
424
|
+
current.score += 2
|
|
425
|
+
}
|
|
426
|
+
sections = append(sections, current)
|
|
427
|
+
}
|
|
428
|
+
body.Reset()
|
|
429
|
+
}
|
|
430
|
+
for _, line := range lines {
|
|
431
|
+
trimmed := strings.TrimSpace(line)
|
|
432
|
+
if !inFence {
|
|
433
|
+
if heading := headingPattern.FindStringSubmatch(trimmed); len(heading) == 2 {
|
|
434
|
+
flush()
|
|
435
|
+
current = researchSection{heading: heading[1]}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
body.WriteString(line)
|
|
439
|
+
body.WriteByte('\n')
|
|
440
|
+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
|
441
|
+
inFence = !inFence
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
flush()
|
|
445
|
+
return sections
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
func focusExcerpt(markdown, question string, budget int) string {
|
|
449
|
+
markdown = strings.TrimSpace(markdown)
|
|
450
|
+
if len(markdown) <= budget {
|
|
451
|
+
return markdown
|
|
452
|
+
}
|
|
453
|
+
if focused := focusedRawJSON(markdown, question, budget); focused != "" {
|
|
454
|
+
return focused
|
|
455
|
+
}
|
|
456
|
+
sections := splitResearchSections(markdown, queryTerms(question))
|
|
457
|
+
if len(sections) == 0 {
|
|
458
|
+
return boundedMarkdown(markdown, budget)
|
|
459
|
+
}
|
|
460
|
+
sorted := append([]researchSection(nil), sections...)
|
|
461
|
+
sort.SliceStable(sorted, func(i, j int) bool {
|
|
462
|
+
if sorted[i].score == sorted[j].score {
|
|
463
|
+
return sorted[i].index < sorted[j].index
|
|
464
|
+
}
|
|
465
|
+
return sorted[i].score > sorted[j].score
|
|
466
|
+
})
|
|
467
|
+
selected := make([]researchSection, 0, 5)
|
|
468
|
+
for _, section := range sorted {
|
|
469
|
+
if len(selected) >= 5 || (section.score == 0 && len(selected) > 0) {
|
|
470
|
+
break
|
|
471
|
+
}
|
|
472
|
+
selected = append(selected, section)
|
|
473
|
+
}
|
|
474
|
+
sort.Slice(selected, func(i, j int) bool { return selected[i].index < selected[j].index })
|
|
475
|
+
var parts []string
|
|
476
|
+
remaining := budget
|
|
477
|
+
for i, section := range selected {
|
|
478
|
+
if remaining < 160 {
|
|
479
|
+
break
|
|
480
|
+
}
|
|
481
|
+
// Allocate budget across selected sections so one huge section doesn't
|
|
482
|
+
// crowd out exact schemas or examples from the other sections.
|
|
483
|
+
allocation := remaining / (len(selected) - i)
|
|
484
|
+
if allocation < 500 && remaining >= 500 {
|
|
485
|
+
allocation = 500
|
|
486
|
+
}
|
|
487
|
+
text := atomicFocusedSection(section.body, question, allocation)
|
|
488
|
+
parts = append(parts, text)
|
|
489
|
+
remaining -= len(text)
|
|
490
|
+
}
|
|
491
|
+
result := strings.Join(parts, "\n\n")
|
|
492
|
+
return result + "\n\n*[Selected excerpts; open the source URL to inspect omitted sections.]*"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
func boundedMarkdown(markdown string, budget int) string {
|
|
496
|
+
if len(markdown) <= budget {
|
|
497
|
+
return markdown
|
|
498
|
+
}
|
|
499
|
+
if budget <= 80 {
|
|
500
|
+
return "*[Section too large; consult source.]*"
|
|
501
|
+
}
|
|
502
|
+
lines := strings.Split(markdown, "\n")
|
|
503
|
+
var out strings.Builder
|
|
504
|
+
inFence := false
|
|
505
|
+
fence := "```"
|
|
506
|
+
for _, line := range lines {
|
|
507
|
+
if out.Len()+len(line)+len("\n…\n```") > budget {
|
|
508
|
+
break
|
|
509
|
+
}
|
|
510
|
+
trimmed := strings.TrimSpace(line)
|
|
511
|
+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
|
512
|
+
if !inFence {
|
|
513
|
+
fence = trimmed[:3]
|
|
514
|
+
}
|
|
515
|
+
inFence = !inFence
|
|
516
|
+
}
|
|
517
|
+
out.WriteString(line)
|
|
518
|
+
out.WriteByte('\n')
|
|
519
|
+
}
|
|
520
|
+
if inFence {
|
|
521
|
+
out.WriteString(fence)
|
|
522
|
+
out.WriteByte('\n')
|
|
523
|
+
}
|
|
524
|
+
out.WriteString("*[Excerpt shortened; see source for full content.]*")
|
|
525
|
+
return out.String()
|
|
526
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"sort"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
const backtickFence = "\x60\x60\x60"
|
|
9
|
+
|
|
10
|
+
type evidenceBlock struct {
|
|
11
|
+
text string
|
|
12
|
+
code bool
|
|
13
|
+
score int
|
|
14
|
+
order int
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A code fence is indivisible evidence: do not silently truncate a command,
|
|
18
|
+
// request body or SDK example and then invent a closing fence.
|
|
19
|
+
func splitEvidenceBlocks(markdown, question string) []evidenceBlock {
|
|
20
|
+
var blocks []evidenceBlock
|
|
21
|
+
var content strings.Builder
|
|
22
|
+
terms := queryTerms(question)
|
|
23
|
+
inCode := false
|
|
24
|
+
fence := ""
|
|
25
|
+
flush := func(code bool) {
|
|
26
|
+
text := strings.TrimSpace(content.String())
|
|
27
|
+
content.Reset()
|
|
28
|
+
if text == "" {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
score := termScore(text, terms)
|
|
32
|
+
if code {
|
|
33
|
+
score *= 3
|
|
34
|
+
if strings.Contains(strings.ToLower(question), "rest") &&
|
|
35
|
+
(strings.Contains(text, "curl") || strings.Contains(text, "POST ") || strings.Contains(text, "GET ")) {
|
|
36
|
+
score += 4
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
blocks = append(blocks, evidenceBlock{text: text, code: code, score: score, order: len(blocks)})
|
|
40
|
+
}
|
|
41
|
+
for _, line := range strings.Split(markdown, "\n") {
|
|
42
|
+
trimmed := strings.TrimSpace(line)
|
|
43
|
+
opening := !inCode && (strings.HasPrefix(trimmed, backtickFence) || strings.HasPrefix(trimmed, "~~~"))
|
|
44
|
+
if opening {
|
|
45
|
+
flush(false)
|
|
46
|
+
inCode = true
|
|
47
|
+
fence = trimmed[:3]
|
|
48
|
+
}
|
|
49
|
+
content.WriteString(line)
|
|
50
|
+
content.WriteByte('\n')
|
|
51
|
+
if inCode && !opening && strings.TrimSpace(strings.TrimPrefix(trimmed, fence)) == "" &&
|
|
52
|
+
strings.HasPrefix(trimmed, fence) {
|
|
53
|
+
flush(true)
|
|
54
|
+
inCode = false
|
|
55
|
+
fence = ""
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Unclosed code blocks are not valid examples; treat them as source prose.
|
|
59
|
+
flush(false)
|
|
60
|
+
return blocks
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Select complete, high-relevance examples and explanatory prose from long
|
|
64
|
+
// sections, preserving original order and the hard context budget.
|
|
65
|
+
func atomicFocusedSection(markdown, question string, budget int) string {
|
|
66
|
+
markdown = strings.TrimSpace(markdown)
|
|
67
|
+
if len(markdown) <= budget {
|
|
68
|
+
return markdown
|
|
69
|
+
}
|
|
70
|
+
if budget < 180 {
|
|
71
|
+
return "*[Section exceeds excerpt budget; follow the source URL.]*"
|
|
72
|
+
}
|
|
73
|
+
blocks := splitEvidenceBlocks(markdown, question)
|
|
74
|
+
const omitted = "\n\n*[Other source text omitted. Follow the original URL for full details.]*"
|
|
75
|
+
remaining := budget - len(omitted) - 8
|
|
76
|
+
if remaining < 80 {
|
|
77
|
+
return "*[Section exceeds excerpt budget; follow the source URL.]*"
|
|
78
|
+
}
|
|
79
|
+
selected := make(map[int]string)
|
|
80
|
+
var codes []evidenceBlock
|
|
81
|
+
var prose []evidenceBlock
|
|
82
|
+
for _, block := range blocks {
|
|
83
|
+
if block.code {
|
|
84
|
+
codes = append(codes, block)
|
|
85
|
+
} else {
|
|
86
|
+
prose = append(prose, block)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
sort.SliceStable(codes, func(i, j int) bool {
|
|
90
|
+
if codes[i].score == codes[j].score {
|
|
91
|
+
return codes[i].order < codes[j].order
|
|
92
|
+
}
|
|
93
|
+
return codes[i].score > codes[j].score
|
|
94
|
+
})
|
|
95
|
+
// Reserve a little space for the section explanation.
|
|
96
|
+
codeAllowance := remaining * 85 / 100
|
|
97
|
+
for _, block := range codes {
|
|
98
|
+
size := len(block.text) + 2
|
|
99
|
+
if size <= codeAllowance {
|
|
100
|
+
selected[block.order] = block.text
|
|
101
|
+
codeAllowance -= size
|
|
102
|
+
remaining -= size
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
sort.SliceStable(prose, func(i, j int) bool {
|
|
106
|
+
if prose[i].score == prose[j].score {
|
|
107
|
+
return prose[i].order < prose[j].order
|
|
108
|
+
}
|
|
109
|
+
return prose[i].score > prose[j].score
|
|
110
|
+
})
|
|
111
|
+
for _, block := range prose {
|
|
112
|
+
if remaining < 100 {
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
if len(block.text)+2 <= remaining {
|
|
116
|
+
selected[block.order] = block.text
|
|
117
|
+
remaining -= len(block.text) + 2
|
|
118
|
+
} else if part := completeProseLines(block.text, remaining-4); part != "" {
|
|
119
|
+
selected[block.order] = part
|
|
120
|
+
remaining -= len(part) + 2
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
var output []string
|
|
124
|
+
for _, block := range blocks {
|
|
125
|
+
if text := selected[block.order]; text != "" {
|
|
126
|
+
output = append(output, text)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if len(output) == 0 {
|
|
130
|
+
return "*[Code examples exceed the excerpt budget; inspect source URL for the full examples.]*"
|
|
131
|
+
}
|
|
132
|
+
return strings.Join(output, "\n\n") + omitted
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func completeProseLines(text string, maxBytes int) string {
|
|
136
|
+
if maxBytes <= 0 {
|
|
137
|
+
return ""
|
|
138
|
+
}
|
|
139
|
+
var lines []string
|
|
140
|
+
size := 0
|
|
141
|
+
for _, line := range strings.Split(text, "\n") {
|
|
142
|
+
if size+len(line)+1 > maxBytes {
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
lines = append(lines, line)
|
|
146
|
+
size += len(line) + 1
|
|
147
|
+
}
|
|
148
|
+
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
149
|
+
}
|