webctx 0.2.5 → 0.2.7

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.
@@ -0,0 +1,83 @@
1
+ package app
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "os"
8
+ "sync"
9
+ "time"
10
+ )
11
+
12
+ type localPaidReplay struct {
13
+ fingerprint string
14
+ output map[string]any
15
+ expires time.Time
16
+ }
17
+
18
+ var paidReplayMemory = struct {
19
+ sync.Mutex
20
+ items map[string]localPaidReplay
21
+ }{items: map[string]localPaidReplay{}}
22
+
23
+ func localReplayKey(id string) string {
24
+ h := sha256.Sum256([]byte(os.Getenv("FIRECRAWL_API_KEY") + "\x00" + id))
25
+ return hex.EncodeToString(h[:])
26
+ }
27
+ func paidRequestFingerprint(calls []DataCall) string {
28
+ body, _ := json.Marshal(calls)
29
+ h := sha256.Sum256(body)
30
+ return hex.EncodeToString(h[:])
31
+ }
32
+
33
+ // This ledger is only a warm-worker replay optimization. It cannot claim to
34
+ // verify charges or prevent replay across multiple Vercel instances; upstream
35
+ // x-request-id idempotency remains authoritative in that situation.
36
+ func cachedPaidReplay(requestID string, calls []DataCall) (map[string]any, error) {
37
+ key := localReplayKey(requestID)
38
+ fingerprint := paidRequestFingerprint(calls)
39
+ paidReplayMemory.Lock()
40
+ entry, ok := paidReplayMemory.items[key]
41
+ paidReplayMemory.Unlock()
42
+ if !ok || time.Now().After(entry.expires) {
43
+ return nil, nil
44
+ }
45
+ if entry.fingerprint != fingerprint {
46
+ return nil, &DataError{
47
+ Code: "duplicate_request",
48
+ Message: "This request_id already completed with different inputs. Restore the original calls to replay, or omit request_id for a new query.",
49
+ Details: map[string]any{"recovery": "Never retry different inputs with the same ID. Use the original operation and input list for replay, or generate a fresh ID."},
50
+ }
51
+ }
52
+ raw, err := json.Marshal(entry.output)
53
+ if err != nil {
54
+ return nil, nil
55
+ }
56
+ var result map[string]any
57
+ if json.Unmarshal(raw, &result) != nil {
58
+ return nil, nil
59
+ }
60
+ result["replayed"] = true
61
+ result["replay_status"] = "served_from_local_cache"
62
+ result["original_credits_used"] = result["credits_used"]
63
+ result["credits_charged_this_request"] = 0
64
+ result["billing_note"] = "This exact replay was served from Origo's in-process cache without contacting Firecrawl, so Origo incurred no new upstream execution on this attempt."
65
+ return result, nil
66
+ }
67
+
68
+ func storePaidReplay(requestID string, calls []DataCall, output map[string]any) {
69
+ raw, err := json.Marshal(output)
70
+ if err != nil || len(raw) > 1<<20 {
71
+ return
72
+ }
73
+ key := localReplayKey(requestID)
74
+ paidReplayMemory.Lock()
75
+ defer paidReplayMemory.Unlock()
76
+ if len(paidReplayMemory.items) > 192 {
77
+ paidReplayMemory.items = map[string]localPaidReplay{}
78
+ }
79
+ paidReplayMemory.items[key] = localPaidReplay{
80
+ fingerprint: paidRequestFingerprint(calls), output: output,
81
+ expires: time.Now().Add(15 * time.Minute),
82
+ }
83
+ }
@@ -1,6 +1,10 @@
1
1
  package app
2
2
 
3
- import "strings"
3
+ import (
4
+ "net/url"
5
+ "sort"
6
+ "strings"
7
+ )
4
8
 
