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,289 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// API contracts are public documentation, not calls to the documented API.
|
|
14
|
+
// When a question requires exact REST payloads, inspecting the site's linked
|
|
15
|
+
// OpenAPI spec is more faithful than extrapolating HTTP requests from SDK code.
|
|
16
|
+
type openAPIContext struct {
|
|
17
|
+
URL string
|
|
18
|
+
Content string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func asksForAPIContracts(question string) bool {
|
|
22
|
+
question = strings.ToLower(question)
|
|
23
|
+
return strings.Contains(question, "rest") || strings.Contains(question, "openapi") ||
|
|
24
|
+
(strings.Contains(question, "api") && (strings.Contains(question, "endpoint") || strings.Contains(question, "request") || strings.Contains(question, "schema") || strings.Contains(question, "contract"))) ||
|
|
25
|
+
(strings.Contains(question, "exact") && (strings.Contains(question, "curl") || strings.Contains(question, "request example")))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func discoverOpenAPIContract(rawURL, question string) openAPIContext {
|
|
29
|
+
if !asksForAPIContracts(question) || directSourceLanguage(rawURL) != "" {
|
|
30
|
+
return openAPIContext{}
|
|
31
|
+
}
|
|
32
|
+
u, err := url.Parse(rawURL)
|
|
33
|
+
if err != nil || validateSourceURL(rawURL) != nil || strings.EqualFold(u.Hostname(), "github.com") {
|
|
34
|
+
return openAPIContext{}
|
|
35
|
+
}
|
|
36
|
+
indexURL := u.Scheme + "://" + u.Host + "/llms.txt"
|
|
37
|
+
ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second)
|
|
38
|
+
defer cancel()
|
|
39
|
+
index, err := fetchPublicText(ctx, indexURL, 300000)
|
|
40
|
+
if err != nil {
|
|
41
|
+
return openAPIContext{}
|
|
42
|
+
}
|
|
43
|
+
var specURLs []sourceCandidate
|
|
44
|
+
seen := make(map[string]bool)
|
|
45
|
+
for _, match := range mdLinkPattern.FindAllStringSubmatch(index, 300) {
|
|
46
|
+
dest, err := url.Parse(strings.TrimSpace(match[2]))
|
|
47
|
+
if err != nil {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
resolved := u.ResolveReference(dest)
|
|
51
|
+
name := strings.ToLower(resolved.Path)
|
|
52
|
+
if !strings.EqualFold(resolved.Hostname(), u.Hostname()) || resolved.Port() != u.Port() ||
|
|
53
|
+
!strings.HasSuffix(name, ".json") || !strings.Contains(name, "openapi") ||
|
|
54
|
+
validateSourceURL(resolved.String()) != nil || sensitiveURL(resolved) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
sourceURL := canonicalSourceURL(resolved)
|
|
58
|
+
if seen[sourceURL] {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
seen[sourceURL] = true
|
|
62
|
+
score := 0
|
|
63
|
+
if strings.Contains(name, "v2-openapi") {
|
|
64
|
+
score += 10
|
|
65
|
+
}
|
|
66
|
+
if strings.HasPrefix(name, "/api-reference/") {
|
|
67
|
+
score += 5
|
|
68
|
+
}
|
|
69
|
+
if name == "/openapi.json" {
|
|
70
|
+
score += 5
|
|
71
|
+
}
|
|
72
|
+
score -= len(name) / 20 // Prefer the canonical path over translations.
|
|
73
|
+
specURLs = append(specURLs, sourceCandidate{url: sourceURL, score: score})
|
|
74
|
+
}
|
|
75
|
+
sort.SliceStable(specURLs, func(i, j int) bool { return specURLs[i].score > specURLs[j].score })
|
|
76
|
+
for i, item := range specURLs {
|
|
77
|
+
if i >= 2 { // Don't recursively fetch every language/version.
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
body, err := fetchPublicText(ctx, item.url, maxDirectSourceBytes)
|
|
81
|
+
if err != nil {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if content := summarizeOpenAPIContract(body, question); content != "" {
|
|
85
|
+
return openAPIContext{URL: item.url, Content: content}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return openAPIContext{}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type openAPIOperation struct {
|
|
92
|
+
Method string
|
|
93
|
+
Path string
|
|
94
|
+
Summary string
|
|
95
|
+
Score int
|
|
96
|
+
Request map[string]any
|
|
97
|
+
Response map[string]any
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func summarizeOpenAPIContract(source, question string) string {
|
|
101
|
+
var spec struct {
|
|
102
|
+
OpenAPI string `json:"openapi"`
|
|
103
|
+
Swagger string `json:"swagger"`
|
|
104
|
+
Servers []struct {
|
|
105
|
+
URL string `json:"url"`
|
|
106
|
+
} `json:"servers"`
|
|
107
|
+
Paths map[string]map[string]any `json:"paths"`
|
|
108
|
+
Components struct {
|
|
109
|
+
Schemas map[string]map[string]any `json:"schemas"`
|
|
110
|
+
} `json:"components"`
|
|
111
|
+
}
|
|
112
|
+
if json.Unmarshal([]byte(source), &spec) != nil || (spec.OpenAPI == "" && spec.Swagger == "") || len(spec.Paths) == 0 {
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
terms := queryTerms(question)
|
|
116
|
+
terms = append(terms, relatedAPITerms(question)...)
|
|
117
|
+
var operations []openAPIOperation
|
|
118
|
+
for path, methods := range spec.Paths {
|
|
119
|
+
// A broad question about Alexandria must not bring in unrelated API
|
|
120
|
+
// routes such as research papers or team activity merely because the
|
|
121
|
+
// path contains "search" or a summary happens to contain "API".
|
|
122
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
123
|
+
strings.Contains(strings.Trim(path, "/"), "/") &&
|
|
124
|
+
!strings.Contains(strings.ToLower(path), "alexandria") {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
for method, rawDetail := range methods {
|
|
128
|
+
switch strings.ToUpper(method) {
|
|
129
|
+
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
|
130
|
+
default:
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
detail := mapValue(rawDetail)
|
|
134
|
+
summary := stringValue(detail["summary"])
|
|
135
|
+
req := mapValue(detail["requestBody"])
|
|
136
|
+
resp := mapValue(detail["responses"])
|
|
137
|
+
score := termScore(path+" "+summary, terms)*4 + termScore(stringValue(detail["description"]), terms)
|
|
138
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
139
|
+
termScore(strings.Trim(path, "/"), []string{"search", "scrape", "find", "tools", "alexandria"}) == 0 {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if examples := requestExamples(req); len(examples) > 0 {
|
|
143
|
+
for name, example := range examples {
|
|
144
|
+
score += termScore(name+" "+stringValue(example["summary"]), terms) * 3
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if score > 0 {
|
|
148
|
+
operations = append(operations, openAPIOperation{
|
|
149
|
+
Method: strings.ToUpper(method), Path: path, Summary: summary, Score: score, Request: req, Response: resp,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
sort.SliceStable(operations, func(i, j int) bool {
|
|
155
|
+
if operations[i].Score == operations[j].Score {
|
|
156
|
+
return operations[i].Path < operations[j].Path
|
|
157
|
+
}
|
|
158
|
+
return operations[i].Score > operations[j].Score
|
|
159
|
+
})
|
|
160
|
+
if len(operations) == 0 {
|
|
161
|
+
return ""
|
|
162
|
+
}
|
|
163
|
+
apiRoot := ""
|
|
164
|
+
for _, server := range spec.Servers {
|
|
165
|
+
if validateSourceURL(server.URL) == nil {
|
|
166
|
+
apiRoot = strings.TrimRight(server.URL, "/")
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
parts := []string{"OpenAPI-defined HTTP operations and example request bodies (not executed):"}
|
|
171
|
+
for i, operation := range operations {
|
|
172
|
+
if i >= 4 {
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
endpoint := operation.Path
|
|
176
|
+
if apiRoot != "" {
|
|
177
|
+
endpoint = apiRoot + operation.Path
|
|
178
|
+
}
|
|
179
|
+
parts = append(parts, "", fmt.Sprintf("### %s %s", operation.Method, endpoint))
|
|
180
|
+
if operation.Summary != "" {
|
|
181
|
+
parts = append(parts, operation.Summary)
|
|
182
|
+
}
|
|
183
|
+
if examples := requestExamples(operation.Request); len(examples) > 0 {
|
|
184
|
+
names := make([]string, 0, len(examples))
|
|
185
|
+
for name := range examples {
|
|
186
|
+
names = append(names, name)
|
|
187
|
+
}
|
|
188
|
+
sort.SliceStable(names, func(i, j int) bool {
|
|
189
|
+
a := termScore(names[i]+" "+stringValue(examples[names[i]]["summary"]), terms)
|
|
190
|
+
b := termScore(names[j]+" "+stringValue(examples[names[j]]["summary"]), terms)
|
|
191
|
+
if a == b {
|
|
192
|
+
return names[i] < names[j]
|
|
193
|
+
}
|
|
194
|
+
return a > b
|
|
195
|
+
})
|
|
196
|
+
emitted := 0
|
|
197
|
+
for _, name := range names {
|
|
198
|
+
value := examples[name]["value"]
|
|
199
|
+
body, err := json.MarshalIndent(value, "", " ")
|
|
200
|
+
if err != nil || len(body) > 9000 || len(body) == 0 {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if strings.Contains(strings.ToLower(question), "alexandria") &&
|
|
204
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "alexandria") &&
|
|
205
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "findtools") &&
|
|
206
|
+
!strings.Contains(strings.ToLower(name+" "+string(body)), "find-tools") {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if emitted >= 2 {
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
emitted++
|
|
213
|
+
parts = append(parts, "", "**Published request example:** "+stringValue(examples[name]["summary"]))
|
|
214
|
+
if apiRoot != "" && !strings.Contains(endpoint, "{") {
|
|
215
|
+
// Keep the published JSON body verbatim; the API key must expand in the shell.
|
|
216
|
+
parts = append(parts, fmt.Sprintf("```bash\ncurl -X %s %q \\\n -H \"Authorization: Bearer $API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n --data-binary @- <<'JSON_REQUEST'\n%s\nJSON_REQUEST\n```", operation.Method, endpoint, string(body)))
|
|
217
|
+
} else {
|
|
218
|
+
parts = append(parts, "```json\n"+string(body)+"\n```")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for _, name := range []string{"FindToolsOptions", "AlexandriaCall", "FindToolsData"} {
|
|
224
|
+
if termScore(question, []string{"alexandria", "tools", "inspect", "schema", "contracts"}) == 0 {
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
contract, ok := spec.Components.Schemas[name]
|
|
228
|
+
if !ok {
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
parts = append(parts, "", "### "+name)
|
|
232
|
+
if desc := stringValue(contract["description"]); desc != "" {
|
|
233
|
+
parts = append(parts, desc)
|
|
234
|
+
}
|
|
235
|
+
if props, ok := contract["properties"].(map[string]any); ok {
|
|
236
|
+
keys := make([]string, 0, len(props))
|
|
237
|
+
for key := range props {
|
|
238
|
+
keys = append(keys, key)
|
|
239
|
+
}
|
|
240
|
+
sort.Strings(keys)
|
|
241
|
+
for _, key := range keys {
|
|
242
|
+
item := mapValue(props[key])
|
|
243
|
+
typ := stringValue(item["type"])
|
|
244
|
+
if typ == "" {
|
|
245
|
+
typ = "schema"
|
|
246
|
+
}
|
|
247
|
+
description := stringValue(item["description"])
|
|
248
|
+
if len(description) > 240 {
|
|
249
|
+
description = description[:240] + "…"
|
|
250
|
+
}
|
|
251
|
+
parts = append(parts, fmt.Sprintf("- `%s` (%s): %s", key, typ, description))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return strings.Join(parts, "\n")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func mapValue(value any) map[string]any {
|
|
259
|
+
v, _ := value.(map[string]any)
|
|
260
|
+
return v
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func requestExamples(request map[string]any) map[string]map[string]any {
|
|
264
|
+
content := mapValue(request["content"])
|
|
265
|
+
jsonContent := mapValue(content["application/json"])
|
|
266
|
+
examples := mapValue(jsonContent["examples"])
|
|
267
|
+
out := make(map[string]map[string]any, len(examples))
|
|
268
|
+
for key, example := range examples {
|
|
269
|
+
if item := mapValue(example); item != nil && item["value"] != nil {
|
|
270
|
+
out[key] = item
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func relatedAPITerms(question string) []string {
|
|
277
|
+
question = strings.ToLower(question)
|
|
278
|
+
var terms []string
|
|
279
|
+
if strings.Contains(question, "discover") || strings.Contains(question, "locat") {
|
|
280
|
+
terms = append(terms, "search", "find", "list")
|
|
281
|
+
}
|
|
282
|
+
if strings.Contains(question, "inspect") || strings.Contains(question, "contract") {
|
|
283
|
+
terms = append(terms, "find", "tools", "schema")
|
|
284
|
+
}
|
|
285
|
+
if strings.Contains(question, "execut") || strings.Contains(question, "run ") {
|
|
286
|
+
terms = append(terms, "scrape", "call", "execute")
|
|
287
|
+
}
|
|
288
|
+
return terms
|
|
289
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func exampleOpenAPISpec(t *testing.T) string {
|
|
11
|
+
t.Helper()
|
|
12
|
+
source := map[string]any{
|
|
13
|
+
"openapi": "3.0.0",
|
|
14
|
+
"servers": []any{map[string]any{"url": "https://api.example.com/v2"}},
|
|
15
|
+
"paths": map[string]any{
|
|
16
|
+
"/search": map[string]any{
|
|
17
|
+
"parameters": []any{map[string]any{"name": "unused"}}, // Legal non-operation path entry.
|
|
18
|
+
"post": map[string]any{"summary": "Search the Alexandria catalogue"},
|
|
19
|
+
},
|
|
20
|
+
"/scrape": map[string]any{
|
|
21
|
+
"post": map[string]any{
|
|
22
|
+
"summary": "Execute a provider capability or find tool contracts",
|
|
23
|
+
"requestBody": map[string]any{
|
|
24
|
+
"content": map[string]any{
|
|
25
|
+
"application/json": map[string]any{
|
|
26
|
+
"examples": map[string]any{
|
|
27
|
+
"findTools": map[string]any{
|
|
28
|
+
"summary": "Find Tools (free)",
|
|
29
|
+
"value": map[string]any{
|
|
30
|
+
"alexandria": map[string]any{
|
|
31
|
+
"provider": "firecrawl",
|
|
32
|
+
"capability": "find-tools",
|
|
33
|
+
"options": map[string]any{
|
|
34
|
+
"providers": []string{"particle"},
|
|
35
|
+
"expand": []string{"options", "response"},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"components": map[string]any{"schemas": map[string]any{
|
|
48
|
+
"FindToolsOptions": map[string]any{
|
|
49
|
+
"description": "Browse providers and inspect tool contracts.",
|
|
50
|
+
"properties": map[string]any{
|
|
51
|
+
"expand": map[string]any{"type": "array", "description": "Contract sections to return"},
|
|
52
|
+
"providers": map[string]any{"type": "array", "description": "Provider IDs"},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}},
|
|
56
|
+
}
|
|
57
|
+
bytes, err := json.Marshal(source)
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
return string(bytes)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func TestOpenAPIContractReturnsExactInspectionRESTExample(t *testing.T) {
|
|
65
|
+
result := summarizeOpenAPIContract(exampleOpenAPISpec(t),
|
|
66
|
+
"What are the Alexandria REST API endpoints for discovering, inspecting, and executing tools? Include exact request examples.")
|
|
67
|
+
for _, want := range []string{
|
|
68
|
+
"POST https://api.example.com/v2/search",
|
|
69
|
+
"POST https://api.example.com/v2/scrape",
|
|
70
|
+
"\"provider\": \"firecrawl\"",
|
|
71
|
+
"\"capability\": \"find-tools\"",
|
|
72
|
+
"\"expand\": [",
|
|
73
|
+
"FindToolsOptions",
|
|
74
|
+
"<<'JSON_REQUEST'",
|
|
75
|
+
"-H \"Authorization: Bearer $API_KEY\"",
|
|
76
|
+
} {
|
|
77
|
+
if !strings.Contains(result, want) {
|
|
78
|
+
t.Errorf("OpenAPI extraction lost %q:\n%s", want, result)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if count := strings.Count(result, "JSON_REQUEST\n"+strings.Repeat(string(rune(96)), 3)); count != 1 {
|
|
82
|
+
t.Fatalf("expected one complete REST example, got %d:\n%s", count, result)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestQuestionFocusedReadPullsOfficialOpenAPISpec(t *testing.T) {
|
|
87
|
+
oldClient := http.DefaultClient
|
|
88
|
+
t.Cleanup(func() { http.DefaultClient = oldClient })
|
|
89
|
+
spec := exampleOpenAPISpec(t)
|
|
90
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
91
|
+
switch req.URL.Path {
|
|
92
|
+
case "/llms.txt":
|
|
93
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
94
|
+
"# Docs\n\n## OpenAPI Specs\n\n- [v2-openapi](/api-reference/v2-openapi.json)\n", nil), nil
|
|
95
|
+
case "/api-reference/v2-openapi.json":
|
|
96
|
+
return testHTTPResponse(req, http.StatusOK, spec, nil), nil
|
|
97
|
+
default:
|
|
98
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
99
|
+
}
|
|
100
|
+
})}
|
|
101
|
+
primary := "# Alexandria\n\n## Discovery\n\ncurl https://api.example.com/v2/search\n" +
|
|
102
|
+
"\n## Execution\n\ncurl https://api.example.com/v2/scrape\n"
|
|
103
|
+
reader := func(url string) (string, error) { return primary, nil }
|
|
104
|
+
result, err := readLinkFocused("https://docs.example.com/features/alexandria",
|
|
105
|
+
"What are the Alexandria REST API endpoints for discovering, inspecting, and executing tools? Include exact request examples.",
|
|
106
|
+
reader)
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatal(err)
|
|
109
|
+
}
|
|
110
|
+
for _, want := range []string{
|
|
111
|
+
"## REST API contract",
|
|
112
|
+
"**Source:** https://docs.example.com/api-reference/v2-openapi.json",
|
|
113
|
+
"\"capability\": \"find-tools\"",
|
|
114
|
+
"curl https://api.example.com/v2/search",
|
|
115
|
+
"curl https://api.example.com/v2/scrape",
|
|
116
|
+
"**Coverage:** 2 source document(s)",
|
|
117
|
+
} {
|
|
118
|
+
if !strings.Contains(result, want) {
|
|
119
|
+
t.Errorf("focused read missing %q:\n%s", want, result)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestOpenAPIDiscoveryNeverCrossesToUnrelatedHost(t *testing.T) {
|
|
125
|
+
oldClient := http.DefaultClient
|
|
126
|
+
t.Cleanup(func() { http.DefaultClient = oldClient })
|
|
127
|
+
var calledOutsideIndex bool
|
|
128
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
129
|
+
if req.URL.Path != "/llms.txt" {
|
|
130
|
+
calledOutsideIndex = true
|
|
131
|
+
}
|
|
132
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
133
|
+
"- [External OpenAPI](https://evil.example.com/openapi.json)\n"+
|
|
134
|
+
"- [Local OpenAPI](http://127.0.0.1/openapi.json)\n", nil), nil
|
|
135
|
+
})}
|
|
136
|
+
if found := discoverOpenAPIContract("https://docs.example.com/reference",
|
|
137
|
+
"Find exact REST API endpoint request examples"); found.Content != "" || calledOutsideIndex {
|
|
138
|
+
t.Fatalf("unsafe OpenAPI discovery: %+v, off-index=%v", found, calledOutsideIndex)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestReadLinkFocusedKeepsURLOnlyContract(t *testing.T) {
|
|
13
|
+
called := 0
|
|
14
|
+
reader := func(url string) (string, error) {
|
|
15
|
+
called++
|
|
16
|
+
if url != "https://docs.example.com/api" {
|
|
17
|
+
t.Fatalf("unexpected URL: %s", url)
|
|
18
|
+
}
|
|
19
|
+
return "# Entire document\n\nno focus needed", nil
|
|
20
|
+
}
|
|
21
|
+
got, err := readLinkFocused("https://docs.example.com/api", "", reader)
|
|
22
|
+
if err != nil || got != "# Entire document\n\nno focus needed" || called != 1 {
|
|
23
|
+
t.Fatalf("URL-only path changed: %q, %v, calls=%d", got, err, called)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestFocusedReadExpandsRelevantSameHostLinks(t *testing.T) {
|
|
28
|
+
old := http.DefaultClient
|
|
29
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
30
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
31
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
32
|
+
})}
|
|
33
|
+
urls := map[string]string{
|
|
34
|
+
"https://docs.example.com/guide": "# Guide\n[Authentication](./auth)\n[Pagination](/pagination)\n[Offsite](https://evil.example/pagination)\n[Secret](./auth?token=secret)\n[Self](#pagination)",
|
|
35
|
+
"https://docs.example.com/auth": "# Authentication\nSet the Authorization header with your token.",
|
|
36
|
+
"https://docs.example.com/pagination": "# Pagination\nPass next_cursor to read the next page.",
|
|
37
|
+
}
|
|
38
|
+
requested := make(map[string]int)
|
|
39
|
+
var requestedMu sync.Mutex
|
|
40
|
+
reader := func(url string) (string, error) {
|
|
41
|
+
requestedMu.Lock()
|
|
42
|
+
requested[url]++
|
|
43
|
+
requestedMu.Unlock()
|
|
44
|
+
value, ok := urls[url]
|
|
45
|
+
if !ok {
|
|
46
|
+
return "", errors.New("missing URL")
|
|
47
|
+
}
|
|
48
|
+
return value, nil
|
|
49
|
+
}
|
|
50
|
+
got, err := readLinkFocused("https://docs.example.com/guide", "How do authentication and pagination work?", reader)
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
for _, want := range []string{"next_cursor", "Authorization header", "https://docs.example.com/auth", "https://docs.example.com/pagination", "**Coverage:** 3 source document(s)"} {
|
|
55
|
+
if !strings.Contains(got, want) {
|
|
56
|
+
t.Errorf("missing %q in result:\n%s", want, got)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if len(requested) != 3 {
|
|
60
|
+
t.Fatalf("followed unsafe/irrelevant link: %+v", requested)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func TestFocusedExcerptPrioritizesRelevantHeadingAndCode(t *testing.T) {
|
|
65
|
+
large := "# Docs\n" + strings.Repeat("Large unrelated introduction.\n", 600) + "\n## Authenticated requests\nUse an Authorization header.\n```go\nheaders.Set(\"Authorization\", token)\n```\n\n## Other\n" + strings.Repeat("Unrelated text\n", 200)
|
|
66
|
+
got := focusExcerpt(large, "How to authenticate requests with Go?", 3000)
|
|
67
|
+
if !strings.Contains(got, "headers.Set") || strings.Contains(got, "Large unrelated introduction.Large") {
|
|
68
|
+
t.Fatalf("did not select API code example: %s", got)
|
|
69
|
+
}
|
|
70
|
+
if len(got) > 3500 {
|
|
71
|
+
t.Fatalf("excerpt unexpectedly large: %d", len(got))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestSearchResearchReturnsEvidenceAndSupportingSource(t *testing.T) {
|
|
76
|
+
results := []SearchDoc{
|
|
77
|
+
{URL: "https://docs.example.com/guide", Title: "Guide"},
|
|
78
|
+
{URL: "https://github.com/example/project", Title: "Source"},
|
|
79
|
+
{URL: "https://other.example.com/blog", Title: "Blog"},
|
|
80
|
+
}
|
|
81
|
+
reader := func(url string) (string, error) {
|
|
82
|
+
switch url {
|
|
83
|
+
case "https://docs.example.com/guide":
|
|
84
|
+
return "# Auth\nUse OAuth.\n[Pagination](/pagination)", nil
|
|
85
|
+
case "https://docs.example.com/pagination":
|
|
86
|
+
return "# Pagination\nnext_cursor is optional.", nil
|
|
87
|
+
case "https://github.com/example/project":
|
|
88
|
+
return "# Source\nOAuth settings are configurable.", nil
|
|
89
|
+
case "https://other.example.com/blog":
|
|
90
|
+
return "# Blog\nOAuth authorization examples.", nil
|
|
91
|
+
default:
|
|
92
|
+
return "", fmt.Errorf("unexpected URL: %s", url)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
got := enrichSearchWithReader("OAuth pagination", results, reader)
|
|
96
|
+
if !strings.Contains(got, "Use OAuth.") || !strings.Contains(got, "next_cursor") || !strings.Contains(got, "Supporting reference") {
|
|
97
|
+
t.Fatalf("search lacked fetched source context: %s", got)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestFollowLinksRejectsOtherHostsSecretsAndAssets(t *testing.T) {
|
|
102
|
+
markdown := "[API auth](https://other.example.com/auth) [API auth](/auth?api_key=private) [API auth](/image.png) [API auth](http://127.0.0.1/auth) [API auth](/auth)"
|
|
103
|
+
got := relatedSourceLinks(markdown, "https://docs.example.com/guide", "api auth", 5)
|
|
104
|
+
if len(got) != 1 || got[0].url != "https://docs.example.com/auth" {
|
|
105
|
+
t.Fatalf("unsafe or irrelevant candidates: %+v", got)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestRelevanceMatchingDoesNotConfuseAPIWithScraping(t *testing.T) {
|
|
110
|
+
if termScore("advanced scraping guide", []string{"api"}) != 0 {
|
|
111
|
+
t.Fatal("API should not match the middle of the word scraping")
|
|
112
|
+
}
|
|
113
|
+
links := relatedSourceLinks("[Advanced scraping](/advanced-scraping-guide) [API endpoint reference](/api-reference/endpoint/scrape)", "https://docs.example.com/features/alexandria", "Alexandria API endpoints", 3)
|
|
114
|
+
if len(links) != 1 || !strings.Contains(links[0].url, "api-reference") {
|
|
115
|
+
t.Fatalf("irrelevant docs were followed: %+v", links)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func TestSearchPrefersTechnicalReferenceOverCommunityPages(t *testing.T) {
|
|
120
|
+
results := []SearchDoc{
|
|
121
|
+
{URL: "https://modelcontextprotocol.io/community/interest-groups/tool-annotations", Title: "Tool annotations interest group"},
|
|
122
|
+
{URL: "https://modelcontextprotocol.io/specification/2025-11-25/server/tools", Title: "Tools specification"},
|
|
123
|
+
{URL: "https://github.com/modelcontextprotocol/go-sdk", Title: "Go SDK for MCP"},
|
|
124
|
+
{URL: "https://pkg.go.dev/golang.org/x/tools/internal/mcp", Title: "Internal Go MCP"},
|
|
125
|
+
}
|
|
126
|
+
selected := chooseSearchSources("Go MCP SDK tool annotations", results, 2)
|
|
127
|
+
if len(selected) != 2 || selected[0].url != "https://github.com/modelcontextprotocol/go-sdk" || !strings.Contains(selected[1].url, "/specification/") {
|
|
128
|
+
t.Fatalf("selected irrelevant pages over official SDK/spec: %+v", selected)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestFollowLinksSkipGenericMCPLinksForSpecificQuestion(t *testing.T) {
|
|
133
|
+
content := "[Skills over MCP](/community/working-groups/skills-over-mcp) [Tool annotations](/specification/server/tools)"
|
|
134
|
+
links := relatedSourceLinks(content, "https://modelcontextprotocol.io/docs/tools", "Go MCP SDK tool annotations", 2)
|
|
135
|
+
if len(links) != 1 || !strings.Contains(links[0].url, "/specification/server/tools") {
|
|
136
|
+
t.Fatalf("followed generic cross-topic link: %+v", links)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func TestSelectJSONFieldsPreservesSyntaxAndPaths(t *testing.T) {
|
|
141
|
+
obj := map[string]any{"paths": map[string]any{"/v2/scrape": map[string]any{"post": map[string]any{"summary": "Scrape an API document"}}}, "metadata": strings.Repeat("x", 5000)}
|
|
142
|
+
got := selectedJSONFields(obj, "scrape", 1400)
|
|
143
|
+
if !strings.Contains(got, "/paths/~1v2~1scrape") || !strings.Contains(got, "```json") || !strings.Contains(got, "Scrape an API document") {
|
|
144
|
+
t.Fatalf("JSON selection missing source pointers: %s", got)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func TestLargeOpenAPISchemaUsesSourceJSONPointers(t *testing.T) {
|
|
149
|
+
source := `**URL:** https://docs.example.com/openapi.json` + "\n\n```json\n" + `{"paths":{"/v2/scrape":{"post":{"summary":"Scrape pages"}}},"large":"` + strings.Repeat("x", 12000) + `"}` + "\n```"
|
|
150
|
+
got := focusExcerpt(source, "scrape endpoint", 2500)
|
|
151
|
+
if !strings.Contains(got, "/paths/~1v2~1scrape") || !strings.Contains(got, "Scrape pages") || len(got) > 2700 {
|
|
152
|
+
t.Fatalf("large raw JSON should be selected by pointer: %s", got)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"log"
|
|
6
|
+
"net/url"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// CAPTCHA pages can contain thousands of bytes of translated prompts. Page
|
|
13
|
+
// length is not evidence of useful content, so challenge detection must not
|
|
14
|
+
// be gated on the size of the extracted Markdown.
|
|
15
|
+
func challengeReason(markdown string) string {
|
|
16
|
+
text := strings.ToLower(strings.TrimSpace(markdown))
|
|
17
|
+
if text == "" {
|
|
18
|
+
return "empty source"
|
|
19
|
+
}
|
|
20
|
+
prefix := text[:min(len(text), 4000)]
|
|
21
|
+
for _, marker := range []string{
|
|
22
|
+
"just a moment...",
|
|
23
|
+
"checking your browser",
|
|
24
|
+
"access denied | cloudflare",
|
|
25
|
+
"attention required! | cloudflare",
|
|
26
|
+
"enable javascript and cookies",
|
|
27
|
+
"no content extracted",
|
|
28
|
+
"please complete the security check",
|
|
29
|
+
"verify you are human",
|
|
30
|
+
"verifying you are human",
|
|
31
|
+
"verify that you are human",
|
|
32
|
+
"please verify you are a human",
|
|
33
|
+
"we need to verify your browser",
|
|
34
|
+
"challenge-platform",
|
|
35
|
+
} {
|
|
36
|
+
if strings.Contains(prefix, marker) {
|
|
37
|
+
return "browser verification challenge"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if strings.Contains(prefix, "hcaptcha") || strings.Contains(prefix, "h-captcha") {
|
|
41
|
+
for _, secondary := range []string{
|
|
42
|
+
"select all", "please verify", "i am human",
|
|
43
|
+
"privacy - terms", "please complete", "security check",
|
|
44
|
+
"captcha challenge", "verify your", "please solve",
|
|
45
|
+
} {
|
|
46
|
+
if strings.Contains(text, secondary) {
|
|
47
|
+
return "hCaptcha challenge"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (strings.Count(text, "captcha") > 2 || strings.Count(text, "verify you are human") > 0) &&
|
|
52
|
+
(strings.Contains(text, "try again") || strings.Contains(text, "challenge") || strings.Contains(text, "i am human")) {
|
|
53
|
+
return "CAPTCHA challenge"
|
|
54
|
+
}
|
|
55
|
+
return ""
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func usableSourceContent(markdown string) bool {
|
|
59
|
+
text := strings.TrimSpace(markdown)
|
|
60
|
+
if len(text) < 10 || challengeReason(text) != "" {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
// Guard against a 200 status containing an HTML block/error page.
|
|
64
|
+
prefix := strings.ToLower(text[:min(len(text), 350)])
|
|
65
|
+
return !strings.Contains(prefix, "<!doctype html") &&
|
|
66
|
+
!strings.Contains(prefix, "<html")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// WEBCTX_DEBUG=1 enables stage traces. Only host + URL path are included;
|
|
70
|
+
// query parameters and credentials are intentionally not logged.
|
|
71
|
+
func traceRetrieval(rawURL, stage, outcome string, start time.Time, err error) {
|
|
72
|
+
if os.Getenv("WEBCTX_DEBUG") != "1" {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
source := "invalid-url"
|
|
76
|
+
if u, parseErr := url.Parse(rawURL); parseErr == nil && u.Hostname() != "" {
|
|
77
|
+
source = u.Hostname() + u.EscapedPath()
|
|
78
|
+
}
|
|
79
|
+
detail := ""
|
|
80
|
+
if err != nil {
|
|
81
|
+
detail = fmt.Sprintf(" error=%q", sanitizedRetrievalError(err))
|
|
82
|
+
}
|
|
83
|
+
log.Printf("webctx retrieval source=%q stage=%q outcome=%q duration_ms=%d%s",
|
|
84
|
+
source, stage, outcome, time.Since(start).Milliseconds(), detail)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func sanitizedRetrievalError(err error) string {
|
|
88
|
+
if err == nil {
|
|
89
|
+
return ""
|
|
90
|
+
}
|
|
91
|
+
message := strings.ToLower(err.Error())
|
|
92
|
+
switch {
|
|
93
|
+
case strings.Contains(message, "403"), strings.Contains(message, "forbidden"):
|
|
94
|
+
return "forbidden"
|
|
95
|
+
case strings.Contains(message, "429"), strings.Contains(message, "rate limit"):
|
|
96
|
+
return "rate-limited"
|
|
97
|
+
case strings.Contains(message, "timeout"), strings.Contains(message, "deadline"):
|
|
98
|
+
return "timeout"
|
|
99
|
+
case strings.Contains(message, "captcha"), strings.Contains(message, "challenge"):
|
|
100
|
+
return "challenge"
|
|
101
|
+
case strings.Contains(message, "404"), strings.Contains(message, "not found"):
|
|
102
|
+
return "not-found"
|
|
103
|
+
default:
|
|
104
|
+
return "source-unavailable"
|
|
105
|
+
}
|
|
106
|
+
}
|