webctx 0.2.4 → 0.2.6
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/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +133 -0
- package/internal/app/data_client.go +214 -0
- package/internal/app/data_examples.go +112 -0
- package/internal/app/data_execute.go +435 -0
- package/internal/app/data_inspect.go +174 -0
- package/internal/app/data_research.go +340 -0
- package/internal/app/data_tools_test.go +444 -0
- package/internal/app/data_types.go +39 -0
- package/internal/app/research.go +20 -4
- package/internal/app/research_copyable.go +48 -0
- package/internal/app/research_copyable_test.go +57 -0
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +117 -0
- package/pkg/origo/server_test.go +63 -6
- package/pkg/retrieval/data.go +14 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"io"
|
|
7
|
+
"net/http"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func dataTestContract() map[string]any {
|
|
14
|
+
return map[string]any{
|
|
15
|
+
"id": "particle/podcasts/episodes/search",
|
|
16
|
+
"provider": "particle",
|
|
17
|
+
"capability": "podcasts/episodes/search",
|
|
18
|
+
"name": "Search podcast conversations",
|
|
19
|
+
"description": "Find episodes by what people discussed.",
|
|
20
|
+
"creditsCost": 15,
|
|
21
|
+
"perRecord": false,
|
|
22
|
+
"requiresOneOf": []any{[]any{"semantic_search", "keyword_search"}},
|
|
23
|
+
"options": []any{
|
|
24
|
+
map[string]any{"name": "semantic_search", "type": "string", "about": "Topic described in your own words."},
|
|
25
|
+
map[string]any{"name": "keyword_search", "type": "string", "about": "Exact transcript keyword."},
|
|
26
|
+
map[string]any{"name": "limit", "type": "number", "default": 25, "min": 1, "max": 100},
|
|
27
|
+
map[string]any{"name": "cursor", "type": "string", "about": "Opaque pagination token."},
|
|
28
|
+
},
|
|
29
|
+
"response": map[string]any{"key": "data", "paginated": true, "fields": []any{map[string]any{"name": "episode", "type": "object"}}},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type dataTestTransport struct {
|
|
34
|
+
mu sync.Mutex
|
|
35
|
+
executions int
|
|
36
|
+
lastHeaders http.Header
|
|
37
|
+
lastPayload map[string]any
|
|
38
|
+
blockPaid bool
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
42
|
+
if req.URL.Host != "api.firecrawl.dev" || req.Method != http.MethodPost {
|
|
43
|
+
return testHTTPResponse(req, http.StatusNotFound, "{}", nil), nil
|
|
44
|
+
}
|
|
45
|
+
body, err := io.ReadAll(req.Body)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return nil, err
|
|
48
|
+
}
|
|
49
|
+
var payload map[string]any
|
|
50
|
+
if err := json.Unmarshal(body, &payload); err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
d.mu.Lock()
|
|
54
|
+
d.lastPayload = payload
|
|
55
|
+
d.lastHeaders = req.Header.Clone()
|
|
56
|
+
d.mu.Unlock()
|
|
57
|
+
write := func(status int, content map[string]any) (*http.Response, error) {
|
|
58
|
+
b, _ := json.Marshal(content)
|
|
59
|
+
return testHTTPResponse(req, status, string(b), nil), nil
|
|
60
|
+
}
|
|
61
|
+
if req.URL.Path == "/v2/search" {
|
|
62
|
+
if strings.Contains(stringField(payload["query"]), "relevance ordering") {
|
|
63
|
+
low := dataTestContract()
|
|
64
|
+
low["id"] = "particle/podcasts/episodes/list"
|
|
65
|
+
low["similarity"] = 0.1
|
|
66
|
+
high := dataTestContract()
|
|
67
|
+
high["id"] = "particle/podcasts/episodes/search"
|
|
68
|
+
high["similarity"] = 0.97
|
|
69
|
+
middle := dataTestContract()
|
|
70
|
+
middle["id"] = "particle/podcasts/episode"
|
|
71
|
+
middle["similarity"] = 0.8
|
|
72
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{"tools": []any{low, high, middle}}})
|
|
73
|
+
}
|
|
74
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
75
|
+
"tools": []any{dataTestContract()},
|
|
76
|
+
}})
|
|
77
|
+
}
|
|
78
|
+
if req.URL.Path != "/v2/scrape" {
|
|
79
|
+
return write(404, map[string]any{"success": false})
|
|
80
|
+
}
|
|
81
|
+
if operation := mapField(payload["alexandria"]); operation != nil &&
|
|
82
|
+
operation["provider"] == "firecrawl" && operation["capability"] == "find-tools" {
|
|
83
|
+
opts := mapField(operation["options"])
|
|
84
|
+
if opts["level"] == "providers" {
|
|
85
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
86
|
+
"alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
|
|
87
|
+
"level": "providers", "total": 3,
|
|
88
|
+
"items": []any{map[string]any{"id": "particle", "name": "Particle", "description": "Podcast data", "toolCount": 40}},
|
|
89
|
+
"next": map[string]any{"provider": "firecrawl", "capability": "find-tools", "options": map[string]any{
|
|
90
|
+
"level": "providers", "limit": 2, "offset": 2,
|
|
91
|
+
}},
|
|
92
|
+
}}},
|
|
93
|
+
}})
|
|
94
|
+
}
|
|
95
|
+
return write(200, map[string]any{"success": true, "data": map[string]any{
|
|
96
|
+
"alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools", "creditsCost": 0, "data": map[string]any{
|
|
97
|
+
"level": "tools", "total": 1, "items": []any{dataTestContract()},
|
|
98
|
+
}}},
|
|
99
|
+
}})
|
|
100
|
+
}
|
|
101
|
+
if calls := listField(payload["alexandria"]); len(calls) > 0 {
|
|
102
|
+
d.mu.Lock()
|
|
103
|
+
d.executions++
|
|
104
|
+
d.mu.Unlock()
|
|
105
|
+
if d.blockPaid {
|
|
106
|
+
return write(403, map[string]any{
|
|
107
|
+
"success": false, "code": "THIRD_PARTY_DATA_TERMS_REQUIRED",
|
|
108
|
+
"error": "Provider terms have not been accepted.",
|
|
109
|
+
"requiresAction": map[string]any{"url": "https://firecrawl.dev/app/settings?tab=data-sources"},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
var results []any
|
|
113
|
+
var total int
|
|
114
|
+
for _, value := range calls {
|
|
115
|
+
call := mapField(value)
|
|
116
|
+
results = append(results, map[string]any{
|
|
117
|
+
"provider": call["provider"], "capability": call["capability"],
|
|
118
|
+
"creditsCost": 15, "alexandriaId": "paid-data-id-1",
|
|
119
|
+
"data": map[string]any{"data": []any{map[string]any{"episode": map[string]any{"id": "ep_1"}}}, "next_cursor": "after-1"},
|
|
120
|
+
})
|
|
121
|
+
total += 15
|
|
122
|
+
}
|
|
123
|
+
return write(200, map[string]any{
|
|
124
|
+
"success": true, "data": map[string]any{"alexandria": results, "creditsCost": total},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
return write(400, map[string]any{"success": false, "error": "Bad test request."})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestResearchSortsMatchesByActualSimilarity(t *testing.T) {
|
|
131
|
+
installDataTestTransport(t)
|
|
132
|
+
results, err := ResearchData(ResearchDataInput{Query: "relevance ordering", Limit: 3})
|
|
133
|
+
if err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
found := results["results"].([]map[string]any)
|
|
137
|
+
if found[0]["id"] != "particle/podcasts/episodes/search" ||
|
|
138
|
+
found[1]["id"] != "particle/podcasts/episode" ||
|
|
139
|
+
found[2]["id"] != "particle/podcasts/episodes/list" {
|
|
140
|
+
t.Fatalf("results were not ordered by reported relevance: %#v", found)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func installDataTestTransport(t *testing.T) *dataTestTransport {
|
|
145
|
+
t.Helper()
|
|
146
|
+
old := http.DefaultClient
|
|
147
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
148
|
+
d := &dataTestTransport{}
|
|
149
|
+
http.DefaultClient = &http.Client{Transport: d}
|
|
150
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-key")
|
|
151
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "100")
|
|
152
|
+
return d
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
|
|
156
|
+
mock := installDataTestTransport(t)
|
|
157
|
+
found, err := ResearchData(ResearchDataInput{Query: "podcast conversations about AI agents", Limit: 2})
|
|
158
|
+
if err != nil {
|
|
159
|
+
t.Fatal(err)
|
|
160
|
+
}
|
|
161
|
+
if found["cost_credits"] != 0 || found["mode"] != "ranked_search" {
|
|
162
|
+
t.Fatalf("ranked discovery not free: %#v", found)
|
|
163
|
+
}
|
|
164
|
+
results := found["results"].([]map[string]any)
|
|
165
|
+
id := results[0]["id"].(string)
|
|
166
|
+
if id != "particle/podcasts/episodes/search" {
|
|
167
|
+
t.Fatalf("wrong discovery ID: %s", id)
|
|
168
|
+
}
|
|
169
|
+
next := mapField(results[0]["next"])
|
|
170
|
+
if next["tool"] != "inspect" || mapField(next["arguments"])["id"] != id {
|
|
171
|
+
t.Fatalf("discovery did not provide inspect arguments: %#v", next)
|
|
172
|
+
}
|
|
173
|
+
details, err := InspectData(InspectDataInput{ID: id})
|
|
174
|
+
if err != nil {
|
|
175
|
+
t.Fatal(err)
|
|
176
|
+
}
|
|
177
|
+
if mapField(details["price"])["credits_per_call"] != float64(15) {
|
|
178
|
+
t.Fatalf("missing pricing: %#v", details)
|
|
179
|
+
}
|
|
180
|
+
if len(listField(mapField(details["inputs"])["fields"])) != 4 {
|
|
181
|
+
t.Fatalf("missing upstream contract fields: %#v", details["inputs"])
|
|
182
|
+
}
|
|
183
|
+
generated := listField(details["examples"])
|
|
184
|
+
if len(generated) == 0 || mapField(generated[0])["valid_input_shape"] != true {
|
|
185
|
+
t.Fatalf("inspection must generate an explicitly labeled, schema-valid example: %#v", details["examples"])
|
|
186
|
+
}
|
|
187
|
+
continued := mapField(details["next"])
|
|
188
|
+
if continued["tool"] != "execute" {
|
|
189
|
+
t.Fatalf("no next execution: %#v", continued)
|
|
190
|
+
}
|
|
191
|
+
args := mapField(continued["arguments"])
|
|
192
|
+
skeleton := listField(args["calls"])
|
|
193
|
+
if len(skeleton) != 1 {
|
|
194
|
+
t.Fatalf("invalid next argument shape: %#v", args)
|
|
195
|
+
}
|
|
196
|
+
inspectedCall := mapField(skeleton[0])
|
|
197
|
+
if inspectedCall == nil || inspectedCall["id"] != id || mapField(inspectedCall["inputs"])["semantic_search"] != nil {
|
|
198
|
+
t.Fatalf("invalid template: %#v", skeleton)
|
|
199
|
+
}
|
|
200
|
+
got, err := ExecuteData(ExecuteDataInput{
|
|
201
|
+
Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
|
|
202
|
+
})
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
if got["credits_used"] != float64(15) {
|
|
207
|
+
t.Fatalf("credits missing: %#v", got)
|
|
208
|
+
}
|
|
209
|
+
executions := got["results"].([]map[string]any)
|
|
210
|
+
if executions[0]["data_id"] != "paid-data-id-1" {
|
|
211
|
+
t.Fatalf("provider provenance missing: %#v", got)
|
|
212
|
+
}
|
|
213
|
+
followup := mapField(executions[0]["next"])
|
|
214
|
+
if followup["tool"] != "execute" {
|
|
215
|
+
t.Fatalf("missing cursor continuation: %#v", followup)
|
|
216
|
+
}
|
|
217
|
+
followArgs := mapField(followup["arguments"])
|
|
218
|
+
nextCall := mapField(listField(followArgs["calls"])[0])
|
|
219
|
+
if mapField(nextCall["inputs"])["cursor"] != "after-1" {
|
|
220
|
+
t.Fatalf("pagination lost filters: %#v", nextCall)
|
|
221
|
+
}
|
|
222
|
+
mock.mu.Lock()
|
|
223
|
+
defer mock.mu.Unlock()
|
|
224
|
+
if mock.executions != 1 || !safeRequestID.MatchString(got["request_id"].(string)) ||
|
|
225
|
+
mock.lastHeaders.Get("x-request-id") != got["request_id"].(string) {
|
|
226
|
+
t.Fatalf("request not paid once with stable ID: executions=%d req=%s", mock.executions, got["request_id"])
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func TestCatalogueBrowsingReturnsExactPaginationCall(t *testing.T) {
|
|
231
|
+
installDataTestTransport(t)
|
|
232
|
+
result, err := ResearchData(ResearchDataInput{View: "sources", Limit: 2})
|
|
233
|
+
if err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
if result["view"] != "sources" || result["total"] != float64(3) {
|
|
237
|
+
t.Fatalf("bad catalogue: %#v", result)
|
|
238
|
+
}
|
|
239
|
+
next := mapField(result["next"])
|
|
240
|
+
args := mapField(next["arguments"])
|
|
241
|
+
if next["tool"] != "research" || args["offset"] != float64(2) || args["view"] != "sources" || args["mode"] != "catalogue" {
|
|
242
|
+
t.Fatalf("invalid pagination continuation: %#v", next)
|
|
243
|
+
}
|
|
244
|
+
fromSource, err := InspectData(InspectDataInput{ID: "particle"})
|
|
245
|
+
if err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
if fromSource["view"] != "operations" {
|
|
249
|
+
t.Fatalf("provider exploration failed: %#v", fromSource)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func TestExecutionValidationAndServerLimitRejectBeforePaidCall(t *testing.T) {
|
|
254
|
+
mock := installDataTestTransport(t)
|
|
255
|
+
id := "particle/podcasts/episodes/search"
|
|
256
|
+
for _, testCase := range []struct {
|
|
257
|
+
inputs map[string]any
|
|
258
|
+
code string
|
|
259
|
+
}{
|
|
260
|
+
{map[string]any{}, "invalid_inputs"},
|
|
261
|
+
{map[string]any{"semantic_search": "AI agents", "limit": 101}, "invalid_inputs"},
|
|
262
|
+
{map[string]any{"semantic_search": "AI agents", "unknown_field": 123}, "invalid_inputs"},
|
|
263
|
+
} {
|
|
264
|
+
_, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}})
|
|
265
|
+
typed, ok := err.(*DataError)
|
|
266
|
+
if !ok || typed.Code != testCase.code {
|
|
267
|
+
t.Fatalf("wanted %s, got %v", testCase.code, err)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
mock.mu.Lock()
|
|
271
|
+
defer mock.mu.Unlock()
|
|
272
|
+
if mock.executions != 0 {
|
|
273
|
+
t.Fatalf("invalid calls charged credits: %d", mock.executions)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
|
|
278
|
+
mock := installDataTestTransport(t)
|
|
279
|
+
mock.blockPaid = true
|
|
280
|
+
_, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{
|
|
281
|
+
ID: "particle/podcasts/episodes/search",
|
|
282
|
+
Inputs: map[string]any{"semantic_search": "AI agents"},
|
|
283
|
+
}}})
|
|
284
|
+
typed, ok := err.(*DataError)
|
|
285
|
+
if !ok || typed.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" ||
|
|
286
|
+
mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
|
|
287
|
+
t.Fatalf("terms condition lost: %+v", err)
|
|
288
|
+
}
|
|
289
|
+
if mapField(typed.Details)["request_id"] == nil {
|
|
290
|
+
t.Fatalf("lost retriable request identifier: %+v", typed)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func TestCLIResearchAndInspectJSON(t *testing.T) {
|
|
295
|
+
installDataTestTransport(t)
|
|
296
|
+
var out, errBuf bytes.Buffer
|
|
297
|
+
if code := Run([]string{"research", "podcasts about AI agents", "--limit", "2", "--raw"}, &out, &errBuf); code != 0 {
|
|
298
|
+
t.Fatalf("CLI research failed: %s", errBuf.String())
|
|
299
|
+
}
|
|
300
|
+
var result map[string]any
|
|
301
|
+
if json.Unmarshal(out.Bytes(), &result) != nil || mapField(result["raw"]) == nil {
|
|
302
|
+
t.Fatalf("CLI JSON or --raw missing: %s", out.String())
|
|
303
|
+
}
|
|
304
|
+
out.Reset()
|
|
305
|
+
if code := Run([]string{"inspect", "particle/podcasts/episodes/search"}, &out, &errBuf); code != 0 {
|
|
306
|
+
t.Fatalf("CLI inspect failed: %s", errBuf.String())
|
|
307
|
+
}
|
|
308
|
+
if !strings.Contains(out.String(), "semantic_search") || strings.Contains(out.String(), "max_credits") {
|
|
309
|
+
t.Fatalf("CLI inspect omitted exact execute syntax: %s", out.String())
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
func TestProviderNextOffsetCreatesCopyReadyContinuation(t *testing.T) {
|
|
314
|
+
id := "fred-stlouisfed-org/economic-data/series_observations"
|
|
315
|
+
inputs := map[string]any{"series_id": "CPIAUCSL", "limit": 2.0, "offset": 0.0}
|
|
316
|
+
next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0})
|
|
317
|
+
if mapField(next)["tool"] != "execute" {
|
|
318
|
+
t.Fatalf("expected executable pagination: %+v", next)
|
|
319
|
+
}
|
|
320
|
+
args := mapField(mapField(next)["arguments"])
|
|
321
|
+
call := mapField(listField(args["calls"])[0])
|
|
322
|
+
fields := mapField(call["inputs"])
|
|
323
|
+
if fields["offset"] != 2 || fields["series_id"] != "CPIAUCSL" {
|
|
324
|
+
t.Fatalf("next offset dropped exact source inputs: %+v", fields)
|
|
325
|
+
}
|
|
326
|
+
if original, _ := numberField(inputs["offset"]); original != 0 {
|
|
327
|
+
t.Fatal("original inputs mutated")
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func TestPerRecordPricingRequiresKnownBound(t *testing.T) {
|
|
332
|
+
contract := map[string]any{"creditsCost": float64(5), "perRecord": true, "options": []any{}}
|
|
333
|
+
if n, err := expectedCallCredits(contract, map[string]any{"limit": float64(3)}); err != nil || n != 15 {
|
|
334
|
+
t.Fatalf("bad predicted per-record charge: %d %v", n, err)
|
|
335
|
+
}
|
|
336
|
+
if _, err := expectedCallCredits(contract, map[string]any{}); err == nil {
|
|
337
|
+
t.Fatal("unknown record count must not trigger unbounded paid call")
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestNumericEnumsMayArriveAsUpstreamStrings(t *testing.T) {
|
|
342
|
+
contract := map[string]any{"id": "example/enum", "options": []any{
|
|
343
|
+
map[string]any{"name": "year", "type": "number", "required": true, "oneOf": []any{"2021", "2023"}},
|
|
344
|
+
}}
|
|
345
|
+
if err := validateDataInputs(contract, map[string]any{"year": float64(2023)}); err != nil {
|
|
346
|
+
t.Fatalf("upstream numeric enum from strings should work: %v", err)
|
|
347
|
+
}
|
|
348
|
+
if err := validateDataInputs(contract, map[string]any{"year": float64(2026)}); err == nil {
|
|
349
|
+
t.Fatal("unlisted numeric value must be rejected")
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func TestInspectOnlyOutputStillHasExecuteInputTemplate(t *testing.T) {
|
|
354
|
+
installDataTestTransport(t)
|
|
355
|
+
details, err := InspectData(InspectDataInput{ID: "particle/podcasts/episodes/search", Include: []string{"output"}})
|
|
356
|
+
if err != nil {
|
|
357
|
+
t.Fatal(err)
|
|
358
|
+
}
|
|
359
|
+
if mapField(details["input_template"])["semantic_search"] != nil || len(listField(mapField(details["inputs"])["fields"])) != 4 {
|
|
360
|
+
t.Fatalf("missing necessary next-call schema after include=output: %+v", details)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func TestServerManagedCreditCeilingCannotBeOverridden(t *testing.T) {
|
|
365
|
+
mock := installDataTestTransport(t)
|
|
366
|
+
// The ceiling defaults to 200 and an invalidly high environment value
|
|
367
|
+
// cannot silently widen it beyond 200.
|
|
368
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "200")
|
|
369
|
+
if got := dataCreditCap(); got != 200 {
|
|
370
|
+
t.Fatalf("bad production cap: %d", got)
|
|
371
|
+
}
|
|
372
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "2000")
|
|
373
|
+
if got := dataCreditCap(); got != 200 {
|
|
374
|
+
t.Fatalf("server cap exceeded 200: %d", got)
|
|
375
|
+
}
|
|
376
|
+
t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "120")
|
|
377
|
+
if got := dataCreditCap(); got != 120 {
|
|
378
|
+
t.Fatalf("server environment not respected: %d", got)
|
|
379
|
+
}
|
|
380
|
+
id := "particle/podcasts/episodes/search"
|
|
381
|
+
validInputs := map[string]any{"semantic_search": "AI agents"}
|
|
382
|
+
// 9*15=135: reject before ANY provider is queried.
|
|
383
|
+
batch := make([]DataCall, 9)
|
|
384
|
+
for i := range batch {
|
|
385
|
+
batch[i] = DataCall{ID: id, Inputs: validInputs}
|
|
386
|
+
}
|
|
387
|
+
_, err := ExecuteData(ExecuteDataInput{Calls: batch})
|
|
388
|
+
typed, ok := err.(*DataError)
|
|
389
|
+
if !ok || typed.Code != "server_credit_limit_exceeded" ||
|
|
390
|
+
mapField(typed.Details)["estimated_credits"] != 135 {
|
|
391
|
+
t.Fatalf("expected server-only limit, got %#v", err)
|
|
392
|
+
}
|
|
393
|
+
mock.mu.Lock()
|
|
394
|
+
defer mock.mu.Unlock()
|
|
395
|
+
if mock.executions != 0 {
|
|
396
|
+
t.Fatalf("server limit failed to prevent execution, calls=%d", mock.executions)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func TestClientCreditControlIsNotAvailableInCLI(t *testing.T) {
|
|
401
|
+
installDataTestTransport(t)
|
|
402
|
+
var stdout, stderr bytes.Buffer
|
|
403
|
+
code := Run([]string{"execute", "particle/podcasts/episodes/search", "--inputs", `{"semantic_search":"AI agents"}`, "--max-credits", "1"}, &stdout, &stderr)
|
|
404
|
+
if code != 1 || !strings.Contains(stderr.String(), `"code": "unsupported_flag"`) {
|
|
405
|
+
t.Fatalf("old client credit option should fail clearly, status=%d error=%s", code, stderr.String())
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func TestCPIInterpretationDoesNotConfuseIndexWithInflation(t *testing.T) {
|
|
410
|
+
levels := dataMeasurementNote(map[string]any{"series_id": "CPIAUCSL", "units": "lin"})
|
|
411
|
+
if !strings.Contains(levels, "index levels") || !strings.Contains(levels, "not annual inflation percentages") {
|
|
412
|
+
t.Fatalf("CPI level explanation missing: %s", levels)
|
|
413
|
+
}
|
|
414
|
+
percentage := dataMeasurementNote(map[string]any{"series_id": "CPIAUCSL", "units": "pc1"})
|
|
415
|
+
if !strings.Contains(percentage, "percent change from one year ago") {
|
|
416
|
+
t.Fatalf("transformation note missing: %s", percentage)
|
|
417
|
+
}
|
|
418
|
+
if dataMeasurementNote(map[string]any{"series_id": "UNRATE", "units": "lin"}) != "" {
|
|
419
|
+
t.Fatal("unemployment must not get CPI-specific semantics")
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
func TestGeneratedFREDExampleUsesDocumentedSeries(t *testing.T) {
|
|
424
|
+
c := map[string]any{
|
|
425
|
+
"id": "fred-stlouisfed-org/economic-data/series_observations",
|
|
426
|
+
"options": []any{
|
|
427
|
+
map[string]any{"name": "series_id", "type": "string", "required": true, "about": "FRED series id, e.g. GDP, UNRATE and CPIAUCSL"},
|
|
428
|
+
map[string]any{"name": "limit", "type": "number", "default": 1000, "min": 1, "max": 10000},
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
examples := listField(contractExamples(c))
|
|
432
|
+
if len(examples) != 1 {
|
|
433
|
+
t.Fatalf("no fallback example: %#v", examples)
|
|
434
|
+
}
|
|
435
|
+
example := mapField(examples[0])
|
|
436
|
+
inputs := mapField(example["inputs"])
|
|
437
|
+
if example["kind"] != "generated_from_inspected_schema" || example["valid_input_shape"] != true ||
|
|
438
|
+
inputs["series_id"] != "CPIAUCSL" || inputs["limit"] != 5 {
|
|
439
|
+
t.Fatalf("unexpected generated FRED sample: %#v", examples)
|
|
440
|
+
}
|
|
441
|
+
if mapField(example["next"])["tool"] != "execute" {
|
|
442
|
+
t.Fatalf("generated example cannot be copied into execute: %#v", example)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
// ResearchData discovers data sources and their available operations. Search
|
|
4
|
+
// and catalogue browsing are both free Firecrawl Alexandria operations.
|
|
5
|
+
type ResearchDataInput struct {
|
|
6
|
+
Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
|
|
7
|
+
Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
|
|
8
|
+
URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
|
|
9
|
+
Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
|
|
10
|
+
Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
|
|
11
|
+
Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
|
|
12
|
+
Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
|
|
13
|
+
View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
|
|
14
|
+
Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
|
|
15
|
+
Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
|
|
16
|
+
Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
|
|
17
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// InspectDataInput returns the exact contract for a selected operation, or
|
|
21
|
+
// browses all operations of a source when passed just a source ID.
|
|
22
|
+
type InspectDataInput struct {
|
|
23
|
+
ID string `json:"id" jsonschema:"Source or operation ID returned by research, e.g. particle/podcasts/episodes/search"`
|
|
24
|
+
Include []string `json:"include,omitempty" jsonschema:"Contract sections: inputs, output, examples; all by default"`
|
|
25
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original Firecrawl response"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type DataCall struct {
|
|
29
|
+
ID string `json:"id" jsonschema:"Exact operation ID returned by research or inspect"`
|
|
30
|
+
Inputs map[string]any `json:"inputs,omitempty" jsonschema:"Provider-specific input fields copied from the inspected contract"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ExecuteDataInput supports up to ten calls in a single provider request.
|
|
34
|
+
// It validates each call against the free catalogue before using credits.
|
|
35
|
+
type ExecuteDataInput struct {
|
|
36
|
+
Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
|
|
37
|
+
RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
|
|
38
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
|
|
39
|
+
}
|
package/internal/app/research.go
CHANGED
|
@@ -87,6 +87,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
87
87
|
if err != nil {
|
|
88
88
|
return "", err
|
|
89
89
|
}
|
|
90
|
+
primary = copyableCurlExamples(primary)
|
|
90
91
|
|
|
91
92
|
// Combine on-page links and authoritative Markdown index links instead of
|
|
92
93
|
// filling the entire budget from whichever links happen to appear first.
|
|
@@ -109,7 +110,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
109
110
|
"**Source:** " + rawURL,
|
|
110
111
|
"",
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
+
primaryBudget := focusedPrimaryBudget
|
|
114
|
+
if asksForAPIContracts(question) {
|
|
115
|
+
primaryBudget = 5300
|
|
116
|
+
}
|
|
117
|
+
primaryExcerpt := focusExcerpt(primary, question, primaryBudget)
|
|
113
118
|
parts = append(parts, primaryExcerpt)
|
|
114
119
|
successful := 1
|
|
115
120
|
relatedSuccessful := 0
|
|
@@ -124,7 +129,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
124
129
|
relatedSuccessful++
|
|
125
130
|
seenURLs[source.url] = true
|
|
126
131
|
allRetrieved += "\n" + source.content
|
|
127
|
-
|
|
132
|
+
relatedBudget := relatedExcerptBudget(source.url)
|
|
133
|
+
if asksForAPIContracts(question) && relatedBudget > 3000 {
|
|
134
|
+
relatedBudget = 3000
|
|
135
|
+
}
|
|
136
|
+
excerpt := focusExcerpt(copyableCurlExamples(source.content), question, relatedBudget)
|
|
128
137
|
outputEvidence += "\n" + excerpt
|
|
129
138
|
parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
|
|
130
139
|
}
|
|
@@ -172,7 +181,11 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
172
181
|
successful++
|
|
173
182
|
relatedSuccessful++
|
|
174
183
|
allRetrieved += "\n" + source.content
|
|
175
|
-
|
|
184
|
+
followupBudget := relatedExcerptBudget(source.url)
|
|
185
|
+
if asksForAPIContracts(question) && followupBudget > 3000 {
|
|
186
|
+
followupBudget = 3000
|
|
187
|
+
}
|
|
188
|
+
excerpt := focusExcerpt(copyableCurlExamples(source.content), question, followupBudget)
|
|
176
189
|
outputEvidence += "\n" + excerpt
|
|
177
190
|
parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
|
|
178
191
|
}
|
|
@@ -181,7 +194,10 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
181
194
|
if contract := <-openAPIDone; contract.Content != "" {
|
|
182
195
|
successful++
|
|
183
196
|
outputEvidence += "\n" + contract.Content
|
|
184
|
-
|
|
197
|
+
// Put exact REST contracts first so coding agents encounter the
|
|
198
|
+
// implementation-ready operations before lengthy source excerpts.
|
|
199
|
+
front := []string{"## REST API contract", "**Source:** " + contract.URL, "", contract.Content, ""}
|
|
200
|
+
parts = append(append(append([]string(nil), parts[:4]...), front...), parts[4:]...)
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
203
|
if structured := <-structuredDone; structured != "" {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var trailingPayloadSlash = regexp.MustCompile("(?m)\\\\[ \\t]*(\\r?\\n)")
|
|
10
|
+
|
|
11
|
+
// Some source scrapers leave JavaScript-style line-continuation slashes
|
|
12
|
+
// inside shell-quoted JSON. Those slashes make the published cURL request
|
|
13
|
+
// uncopyable. Repair only payloads that are initially invalid JSON and become
|
|
14
|
+
// valid JSON after removing those continuation markers. Label every repair.
|
|
15
|
+
// The original source URL remains in the surrounding cited context.
|
|
16
|
+
func copyableCurlExamples(markdown string) string {
|
|
17
|
+
parts := strings.Split(markdown, backtickFence)
|
|
18
|
+
if len(parts) < 3 {
|
|
19
|
+
return markdown
|
|
20
|
+
}
|
|
21
|
+
for i := 1; i < len(parts); i += 2 {
|
|
22
|
+
code := parts[i]
|
|
23
|
+
if !strings.Contains(code, "curl ") {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
start := strings.Index(code, "-d '{")
|
|
27
|
+
if start < 0 {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
bodyStart := start + len("-d '")
|
|
31
|
+
endRel := strings.LastIndex(code[bodyStart:], "}'")
|
|
32
|
+
if endRel < 0 {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
bodyEnd := bodyStart + endRel + 1
|
|
36
|
+
original := code[bodyStart:bodyEnd]
|
|
37
|
+
if json.Valid([]byte(original)) {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
fixed := trailingPayloadSlash.ReplaceAllString(original, "$1")
|
|
41
|
+
if fixed == original || !json.Valid([]byte(fixed)) {
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
parts[i] = code[:bodyStart] + fixed + code[bodyEnd:]
|
|
45
|
+
parts[i-1] += "\n\n*[Copy-ready correction: removed stray line-continuation backslashes inside the published JSON payload; see the cited URL for the original.]*\n"
|
|
46
|
+
}
|
|
47
|
+
return strings.Join(parts, backtickFence)
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestMalformedAlexandriaJSONBecomesCopyable(t *testing.T) {
|
|
10
|
+
source := "# REST example\n" + backtickFence + "bash\n" +
|
|
11
|
+
"curl https://api.firecrawl.dev/v2/search \\\n" +
|
|
12
|
+
" -H 'Content-Type: application/json' \\\n" +
|
|
13
|
+
" -d '{\n \"query\": \"podcast conversations\",\n \"sources\": [\\\n \"web\",\\\n \"alexandria\"\\\n ],\n \"limit\": 2\n}'\n" +
|
|
14
|
+
backtickFence
|
|
15
|
+
got := copyableCurlExamples(source)
|
|
16
|
+
if !strings.Contains(got, "Copy-ready correction") {
|
|
17
|
+
t.Fatalf("lost truthful correction label: %s", got)
|
|
18
|
+
}
|
|
19
|
+
start := strings.Index(got, "-d '{")
|
|
20
|
+
if start < 0 {
|
|
21
|
+
t.Fatalf("lost source cURL: %s", got)
|
|
22
|
+
}
|
|
23
|
+
bodyStart := start + len("-d '")
|
|
24
|
+
bodyEnd := bodyStart + strings.LastIndex(got[bodyStart:], "}'") + 1
|
|
25
|
+
if !json.Valid([]byte(got[bodyStart:bodyEnd])) {
|
|
26
|
+
t.Fatalf("JSON request still uncopyable: %s", got[bodyStart:bodyEnd])
|
|
27
|
+
}
|
|
28
|
+
if !strings.Contains(got, " -H 'Content-Type: application/json' \\") {
|
|
29
|
+
t.Fatalf("source shell continuations should be preserved: %s", got)
|
|
30
|
+
}
|
|
31
|
+
if strings.Contains(got, "\"sources\": [\\") {
|
|
32
|
+
t.Fatalf("malformed JSON continuation not repaired: %s", got)
|
|
33
|
+
}
|
|
34
|
+
if copyableCurlExamples(got) != got {
|
|
35
|
+
t.Fatalf("correction should be idempotent")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestCorrectSourceExamplesAreNeverRewritten(t *testing.T) {
|
|
40
|
+
source := backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
|
|
41
|
+
if got := copyableCurlExamples(source); got != source {
|
|
42
|
+
t.Fatalf("valid published snippet was modified: %s", got)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestRESTContractsLeadAndExcerptsStayConcise(t *testing.T) {
|
|
47
|
+
if focusedPrimaryBudget <= 5300 {
|
|
48
|
+
t.Fatal("REST excerpt budget should be narrower than normal focused context")
|
|
49
|
+
}
|
|
50
|
+
source := "# Official API docs\n" + strings.Repeat("Large unrelated introduction about navigation.\n", 700) +
|
|
51
|
+
"\n## REST API endpoint example\n" +
|
|
52
|
+
backtickFence + "bash\ncurl https://api.example.com/v2/search -d '{\"sources\":[\"alexandria\"]}'\n" + backtickFence
|
|
53
|
+
got := focusExcerpt(source, "What are the REST API endpoints and exact request examples?", 5300)
|
|
54
|
+
if len(got) > 6000 || !strings.Contains(got, "/v2/search") {
|
|
55
|
+
t.Fatalf("REST excerpt did not prioritize complete example under budget: len=%d", len(got))
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webctx",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.6",
|
|
4
|
+
"description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "amxv",
|
|
7
7
|
"repository": {
|