5
9
  func researchView(view, query string) (string, error) {
6
10
  switch strings.ToLower(strings.TrimSpace(view)) {
@@ -57,7 +61,7 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
57
61
  if err != nil {
58
62
  return input, "", nil, err
59
63
  }
60
- if input.Limit == 0 {
64
+ if input.Limit == 0 && !input.limitProvided {
61
65
  input.Limit = 10
62
66
  }
63
67
  if input.Limit < 1 || input.Limit > 100 {
@@ -71,6 +75,16 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
71
75
  return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
72
76
  }
73
77
  }
78
+ if len(input.URLs) > 0 && len(input.Sources) == 0 {
79
+ for _, target := range input.URLs {
80
+ u, _ := url.Parse(target)
81
+ host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
82
+ if host != "" {
83
+ source := strings.ReplaceAll(host, ".", "-")
84
+ input.Sources = append(input.Sources, source)
85
+ }
86
+ }
87
+ }
74
88
  return input, view, expand, nil
75
89
  }
76
90
 
@@ -79,7 +93,7 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
79
93
  if input.Query != "" {
80
94
  o["query"] = input.Query
81
95
  }
82
- if len(input.URLs) > 0 {
96
+ if len(input.URLs) > 0 && len(input.Sources) == 0 {
83
97
  o["urls"] = input.URLs
84
98
  }
85
99
  if len(input.Sources) > 0 {
@@ -120,6 +134,9 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
120
134
  }
121
135
  }
122
136
  if len(expand) > 0 {
137
+ if containsString(expand, "examples") && !containsString(expand, "options") {
138
+ expand = append(append([]string(nil), expand...), "options")
139
+ }
123
140
  o["expand"] = expand
124
141
  }
125
142
  return o
@@ -142,9 +159,19 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
142
159
  }
143
160
  ctx, cancel := defaultDataContext()
144
161
  defer cancel()
162
+ explicitExact := len(input.Operations) > 0
163
+ // An exact operation ID is authoritative. A descriptive query helps
164
+ // communicate intent, but must not exclude a known operation.
165
+ note := ""
166
+ if explicitExact && input.Query != "" {
167
+ note = "Exact operation IDs take precedence over descriptive query terms; the query was not used as a restrictive upstream filter."
168
+ input.Query = ""
169
+ }
145
170
  semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
146
171
  len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
147
172
  len(input.Groups) == 0 && len(input.Operations) == 0
173
+ localFilteredRanking := view == "tools" && input.Query != "" &&
174
+ (len(input.URLs) > 0 || len(input.Sources) > 0 || len(input.Groups) > 0 || len(input.Categories) > 0)
148
175
  var items []any
149
176
  var total any
150
177
  var next any
@@ -156,15 +183,25 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
156
183
  // compact upstream matches may omit both price and operation name.
157
184
  // We still return compact result cards unless include was requested.
158
185
  detail := "full"
186
+ fetchLimit := input.Limit * 4
187
+ if fetchLimit < 20 {
188
+ fetchLimit = 20
189
+ }
190
+ if fetchLimit > 100 {
191
+ fetchLimit = 100
192
+ }
159
193
  raw, err = doDataAPI(ctx, "/search", map[string]any{
160
194
  "query": input.Query, "sources": []string{"alexandria"},
161
- "limit": input.Limit, "toolDetail": detail,
195
+ "limit": fetchLimit, "toolDetail": detail,
162
196
  }, "")
163
197
  if err != nil {
164
198
  return nil, err
165
199
  }
166
200
  data := mapField(raw["data"])
167
201
  items = listField(data["tools"])
202
+ // Some upstream ranked matches arrive out of order despite including a
203
+ // similarity score. Present the highest-scoring candidate first, retaining
204
+ // upstream order for exact ties or missing scores.
168
205
  // Ranked search has no native cursor. Expose the free, complete
169
206
  // catalogue with matching semantic query as an optional expansion.
