webctx 0.2.2 → 0.2.4
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 +21 -18
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +615 -0
- package/internal/app/research_excerpt.go +149 -0
- package/internal/app/research_excerpt_test.go +47 -0
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_openapi.go +289 -0
- package/internal/app/research_openapi_test.go +140 -0
- package/internal/app/research_test.go +154 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +259 -0
- package/internal/app/structured_source_test.go +70 -0
- package/internal/app/tools.go +36 -9
- 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,615 @@
|
|
|
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 = 5
|
|
24
|
+
maxFollowupSources = 2
|
|
25
|
+
maxSearchSources = 3
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func relatedExcerptBudget(rawURL string) int {
|
|
29
|
+
lower := strings.ToLower(rawURL)
|
|
30
|
+
if strings.Contains(lower, "/get-started/") || strings.Contains(lower, "/quickstart") {
|
|
31
|
+
return 18000 // Preserve end-to-end runnable setup guides.
|
|
32
|
+
}
|
|
33
|
+
return focusedRelatedBudget
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var (
|
|
37
|
+
mdLinkPattern = regexp.MustCompile(`\[([^\]\n]{2,120})\]\(([^\s)]+)(?:\s+"[^"]*")?\)`)
|
|
38
|
+
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_-]{2,}`)
|
|
39
|
+
sourceWordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]{1,}`)
|
|
40
|
+
headingPattern = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
type researchSection struct {
|
|
44
|
+
index int
|
|
45
|
+
heading string
|
|
46
|
+
body string
|
|
47
|
+
score int
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type sourceCandidate struct {
|
|
51
|
+
url string
|
|
52
|
+
label string
|
|
53
|
+
score int
|
|
54
|
+
order int
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type sourceRead struct {
|
|
58
|
+
url string
|
|
59
|
+
content string
|
|
60
|
+
err error
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ReadLinkFocused is backwards-compatible: URL-only calls still return the
|
|
64
|
+
// complete original source. A question makes the engine select excerpts and
|
|
65
|
+
// automatically inspect related documentation on the same host.
|
|
66
|
+
func ReadLinkFocused(rawURL, question string) (string, error) {
|
|
67
|
+
return readLinkFocused(rawURL, question, ReadLink)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func readLinkFocused(rawURL, question string, reader func(string) (string, error)) (string, error) {
|
|
71
|
+
if strings.TrimSpace(question) == "" {
|
|
72
|
+
return reader(rawURL)
|
|
73
|
+
}
|
|
74
|
+
if err := validateSourceURL(rawURL); err != nil {
|
|
75
|
+
return "", err
|
|
76
|
+
}
|
|
77
|
+
question = strings.TrimSpace(question)
|
|
78
|
+
// REST contract discovery is independent of the page scrape. Start it
|
|
79
|
+
// immediately so a slow Firecrawl read and a large OpenAPI download overlap
|
|
80
|
+
// instead of approaching the hosted MCP function timeout sequentially.
|
|
81
|
+
var openAPIDone chan openAPIContext
|
|
82
|
+
if asksForAPIContracts(question) {
|
|
83
|
+
openAPIDone = make(chan openAPIContext, 1)
|
|
84
|
+
go func() { openAPIDone <- discoverOpenAPIContract(rawURL, question) }()
|
|
85
|
+
}
|
|
86
|
+
primary, err := reader(rawURL)
|
|
87
|
+
if err != nil {
|
|
88
|
+
return "", err
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Combine on-page links and authoritative Markdown index links instead of
|
|
92
|
+
// filling the entire budget from whichever links happen to appear first.
|
|
93
|
+
// In particular, an implementation question should prefer get-started,
|
|
94
|
+
// SQLite, webhook, and API references over incidental alarm/concept pages.
|
|
95
|
+
links := researchCandidates(rawURL, primary, question, maxRelatedSources)
|
|
96
|
+
structuredDone := make(chan string, 1)
|
|
97
|
+
if strings.Contains(primary, "**Markdown source:**") {
|
|
98
|
+
structuredDone <- ""
|
|
99
|
+
} else {
|
|
100
|
+
go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
|
|
101
|
+
}
|
|
102
|
+
more := readSources(links, reader)
|
|
103
|
+
parts := []string{
|
|
104
|
+
"# Source-grounded context",
|
|
105
|
+
"",
|
|
106
|
+
"**Question:** " + question,
|
|
107
|
+
"",
|
|
108
|
+
"## Primary source",
|
|
109
|
+
"**Source:** " + rawURL,
|
|
110
|
+
"",
|
|
111
|
+
}
|
|
112
|
+
primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
|
|
113
|
+
parts = append(parts, primaryExcerpt)
|
|
114
|
+
successful := 1
|
|
115
|
+
relatedSuccessful := 0
|
|
116
|
+
allRetrieved := primary
|
|
117
|
+
outputEvidence := primaryExcerpt
|
|
118
|
+
seenURLs := map[string]bool{rawURL: true}
|
|
119
|
+
for _, source := range more {
|
|
120
|
+
if source.err != nil {
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
successful++
|
|
124
|
+
relatedSuccessful++
|
|
125
|
+
seenURLs[source.url] = true
|
|
126
|
+
allRetrieved += "\n" + source.content
|
|
127
|
+
excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
|
|
128
|
+
outputEvidence += "\n" + excerpt
|
|
129
|
+
parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
|
|
130
|
+
}
|
|
131
|
+
// One additional bounded expansion if required code/API evidence remains
|
|
132
|
+
// missing. This follows references from the implementation pages we actually
|
|
133
|
+
// found, not arbitrary link hops across the entire site.
|
|
134
|
+
missing := missingResearchEvidence(question, allRetrieved)
|
|
135
|
+
if len(missing) > 0 {
|
|
136
|
+
var next []sourceCandidate
|
|
137
|
+
for _, source := range more {
|
|
138
|
+
if source.err != nil {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if strings.Contains(strings.ToLower(source.content), "article has multiple variants") {
|
|
142
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, question, 8) {
|
|
143
|
+
if !seenURLs[candidate.url] {
|
|
144
|
+
seenURLs[candidate.url] = true
|
|
145
|
+
candidate.score += 50 // A published page variant is the actual documentation, not an index.
|
|
146
|
+
if strings.Contains(strings.ToLower(question), "hosted") &&
|
|
147
|
+
strings.Contains(strings.ToLower(candidate.url+" "+candidate.label), "hosted") {
|
|
148
|
+
candidate.score += 20
|
|
149
|
+
}
|
|
150
|
+
next = append(next, candidate)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, strings.Join(missing, " "), 8) {
|
|
155
|
+
if !seenURLs[candidate.url] {
|
|
156
|
+
seenURLs[candidate.url] = true
|
|
157
|
+
candidate.score += implementationLinkBonus(candidate, question)
|
|
158
|
+
next = append(next, candidate)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
sort.SliceStable(next, func(i, j int) bool { return next[i].score > next[j].score })
|
|
163
|
+
if len(next) > maxFollowupSources {
|
|
164
|
+
next = next[:maxFollowupSources]
|
|
165
|
+
}
|
|
166
|
+
followups := readSources(next, reader)
|
|
167
|
+
more = append(more, followups...)
|
|
168
|
+
for _, source := range followups {
|
|
169
|
+
if source.err != nil {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
successful++
|
|
173
|
+
relatedSuccessful++
|
|
174
|
+
allRetrieved += "\n" + source.content
|
|
175
|
+
excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
|
|
176
|
+
outputEvidence += "\n" + excerpt
|
|
177
|
+
parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if openAPIDone != nil {
|
|
181
|
+
if contract := <-openAPIDone; contract.Content != "" {
|
|
182
|
+
successful++
|
|
183
|
+
outputEvidence += "\n" + contract.Content
|
|
184
|
+
parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if structured := <-structuredDone; structured != "" {
|
|
188
|
+
outputEvidence += "\n" + structured
|
|
189
|
+
parts = append(parts, "", structured)
|
|
190
|
+
}
|
|
191
|
+
if unmet := missingResearchEvidence(question, outputEvidence); len(unmet) > 0 {
|
|
192
|
+
parts = append(parts, "", "**Requested details not fully evidenced in returned excerpts:** "+strings.Join(unmet, ", ")+". Follow the cited source pages rather than assuming these details.")
|
|
193
|
+
}
|
|
194
|
+
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))
|
|
195
|
+
if failed := len(more) - relatedSuccessful; failed > 0 {
|
|
196
|
+
parts = append(parts, fmt.Sprintf("%d related page(s) could not be retrieved; the primary source is preserved.", failed))
|
|
197
|
+
}
|
|
198
|
+
return strings.Join(parts, "\n"), nil
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// enrichSearch gives agents real documentation content in their first search
|
|
202
|
+
// response, with the original ranked source list retained for navigation.
|
|
203
|
+
func enrichSearch(query string, ranked []SearchDoc) string {
|
|
204
|
+
return enrichSearchWithReader(query, ranked, ReadLink)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func enrichSearchWithReader(query string, ranked []SearchDoc, reader func(string) (string, error)) string {
|
|
208
|
+
chosen := chooseSearchSources(query, ranked, maxSearchSources)
|
|
209
|
+
if len(chosen) == 0 {
|
|
210
|
+
return ""
|
|
211
|
+
}
|
|
212
|
+
more := readSources(chosen, reader)
|
|
213
|
+
parts := []string{}
|
|
214
|
+
seen := make(map[string]bool)
|
|
215
|
+
for _, source := range more {
|
|
216
|
+
if source.err != nil {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
seen[source.url] = true
|
|
220
|
+
parts = append(parts, "### "+source.url, "", focusExcerpt(source.content, query, searchSourceBudget), "")
|
|
221
|
+
}
|
|
222
|
+
if len(parts) == 0 {
|
|
223
|
+
return ""
|
|
224
|
+
}
|
|
225
|
+
// Expand high-relevance documentation references from retrieved results,
|
|
226
|
+
// not arbitrary websites or unlimited recursive link trees.
|
|
227
|
+
var follow []sourceCandidate
|
|
228
|
+
for _, source := range more {
|
|
229
|
+
if source.err != nil || len(follow) >= 2 {
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, query, 2) {
|
|
233
|
+
if seen[candidate.url] {
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
seen[candidate.url] = true
|
|
237
|
+
follow = append(follow, candidate)
|
|
238
|
+
if len(follow) >= 2 {
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for _, source := range readSources(follow, reader) {
|
|
244
|
+
if source.err == nil {
|
|
245
|
+
parts = append(parts, "### Supporting reference: "+source.url, "", focusExcerpt(source.content, query, focusedRelatedBudget), "")
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
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")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func linksFromDocsIndex(rawURL, question string, limit int) []sourceCandidate {
|
|
252
|
+
return linksFromDocsIndexWithPrimary(rawURL, "", question, limit)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func linksFromDocsIndexWithPrimary(rawURL, primary, question string, limit int) []sourceCandidate {
|
|
256
|
+
u, err := url.Parse(rawURL)
|
|
257
|
+
if err != nil || u.Hostname() == "github.com" || directSourceLanguage(rawURL) != "" {
|
|
258
|
+
return nil
|
|
259
|
+
}
|
|
260
|
+
if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") &&
|
|
261
|
+
!strings.Contains(u.Path, "/reference/") && !strings.Contains(primary, "llms.txt") {
|
|
262
|
+
return nil
|
|
263
|
+
}
|
|
264
|
+
indexURLs := documentIndexURLs(u, primary)
|
|
265
|
+
var candidates []sourceCandidate
|
|
266
|
+
seen := map[string]bool{}
|
|
267
|
+
for _, indexURL := range indexURLs {
|
|
268
|
+
if rawURL == indexURL {
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
272
|
+
index, err := fetchPublicText(ctx, indexURL, 300000)
|
|
273
|
+
cancel()
|
|
274
|
+
if err != nil || !strings.Contains(index, "](") {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
for _, item := range relatedSourceLinks(index, indexURL, question, 300) {
|
|
278
|
+
item.score += implementationLinkBonus(item, question)
|
|
279
|
+
item.score += sourceScopeBonus(item, rawURL, question)
|
|
280
|
+
if !seen[item.url] {
|
|
281
|
+
seen[item.url] = true
|
|
282
|
+
candidates = append(candidates, item)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if len(candidates) >= limit {
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
|
290
|
+
if len(candidates) > limit {
|
|
291
|
+
candidates = candidates[:limit]
|
|
292
|
+
}
|
|
293
|
+
return candidates
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func readSources(candidates []sourceCandidate, reader func(string) (string, error)) []sourceRead {
|
|
297
|
+
result := make([]sourceRead, len(candidates))
|
|
298
|
+
var wg sync.WaitGroup
|
|
299
|
+
for i, candidate := range candidates {
|
|
300
|
+
wg.Add(1)
|
|
301
|
+
go func(i int, candidate sourceCandidate) {
|
|
302
|
+
defer wg.Done()
|
|
303
|
+
content, err := reader(candidate.url)
|
|
304
|
+
result[i] = sourceRead{url: candidate.url, content: content, err: err}
|
|
305
|
+
}(i, candidate)
|
|
306
|
+
}
|
|
307
|
+
wg.Wait()
|
|
308
|
+
return result
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
func chooseSearchSources(query string, ranked []SearchDoc, limit int) []sourceCandidate {
|
|
312
|
+
items := make([]sourceCandidate, 0, limit)
|
|
313
|
+
terms := queryTerms(query)
|
|
314
|
+
for i, source := range ranked {
|
|
315
|
+
if i >= 18 {
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
if err := validateSourceURL(source.URL); err != nil || disallowedSourcePath(source.URL) {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
u, _ := url.Parse(source.URL)
|
|
322
|
+
score := 18 - i + termScore(source.Title, terms)*2 + termScore(u.Hostname()+" "+u.Path, terms)
|
|
323
|
+
if strings.HasPrefix(u.Hostname(), "docs.") || strings.Contains(u.Path, "/docs/") || strings.Contains(u.Path, "/reference/") {
|
|
324
|
+
score += 7
|
|
325
|
+
}
|
|
326
|
+
if strings.Contains(u.Path, "/specification/") || strings.Contains(u.Path, "/api-reference/") || strings.Contains(u.Path, "/sdk/") {
|
|
327
|
+
score += 7
|
|
328
|
+
}
|
|
329
|
+
if u.Hostname() == "github.com" || strings.Contains(u.Path, "openapi") {
|
|
330
|
+
score += 5
|
|
331
|
+
}
|
|
332
|
+
if strings.Contains(u.Path, "/community/") || strings.Contains(u.Path, "/interest-groups/") {
|
|
333
|
+
score -= 16 // governance pages rarely contain usable API contracts
|
|
334
|
+
}
|
|
335
|
+
if strings.Contains(u.Path, "/blog/") || strings.HasPrefix(u.Hostname(), "blog.") {
|
|
336
|
+
score -= 5
|
|
337
|
+
}
|
|
338
|
+
if strings.Contains(u.Path, "/internal/") && u.Hostname() == "pkg.go.dev" {
|
|
339
|
+
score -= 12 // internal Go packages aren't public SDK contracts
|
|
340
|
+
}
|
|
341
|
+
if strings.Contains(strings.ToLower(query), "go sdk") && strings.Contains(strings.ToLower(source.Title+" "+u.Path), "go-sdk") {
|
|
342
|
+
score += 7
|
|
343
|
+
}
|
|
344
|
+
items = append(items, sourceCandidate{url: source.URL, label: source.Title, score: score, order: i})
|
|
345
|
+
}
|
|
346
|
+
sort.SliceStable(items, func(i, j int) bool { return items[i].score > items[j].score })
|
|
347
|
+
selected := make([]sourceCandidate, 0, limit)
|
|
348
|
+
seenHosts := make(map[string]int)
|
|
349
|
+
for _, item := range items {
|
|
350
|
+
if len(selected) == limit {
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
u, _ := url.Parse(item.url)
|
|
354
|
+
if seenHosts[u.Hostname()] >= 2 { // diversify while allowing two relevant docs pages
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
seenHosts[u.Hostname()]++
|
|
358
|
+
selected = append(selected, item)
|
|
359
|
+
}
|
|
360
|
+
return selected
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func relatedSourceLinks(markdown, baseURL, question string, limit int) []sourceCandidate {
|
|
364
|
+
base, err := url.Parse(baseURL)
|
|
365
|
+
if err != nil {
|
|
366
|
+
return nil
|
|
367
|
+
}
|
|
368
|
+
terms := queryTerms(question)
|
|
369
|
+
seen := map[string]bool{canonicalSourceURL(base): true}
|
|
370
|
+
var links []sourceCandidate
|
|
371
|
+
for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 1000) {
|
|
372
|
+
destination := strings.TrimSpace(match[2])
|
|
373
|
+
if strings.Contains(destination, "\\") { // malformed Markdown-escaped URLs
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
ref, err := url.Parse(destination)
|
|
377
|
+
if err != nil {
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
u := base.ResolveReference(ref)
|
|
381
|
+
if !strings.EqualFold(base.Hostname(), u.Hostname()) || base.Port() != u.Port() || u.User != nil {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
if err := validateSourceURL(u.String()); err != nil || disallowedSourcePath(u.String()) || sensitiveURL(u) {
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
key := canonicalSourceURL(u)
|
|
388
|
+
if seen[key] {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
seen[key] = true
|
|
392
|
+
label := strings.TrimSpace(match[1])
|
|
393
|
+
score := termScore(label, terms)*4 + termScore(u.Path, terms)*3
|
|
394
|
+
if score == 0 { // no relevant relationship to the user's question
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
if len(terms) >= 4 && len(matchedLinkTerms(label+" "+u.Path, terms)) == 1 {
|
|
398
|
+
term := matchedLinkTerms(label+" "+u.Path, terms)[0]
|
|
399
|
+
if len(term) <= 5 { // don't expand generic links matching only 'MCP' or 'tools'
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
links = append(links, sourceCandidate{url: key, label: label, score: score, order: i})
|
|
404
|
+
}
|
|
405
|
+
sort.SliceStable(links, func(i, j int) bool {
|
|
406
|
+
if links[i].score == links[j].score {
|
|
407
|
+
return links[i].order < links[j].order
|
|
408
|
+
}
|
|
409
|
+
return links[i].score > links[j].score
|
|
410
|
+
})
|
|
411
|
+
if len(links) > limit {
|
|
412
|
+
links = links[:limit]
|
|
413
|
+
}
|
|
414
|
+
return links
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func matchedLinkTerms(text string, terms []string) []string {
|
|
418
|
+
var matched []string
|
|
419
|
+
for _, term := range terms {
|
|
420
|
+
if termScore(text, []string{term}) > 0 {
|
|
421
|
+
matched = append(matched, term)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return matched
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func canonicalSourceURL(u *url.URL) string {
|
|
428
|
+
copy := *u
|
|
429
|
+
copy.Fragment = ""
|
|
430
|
+
copy.RawFragment = ""
|
|
431
|
+
return copy.String()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
func sensitiveURL(u *url.URL) bool {
|
|
435
|
+
for name := range u.Query() {
|
|
436
|
+
name = strings.ToLower(name)
|
|
437
|
+
if strings.Contains(name, "token") || strings.Contains(name, "secret") || strings.Contains(name, "key") || strings.Contains(name, "auth") || strings.Contains(name, "signature") {
|
|
438
|
+
return true
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
func disallowedSourcePath(raw string) bool {
|
|
445
|
+
u, err := url.Parse(raw)
|
|
446
|
+
if err != nil {
|
|
447
|
+
return true
|
|
448
|
+
}
|
|
449
|
+
path := strings.ToLower(u.Path)
|
|
450
|
+
for _, suffix := range []string{".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".zip", ".mp4", ".mov", ".mp3", ".woff", ".woff2", ".pdf", ".dmg", ".exe"} {
|
|
451
|
+
if strings.HasSuffix(path, suffix) {
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func queryTerms(query string) []string {
|
|
459
|
+
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}
|
|
460
|
+
seen := make(map[string]bool)
|
|
461
|
+
var terms []string
|
|
462
|
+
for _, term := range sourceWordPattern.FindAllString(strings.ToLower(query), 40) {
|
|
463
|
+
if len(term) < 3 {
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
if stop[term] || seen[term] {
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
seen[term] = true
|
|
470
|
+
terms = append(terms, term)
|
|
471
|
+
}
|
|
472
|
+
return terms
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
func termScore(text string, terms []string) int {
|
|
476
|
+
// Compare words instead of substrings. Without this, an "api" search
|
|
477
|
+
// incorrectly matches "scraping" and chooses unrelated pages.
|
|
478
|
+
words := sourceWordPattern.FindAllString(strings.ToLower(text), -1)
|
|
479
|
+
count := 0
|
|
480
|
+
for _, term := range terms {
|
|
481
|
+
// Approximate inflection matching: pagination / paginate, authenticated /
|
|
482
|
+
// authentication, etc. Exact matches are weighted slightly higher.
|
|
483
|
+
root := term
|
|
484
|
+
if len(root) > 5 {
|
|
485
|
+
root = root[:5]
|
|
486
|
+
}
|
|
487
|
+
for _, word := range words {
|
|
488
|
+
if word == term {
|
|
489
|
+
count += 2
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
if len(root) >= 4 && strings.HasPrefix(word, root) {
|
|
493
|
+
count++
|
|
494
|
+
break
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return count
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
func splitResearchSections(markdown string, terms []string) []researchSection {
|
|
502
|
+
lines := strings.Split(markdown, "\n")
|
|
503
|
+
sections := make([]researchSection, 0, 20)
|
|
504
|
+
current := researchSection{heading: "Introduction"}
|
|
505
|
+
var body strings.Builder
|
|
506
|
+
inFence := false
|
|
507
|
+
flush := func() {
|
|
508
|
+
current.body = strings.TrimSpace(body.String())
|
|
509
|
+
if current.body != "" {
|
|
510
|
+
current.index = len(sections)
|
|
511
|
+
current.score = 5*termScore(current.heading, terms) + termScore(current.body, terms)
|
|
512
|
+
if strings.Contains(current.body, "```") && current.score > 0 {
|
|
513
|
+
current.score += 2
|
|
514
|
+
}
|
|
515
|
+
sections = append(sections, current)
|
|
516
|
+
}
|
|
517
|
+
body.Reset()
|
|
518
|
+
}
|
|
519
|
+
for _, line := range lines {
|
|
520
|
+
trimmed := strings.TrimSpace(line)
|
|
521
|
+
if !inFence {
|
|
522
|
+
if heading := headingPattern.FindStringSubmatch(trimmed); len(heading) == 2 {
|
|
523
|
+
flush()
|
|
524
|
+
current = researchSection{heading: heading[1]}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
body.WriteString(line)
|
|
528
|
+
body.WriteByte('\n')
|
|
529
|
+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
|
530
|
+
inFence = !inFence
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
flush()
|
|
534
|
+
return sections
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
func focusExcerpt(markdown, question string, budget int) string {
|
|
538
|
+
markdown = strings.TrimSpace(markdown)
|
|
539
|
+
if len(markdown) <= budget {
|
|
540
|
+
return markdown
|
|
541
|
+
}
|
|
542
|
+
if focused := focusedRawJSON(markdown, question, budget); focused != "" {
|
|
543
|
+
return focused
|
|
544
|
+
}
|
|
545
|
+
sections := splitResearchSections(markdown, queryTerms(question))
|
|
546
|
+
if len(sections) == 0 {
|
|
547
|
+
return boundedMarkdown(markdown, budget)
|
|
548
|
+
}
|
|
549
|
+
sorted := append([]researchSection(nil), sections...)
|
|
550
|
+
sort.SliceStable(sorted, func(i, j int) bool {
|
|
551
|
+
if sorted[i].score == sorted[j].score {
|
|
552
|
+
return sorted[i].index < sorted[j].index
|
|
553
|
+
}
|
|
554
|
+
return sorted[i].score > sorted[j].score
|
|
555
|
+
})
|
|
556
|
+
selected := make([]researchSection, 0, 5)
|
|
557
|
+
for _, section := range sorted {
|
|
558
|
+
if len(selected) >= 5 || (section.score == 0 && len(selected) > 0) {
|
|
559
|
+
break
|
|
560
|
+
}
|
|
561
|
+
selected = append(selected, section)
|
|
562
|
+
}
|
|
563
|
+
sort.Slice(selected, func(i, j int) bool { return selected[i].index < selected[j].index })
|
|
564
|
+
var parts []string
|
|
565
|
+
remaining := budget
|
|
566
|
+
for i, section := range selected {
|
|
567
|
+
if remaining < 160 {
|
|
568
|
+
break
|
|
569
|
+
}
|
|
570
|
+
// Allocate budget across selected sections so one huge section doesn't
|
|
571
|
+
// crowd out exact schemas or examples from the other sections.
|
|
572
|
+
allocation := remaining / (len(selected) - i)
|
|
573
|
+
if allocation < 500 && remaining >= 500 {
|
|
574
|
+
allocation = 500
|
|
575
|
+
}
|
|
576
|
+
text := atomicFocusedSection(section.body, question, allocation)
|
|
577
|
+
parts = append(parts, text)
|
|
578
|
+
remaining -= len(text)
|
|
579
|
+
}
|
|
580
|
+
result := strings.Join(parts, "\n\n")
|
|
581
|
+
return result + "\n\n*[Selected excerpts; open the source URL to inspect omitted sections.]*"
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
func boundedMarkdown(markdown string, budget int) string {
|
|
585
|
+
if len(markdown) <= budget {
|
|
586
|
+
return markdown
|
|
587
|
+
}
|
|
588
|
+
if budget <= 80 {
|
|
589
|
+
return "*[Section too large; consult source.]*"
|
|
590
|
+
}
|
|
591
|
+
lines := strings.Split(markdown, "\n")
|
|
592
|
+
var out strings.Builder
|
|
593
|
+
inFence := false
|
|
594
|
+
fence := "```"
|
|
595
|
+
for _, line := range lines {
|
|
596
|
+
if out.Len()+len(line)+len("\n…\n```") > budget {
|
|
597
|
+
break
|
|
598
|
+
}
|
|
599
|
+
trimmed := strings.TrimSpace(line)
|
|
600
|
+
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
|
601
|
+
if !inFence {
|
|
602
|
+
fence = trimmed[:3]
|
|
603
|
+
}
|
|
604
|
+
inFence = !inFence
|
|
605
|
+
}
|
|
606
|
+
out.WriteString(line)
|
|
607
|
+
out.WriteByte('\n')
|
|
608
|
+
}
|
|
609
|
+
if inFence {
|
|
610
|
+
out.WriteString(fence)
|
|
611
|
+
out.WriteByte('\n')
|
|
612
|
+
}
|
|
613
|
+
out.WriteString("*[Excerpt shortened; see source for full content.]*")
|
|
614
|
+
return out.String()
|
|
615
|
+
}
|