170
207
  next = map[string]any{"tool": "research", "arguments": map[string]any{
@@ -172,6 +209,22 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
172
209
  }, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
173
210
  } else {
174
211
  options := nativeBrowseOptions(input, view, expand)
212
+ if localFilteredRanking {
213
+ // Provider-specified lexical filters can exclude operations whose
214
+ // descriptions describe the requested data indirectly. Fetch a
215
+ // catalogue slice from the actual source, then rank it ourselves.
216
+ delete(options, "query")
217
+ options["limit"] = 100
218
+ options["offset"] = 0
219
+ note = "The source/category filters select candidates first. The question ranks those candidates locally, rather than excluding matching source operations."
220
+ if input.Mode != "catalogue" {
221
+ mode = "ranked_filtered"
222
+ }
223
+ }
224
+ if explicitExact {
225
+ delete(options, "query")
226
+ delete(options, "urls")
227
+ }
175
228
  var catalogue map[string]any
176
229
  catalogue, raw, err = freeDataBrowse(ctx, options)
177
230
  if err != nil {
@@ -183,6 +236,48 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
183
236
  next = continuationFromCatalogue(native)
184
237
  }
185
238
  }
239
+ if view == "tools" {
240
+ termsQuery := input.Query
241
+ for _, op := range input.Operations {
242
+ termsQuery += " " + op
243
+ }
244
+ if strings.TrimSpace(termsQuery) != "" {
245
+ sort.SliceStable(items, func(i, j int) bool {
246
+ return intentRelevance(mapField(items[i]), termsQuery) > intentRelevance(mapField(items[j]), termsQuery)
247
+ })
248
+ } else {
249
+ sort.SliceStable(items, func(i, j int) bool {
250
+ a, aok := numberField(mapField(items[i])["similarity"])
251
+ b, bok := numberField(mapField(items[j])["similarity"])
252
+ if aok != bok {
253
+ return aok
254
+ }
255
+ return a > b
256
+ })
257
+ }
258
+ }
259
+ if semantic && len(items) > input.Limit {
260
+ items = items[:input.Limit]
261
+ }
262
+ if localFilteredRanking {
263
+ count := len(items)
264
+ start := input.Offset
265
+ if start > count {
266
+ start = count
267
+ }
268
+ end := start + input.Limit
269
+ if end > count {
270
+ end = count
271
+ }
272
+ items = items[start:end]
273
+ if count > end {
274
+ next = map[string]any{"tool": "research", "arguments": ResearchDataInput{
275
+ Query: input.Query, Mode: input.Mode, URLs: input.URLs, Sources: input.Sources,
276
+ Categories: input.Categories, Groups: input.Groups, View: niceDataView(view),
277
+ Include: input.Include, Limit: input.Limit, Offset: end,
278
+ }}
279
+ }
280
+ }
186
281
  results := make([]map[string]any, 0, len(items))
187
282
  for _, value := range items {
188
283
  entry := mapField(value)
@@ -206,6 +301,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
206
301
  }
207
302
  if cost, ok := entry["creditsCost"]; ok {
208
303
  result["credits_per_call"] = cost
304
+ if listed, ok := numberField(cost); ok {
305
+ if listed > float64(dataCreditCap()) {
306
+ result["executable"] = false
307
+ result["availability"] = "exceeds_server_credit_limit"
308
+ result["server_credit_limit"] = dataCreditCap()
309
+ } else if view == "tools" {
310
+ result["executable"] = true
311
+ }
312
+ }
209
313
  }
210
314
  if cost, ok := entry["perRecord"]; ok {
211
315
  result["per_record"] = cost
@@ -213,8 +317,14 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
213
317
  if count, ok := entry["toolCount"]; ok {
214
318
  result["operation_count"] = count
215
319
  }
320
+ if input.Query != "" && view == "tools" {
321
+ result["relevance"] = intentRelevance(entry, input.Query)
322
+ }
216
323
  if rank, ok := entry["similarity"]; ok {
217
- result["relevance"] = rank
324
+ result["semantic_similarity"] = rank
325
+ if input.Query == "" {
326
+ result["relevance"] = rank
327
+ }
218
328
  }
219
329
  if len(expand) > 0 {
220
330
  if selected := entry["options"]; selected != nil && containsString(expand, "options") {
@@ -226,8 +336,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
226
336
  if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
227
337
  result["examples"] = selected
228
338
  }
339
+ if containsString(expand, "examples") && result["examples"] == nil {
340
+ if entry["options"] != nil {
341
+ result["examples"] = contractExamples(entry)
342
+ }
343
+ }
229
344
  }
230
- if continuation := mapField(entry["next"]); continuation != nil {
345
+ if view == "tools" && id != "" {
346
+ result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
347
+ } else if continuation := mapField(entry["next"]); continuation != nil {
231
348
  result["next"] = continuationFromCatalogue(continuation)
232
349
  } else if id != "" {
233
350
  result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
@@ -239,6 +356,12 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
239
356
  "count": len(results), "cost_credits": 0,
240
357
  "guidance": "Use inspect with an operation ID to see complete inputs, output, examples and the next execute call. To explore a source, inspect its source ID.",
241
358
  }
359
+ if note != "" {
360
+ output["mode_note"] = note
361
+ }
362
+ if len(input.URLs) > 0 && len(input.Sources) > 0 {
363
+ output["resolved_url_sources"] = input.Sources
364
+ }
242
365
  if total != nil {
243
366
  output["total"] = total
244
367
  }
@@ -251,6 +374,55 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
251
374
  return output, nil
252
375
  }
253
376
 
377
+ // Rank operations by their ability to deliver the requested data, not just
378
+ // semantic similarity. Keep the provider similarity separately for auditing.
379
+ func intentRelevance(entry map[string]any, query string) float64 {
380
+ if entry == nil {
381
+ return 0
382
+ }
383
+ base, _ := numberField(entry["similarity"])
384
+ if query == "" {
385
+ return base
386
+ }
387
+ q := strings.ToLower(query)
388
+ id := strings.ToLower(stringField(entry["id"]))
389
+ name := strings.ToLower(stringField(entry["name"]))
390
+ desc := strings.ToLower(stringField(entry["description"]))
391
+ p := id + " " + name
392
+ searchIntent := strings.Contains(q, "search") || strings.Contains(q, "find") ||
393
+ strings.Contains(q, "papers") || strings.Contains(q, "podcast") ||
394
+ strings.Contains(q, "trials") || strings.Contains(q, "about")
395
+ historyIntent := strings.Contains(q, "historical") || strings.Contains(q, "history") ||
396
+ strings.Contains(q, "time series") || strings.Contains(q, "over time") ||
397
+ strings.Contains(q, "inflation")
398
+ if historyIntent && (strings.Contains(p, "observations") ||
399
+ strings.Contains(p, "history") || strings.Contains(p, "rates") ||
400
+ strings.Contains(p, "timeseries")) {
401
+ base += 0.23
402
+ }
403
+ if searchIntent && (strings.Contains(p, "/search") || strings.Contains(p, "/studies") ||
404
+ strings.Contains(p, "episode-search")) {
405
+ base += 0.20
406
+ }
407
+ if searchIntent && strings.Contains(p, "/list") && !strings.Contains(q, "list") {
408
+ base -= 0.04
409
+ }
410
+ for _, ancillary := range []string{"suggest", "related", "directory", "releases", "/source", "metadata", "browse"} {
411
+ if strings.Contains(p, ancillary) && !strings.Contains(q, strings.TrimLeft(ancillary, "/")) {
412
+ base -= 0.14
413
+ }
414
+ }
415
+ terms := queryTerms(query)
416
+ if len(terms) > 0 {
417
+ fit := termScore(name+" "+desc, terms)
418
+ if fit > 8 {
419
+ fit = 8
420
+ }
421
+ base += float64(fit) * 0.007
422
+ }
423
+ return float64(int(base*1000000+0.5)) / 1000000
424
+ }
425
+
254
426
  func niceDataView(view string) string {
255
427
  switch view {
256
428
  case "providers":
@@ -59,6 +59,18 @@ func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error)
59
59
  return testHTTPResponse(req, status, string(b), nil), nil
60
60
  }
61
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
+ }
62
74
  return write(200, map[string]any{"success": true, "data": map[string]any{
63
75
  "tools": []any{dataTestContract()},
64
76
  }})
@@ -115,6 +127,20 @@ func (d *dataTestTransport) RoundTrip(req *http.Request) (*http.Response, error)
115
127
  return write(400, map[string]any{"success": false, "error": "Bad test request."})
116
128
  }
117
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
+
118
144
  func installDataTestTransport(t *testing.T) *dataTestTransport {
119
145
  t.Helper()
120
146
  old := http.DefaultClient
@@ -122,7 +148,6 @@ func installDataTestTransport(t *testing.T) *dataTestTransport {
122
148
  d := &dataTestTransport{}
123
149
  http.DefaultClient = &http.Client{Transport: d}
124
150
  t.Setenv("FIRECRAWL_API_KEY", "test-key")
125
- t.Setenv("WEBCTX_ALEXANDRIA_PAID", "true")
126
151
  t.Setenv("WEBCTX_ALEXANDRIA_MAX_CREDITS", "100")
127
152
  return d
128
153
  }
@@ -155,6 +180,10 @@ func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
155
180
  if len(listField(mapField(details["inputs"])["fields"])) != 4 {
156
181
  t.Fatalf("missing upstream contract fields: %#v", details["inputs"])
157
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
+ }
158
187
  continued := mapField(details["next"])
159
188
  if continued["tool"] != "execute" {
160
189
  t.Fatalf("no next execution: %#v", continued)
@@ -169,8 +198,7 @@ func TestProgressiveDataDiscoveryAndExecution(t *testing.T) {
169
198
  t.Fatalf("invalid template: %#v", skeleton)
170
199
  }
171
200
  got, err := ExecuteData(ExecuteDataInput{
172
- Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
173
- MaxCredits: 20,
201
+ Calls: []DataCall{{ID: id, Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)}}},
174
202
  })
175
203
  if err != nil {
176
204
  t.Fatal(err)
@@ -222,20 +250,18 @@ func TestCatalogueBrowsingReturnsExactPaginationCall(t *testing.T) {
222
250
  }
223
251
  }
224
252
 
225
- func TestExecutionValidationAndBudgetRejectBeforePaidCall(t *testing.T) {
253
+ func TestExecutionValidationAndServerLimitRejectBeforePaidCall(t *testing.T) {
226
254
  mock := installDataTestTransport(t)
227
255
  id := "particle/podcasts/episodes/search"
228
256
  for _, testCase := range []struct {
229
- inputs map[string]any
230
- credits int
231
- code string
257
+ inputs map[string]any
258
+ code string
232
259
  }{
233
- {map[string]any{}, 20, "invalid_inputs"},
234
- {map[string]any{"semantic_search": "AI agents", "limit": 101}, 20, "invalid_inputs"},
235
- {map[string]any{"semantic_search": "AI agents", "unknown_field": 123}, 20, "invalid_inputs"},
236
- {map[string]any{"semantic_search": "AI agents", "limit": 5}, 5, "credit_budget_exceeded"},
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"},
237
263
  } {
238
- _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}, MaxCredits: testCase.credits})
264
+ _, err := ExecuteData(ExecuteDataInput{Calls: []DataCall{{ID: id, Inputs: testCase.inputs}}})
239
265
  typed, ok := err.(*DataError)
240
266
  if !ok || typed.Code != testCase.code {
241
267
  t.Fatalf("wanted %s, got %v", testCase.code, err)
@@ -260,8 +286,8 @@ func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
260
286
  mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
261
287
  t.Fatalf("terms condition lost: %+v", err)
262
288
  }
263
- if mapField(typed.Details)["request_id"] == nil {
264
- t.Fatalf("lost retriable request identifier: %+v", typed)
289
+ if mapField(typed.Details)["retry"] != nil {
290
+ t.Fatalf("terms rejection must not recommend automatic paid retry: %+v", typed)
265
291
  }
266
292
  }
267
293
 
@@ -279,7 +305,7 @@ func TestCLIResearchAndInspectJSON(t *testing.T) {
279
305
  if code := Run([]string{"inspect", "particle/podcasts/episodes/search"}, &out, &errBuf); code != 0 {
280
306
  t.Fatalf("CLI inspect failed: %s", errBuf.String())
281
307
  }
282
- if !strings.Contains(out.String(), "semantic_search") || !strings.Contains(out.String(), "max_credits") {
308
+ if !strings.Contains(out.String(), "semantic_search") || strings.Contains(out.String(), "max_credits") {
283
309
  t.Fatalf("CLI inspect omitted exact execute syntax: %s", out.String())
284
310
  }
285
311
  }
@@ -287,7 +313,7 @@ func TestCLIResearchAndInspectJSON(t *testing.T) {
287
313
  func TestProviderNextOffsetCreatesCopyReadyContinuation(t *testing.T) {
288
314
  id := "fred-stlouisfed-org/economic-data/series_observations"
289
315
  inputs := map[string]any{"series_id": "CPIAUCSL", "limit": 2.0, "offset": 0.0}
290
- next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0}, 15)
316
+ next := continuationForDataResult(id, inputs, map[string]any{"next_offset": 2.0, "count": 956.0})
291
317
  if mapField(next)["tool"] != "execute" {
292
318
  t.Fatalf("expected executable pagination: %+v", next)
293
319
  }
@@ -334,3 +360,85 @@ func TestInspectOnlyOutputStillHasExecuteInputTemplate(t *testing.T) {
334
360
  t.Fatalf("missing necessary next-call schema after include=output: %+v", details)
335
361
  }
336
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
+ }
@@ -1,20 +1,43 @@
1
1
  package app
2
2
 
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ )
7
+
3
8
  // ResearchData discovers data sources and their available operations. Search
4
9
  // and catalogue browsing are both free Firecrawl Alexandria operations.
5
10
  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"`
11
+ Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
12
+ Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
13
+ URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
14
+ Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
15
+ Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
16
+ Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
17
+ Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
18
+ View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
19
+ Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
20
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
21
+ Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
22
+ Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
23
+ limitProvided bool `json:"-"`
24
+ }
25
+
26
+ func (input *ResearchDataInput) UnmarshalJSON(raw []byte) error {
27
+ type noMethods ResearchDataInput
28
+ var parsed noMethods
29
+ decoder := json.NewDecoder(bytes.NewReader(raw))
30
+ decoder.DisallowUnknownFields()
31
+ if err := decoder.Decode(&parsed); err != nil {
32
+ return err
33
+ }
34
+ var fields map[string]json.RawMessage
35
+ if err := json.Unmarshal(raw, &fields); err != nil {
36
+ return err
37
+ }
38
+ *input = ResearchDataInput(parsed)
39
+ _, input.limitProvided = fields["limit"]
40
+ return nil
18
41
  }
19
42
 
20
43
  // InspectDataInput returns the exact contract for a selected operation, or
@@ -33,8 +56,7 @@ type DataCall struct {
33
56
  // ExecuteDataInput supports up to ten calls in a single provider request.
34
57
  // It validates each call against the free catalogue before using credits.
35
58
  type ExecuteDataInput struct {
36
- Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
37
- MaxCredits int `json:"max_credits,omitempty" jsonschema:"Maximum predicted credits for this request; defaults to WEBCTX_ALEXANDRIA_MAX_CREDITS or 100"`
38
- RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
39
- Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
59
+ Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
60
+ RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
61
+ Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
40
62
  }