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.
- package/README.md +4 -4
- package/internal/app/app.go +34 -11
- package/internal/app/data_audit_test.go +433 -0
- package/internal/app/data_client.go +196 -14
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +185 -0
- package/internal/app/data_execute.go +231 -79
- package/internal/app/data_inspect.go +21 -40
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +83 -0
- package/internal/app/data_research.go +178 -6
- package/internal/app/data_tools_test.go +124 -16
- package/internal/app/data_types.go +38 -16
- package/internal/app/research.go +24 -5
- package/internal/app/research_answers.go +30 -0
- package/internal/app/research_copyable.go +48 -0
- package/internal/app/research_copyable_test.go +57 -0
- package/internal/app/site_map_page.go +256 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +31 -5
- package/pkg/origo/server.go +106 -28
- package/pkg/origo/server_test.go +56 -4
- package/pkg/retrieval/data.go +3 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
4
7
|
"encoding/json"
|
|
5
8
|
"fmt"
|
|
6
9
|
"math"
|
|
@@ -70,6 +73,26 @@ func validateDataInputs(contract map[string]any, inputs map[string]any) error {
|
|
|
70
73
|
issues = append(issues, fmt.Sprintf("field %q must be %s", name, stringField(field["type"])))
|
|
71
74
|
continue
|
|
72
75
|
}
|
|
76
|
+
if integerDataInputs[name] {
|
|
77
|
+
if n, ok := numberField(value); ok && n != math.Trunc(n) {
|
|
78
|
+
issues = append(issues, fmt.Sprintf("field %q requires an integer value", name))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for _, key := range []string{"requires", "dependsOn"} {
|
|
82
|
+
if deps := listField(field[key]); len(deps) > 0 {
|
|
83
|
+
for _, dep := range deps {
|
|
84
|
+
dependent := stringField(dep)
|
|
85
|
+
if dependent != "" && inputs[dependent] == nil {
|
|
86
|
+
issues = append(issues, fmt.Sprintf("field %q requires accompanying field %q", name, dependent))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for _, other := range listField(field["mutuallyExclusiveWith"]) {
|
|
92
|
+
if alternate := stringField(other); alternate != "" && inputs[alternate] != nil {
|
|
93
|
+
issues = append(issues, fmt.Sprintf("fields %q and %q cannot be combined", name, alternate))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
73
96
|
if enum := listField(field["oneOf"]); len(enum) > 0 {
|
|
74
97
|
found := false
|
|
75
98
|
for _, allowed := range enum {
|
|
@@ -185,16 +208,22 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
185
208
|
if perRecord, _ := contract["perRecord"].(bool); perRecord {
|
|
186
209
|
// A per-record price has no hard upper bound without a record limit.
|
|
187
210
|
count := 0.0
|
|
188
|
-
for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
|
|
211
|
+
for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
|
|
189
212
|
if n, ok := numberField(inputs[key]); ok && n >= 1 {
|
|
190
213
|
count = n
|
|
191
214
|
break
|
|
192
215
|
}
|
|
193
216
|
}
|
|
217
|
+
// For bulk identifier lookups, a response may be charged per input.
|
|
218
|
+
for _, key := range []string{"ids", "domains", "urls", "companies", "people", "symbols"} {
|
|
219
|
+
if records := listField(inputs[key]); count == 0 && len(records) > 0 {
|
|
220
|
+
count = float64(len(records))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
194
223
|
if count == 0 {
|
|
195
224
|
for _, v := range listField(contract["options"]) {
|
|
196
225
|
f := mapField(v)
|
|
197
|
-
for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
|
|
226
|
+
for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
|
|
198
227
|
if stringField(f["name"]) == key {
|
|
199
228
|
if n, ok := numberField(f["default"]); ok && n >= 1 {
|
|
200
229
|
count = n
|
|
@@ -203,9 +232,30 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
203
232
|
}
|
|
204
233
|
}
|
|
205
234
|
}
|
|
235
|
+
if count == 0 {
|
|
236
|
+
// An exact, non-paginated lookup with an individual identifier has
|
|
237
|
+
// a single-record upper bound, even without a limit parameter.
|
|
238
|
+
// Only apply this to known singleton operations, never searches.
|
|
239
|
+
id := strings.ToLower(stringField(contract["id"]))
|
|
240
|
+
isLookup := strings.HasSuffix(id, "/lookup") || strings.HasSuffix(id, "/get") ||
|
|
241
|
+
strings.HasSuffix(id, "/detail") || strings.HasSuffix(id, "/details") ||
|
|
242
|
+
strings.HasSuffix(id, "/resolve") || strings.HasSuffix(id, "/profile")
|
|
243
|
+
hasIdentifier := false
|
|
244
|
+
for _, key := range []string{"domain", "id", "company_id", "person_id", "symbol", "url", "linkedin_url", "repo", "doi"} {
|
|
245
|
+
if s := stringField(inputs[key]); s != "" {
|
|
246
|
+
hasIdentifier = true
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if isLookup && hasIdentifier {
|
|
251
|
+
if paginated, ok := mapField(contract["response"])["paginated"].(bool); !ok || !paginated {
|
|
252
|
+
count = 1
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
206
256
|
if count == 0 {
|
|
207
257
|
return 0, dataError("unbounded_per_record_cost",
|
|
208
|
-
"This operation charges per record but
|
|
258
|
+
"This operation charges per record, but the contract does not establish a finite record bound. Inspect its output and supply a supported count parameter or smaller input set. Origo cannot safely invent a limit field.")
|
|
209
259
|
}
|
|
210
260
|
estimated = cost * count
|
|
211
261
|
}
|
|
@@ -215,17 +265,19 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
215
265
|
return int(math.Ceil(estimated)), nil
|
|
216
266
|
}
|
|
217
267
|
|
|
218
|
-
func continuationForDataResult(id string, originalInputs map[string]any, data any
|
|
268
|
+
func continuationForDataResult(id string, originalInputs map[string]any, data any) map[string]any {
|
|
219
269
|
value := mapField(data)
|
|
220
270
|
if value == nil {
|
|
221
271
|
return nil
|
|
222
272
|
}
|
|
273
|
+
if next := providerContinuation(id, originalInputs, data); next != nil {
|
|
274
|
+
return next
|
|
275
|
+
}
|
|
223
276
|
if next := mapField(value["next"]); next != nil {
|
|
224
277
|
if provider := stringField(next["provider"]); provider != "" {
|
|
225
278
|
if capability := stringField(next["capability"]); capability != "" {
|
|
226
279
|
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
227
|
-
"calls":
|
|
228
|
-
"max_credits": maxCredits,
|
|
280
|
+
"calls": []any{map[string]any{"id": provider + "/" + capability, "inputs": mapField(next["options"])}},
|
|
229
281
|
}}
|
|
230
282
|
}
|
|
231
283
|
}
|
|
@@ -245,16 +297,14 @@ func continuationForDataResult(id string, originalInputs map[string]any, data an
|
|
|
245
297
|
next["cursor"] = cursor
|
|
246
298
|
}
|
|
247
299
|
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
248
|
-
"calls":
|
|
249
|
-
"max_credits": maxCredits,
|
|
300
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
250
301
|
}}
|
|
251
302
|
}
|
|
252
303
|
if page, ok := numberField(value["next_page"]); ok && page > 0 {
|
|
253
304
|
next := copyInputs(originalInputs)
|
|
254
305
|
next["page"] = int(page)
|
|
255
306
|
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
256
|
-
"calls":
|
|
257
|
-
"max_credits": maxCredits,
|
|
307
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
258
308
|
}}
|
|
259
309
|
}
|
|
260
310
|
if offset, ok := numberField(value["next_offset"]); ok && offset >= 0 {
|
|
@@ -263,8 +313,7 @@ func continuationForDataResult(id string, originalInputs map[string]any, data an
|
|
|
263
313
|
if offset > previous {
|
|
264
314
|
next["offset"] = int(offset)
|
|
265
315
|
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
266
|
-
"calls":
|
|
267
|
-
"max_credits": maxCredits,
|
|
316
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
268
317
|
}}
|
|
269
318
|
}
|
|
270
319
|
}
|
|
@@ -279,22 +328,101 @@ func copyInputs(original map[string]any) map[string]any {
|
|
|
279
328
|
return out
|
|
280
329
|
}
|
|
281
330
|
|
|
331
|
+
// A multi-operation request uses a distinct, deterministic provider request ID
|
|
332
|
+
// for every operation. The upstream batch endpoint is not transactionally
|
|
333
|
+
// isolated: one provider's terms rejection can abort unrelated operations.
|
|
334
|
+
func operationRequestID(root string, index int, id string, count int) string {
|
|
335
|
+
if count == 1 {
|
|
336
|
+
return root
|
|
337
|
+
}
|
|
338
|
+
digest := sha256.Sum256([]byte(id))
|
|
339
|
+
if len(root) > 92 {
|
|
340
|
+
root = root[:92]
|
|
341
|
+
}
|
|
342
|
+
return fmt.Sprintf("%s-%d-%s", root, index, hex.EncodeToString(digest[:6]))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func executeOneDataOperation(ctx context.Context, call DataCall, requestID string, index int, includeRaw bool) (map[string]any, float64, error) {
|
|
346
|
+
provider, capability, err := parseOperationID(call.ID)
|
|
347
|
+
if err != nil {
|
|
348
|
+
return nil, 0, err
|
|
349
|
+
}
|
|
350
|
+
options := call.Inputs
|
|
351
|
+
if options == nil {
|
|
352
|
+
options = map[string]any{}
|
|
353
|
+
}
|
|
354
|
+
raw, err := doDataAPI(ctx, "/scrape", map[string]any{"alexandria": []any{
|
|
355
|
+
map[string]any{"provider": provider, "capability": capability, "options": options},
|
|
356
|
+
}}, requestID)
|
|
357
|
+
if err != nil {
|
|
358
|
+
return nil, 0, err
|
|
359
|
+
}
|
|
360
|
+
payload := mapField(raw["data"])
|
|
361
|
+
results := listField(payload["alexandria"])
|
|
362
|
+
if len(results) != 1 {
|
|
363
|
+
return nil, 0, dataError("invalid_upstream_response", "The provider did not return exactly one result for the requested operation.")
|
|
364
|
+
}
|
|
365
|
+
entry := mapField(results[0])
|
|
366
|
+
if entry == nil {
|
|
367
|
+
return nil, 0, dataError("invalid_upstream_response", "The provider returned an unreadable result.")
|
|
368
|
+
}
|
|
369
|
+
if providerError := entry["error"]; providerError != nil {
|
|
370
|
+
return nil, 0, &DataError{Code: "provider_operation_failed", Message: fmt.Sprint(providerError)}
|
|
371
|
+
}
|
|
372
|
+
credits, _ := numberField(entry["creditsCost"])
|
|
373
|
+
if total, ok := numberField(payload["creditsCost"]); ok {
|
|
374
|
+
credits = total
|
|
375
|
+
}
|
|
376
|
+
item := map[string]any{
|
|
377
|
+
"operation_index": index, "id": call.ID, "source_id": provider,
|
|
378
|
+
"ok": true, "data": entry["data"],
|
|
379
|
+
"credits_used": credits, "receipt_scope": "upstream_request",
|
|
380
|
+
"provider_request_id": requestID,
|
|
381
|
+
"provider_credit_note": "Provider-native fields named credits may use different units. credits_used is Firecrawl Alexandria billing.",
|
|
382
|
+
}
|
|
383
|
+
if token := entry["alexandriaId"]; token != nil {
|
|
384
|
+
item["data_id"] = token
|
|
385
|
+
item["receipt_scope"] = "provider_response_or_batch"
|
|
386
|
+
}
|
|
387
|
+
if note := dataMeasurementNote(entry["data"]); note != "" {
|
|
388
|
+
item["measurement_note"] = note
|
|
389
|
+
}
|
|
390
|
+
if next := continuationForDataResult(call.ID, call.Inputs, entry["data"]); next != nil {
|
|
391
|
+
item["next"] = next
|
|
392
|
+
}
|
|
393
|
+
if extra := raw["scrape_id"]; extra != nil {
|
|
394
|
+
item["scrape_id"] = extra
|
|
395
|
+
}
|
|
396
|
+
if includeRaw {
|
|
397
|
+
item["raw"] = raw
|
|
398
|
+
}
|
|
399
|
+
return item, credits, nil
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func operationErrorResult(call DataCall, index int, requestID string, err error) map[string]any {
|
|
403
|
+
typed, ok := err.(*DataError)
|
|
404
|
+
if !ok {
|
|
405
|
+
typed = &DataError{Code: "execution_failed", Message: err.Error()}
|
|
406
|
+
}
|
|
407
|
+
out := map[string]any{
|
|
408
|
+
"id": call.ID, "operation_index": index, "ok": false,
|
|
409
|
+
"provider_request_id": requestID, "error": typed,
|
|
410
|
+
"credits_used": nil, "billing_status": "unknown_if_provider_accepted_request",
|
|
411
|
+
}
|
|
412
|
+
if typed.Code == "THIRD_PARTY_DATA_TERMS_REQUIRED" {
|
|
413
|
+
out["billing_status"] = "blocked_pending_human_terms"
|
|
414
|
+
out["credits_used"] = 0
|
|
415
|
+
}
|
|
416
|
+
return out
|
|
417
|
+
}
|
|
418
|
+
|
|
282
419
|
// ExecuteData validates contracts and credit estimates before calling any
|
|
283
420
|
// paid provider. The native API accepts at most ten executions per request.
|
|
284
421
|
func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
285
422
|
if len(input.Calls) == 0 || len(input.Calls) > 10 {
|
|
286
423
|
return nil, dataError("invalid_calls", "Provide 1-10 operation calls with id and inputs.")
|
|
287
424
|
}
|
|
288
|
-
if !paidDataEnabled() {
|
|
289
|
-
return nil, dataError("paid_data_disabled", "Paid data queries are disabled by WEBCTX_ALEXANDRIA_PAID=off.")
|
|
290
|
-
}
|
|
291
425
|
cap := dataCreditCap()
|
|
292
|
-
if input.MaxCredits > 0 && input.MaxCredits < cap {
|
|
293
|
-
cap = input.MaxCredits
|
|
294
|
-
}
|
|
295
|
-
if input.MaxCredits < 0 || input.MaxCredits > dataCreditCap() {
|
|
296
|
-
return nil, dataError("invalid_budget", fmt.Sprintf("max_credits must be between 1 and the configured maximum of %d.", dataCreditCap()))
|
|
297
|
-
}
|
|
298
426
|
if input.RequestID != "" && !safeRequestID.MatchString(input.RequestID) {
|
|
299
427
|
return nil, dataError("invalid_request_id", "request_id must have 8-128 letters, digits, hyphens or underscores.")
|
|
300
428
|
}
|
|
@@ -319,7 +447,6 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
|
319
447
|
}
|
|
320
448
|
wg.Wait()
|
|
321
449
|
var estimated int
|
|
322
|
-
var calls []map[string]any
|
|
323
450
|
for i, call := range input.Calls {
|
|
324
451
|
if checked[i].Err != nil {
|
|
325
452
|
return nil, checked[i].Err
|
|
@@ -334,17 +461,11 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
|
334
461
|
}
|
|
335
462
|
estimated += cost
|
|
336
463
|
if estimated > cap {
|
|
337
|
-
return nil, &DataError{Code: "
|
|
338
|
-
Message: fmt.Sprintf("
|
|
339
|
-
Details: map[string]any{"estimated_credits": estimated, "
|
|
464
|
+
return nil, &DataError{Code: "server_credit_limit_exceeded",
|
|
465
|
+
Message: fmt.Sprintf("This request is estimated at %d credits, above Origo's %d-credit server limit. Reduce the number of operations or requested records.", estimated, cap),
|
|
466
|
+
Details: map[string]any{"estimated_credits": estimated, "server_credit_limit": cap},
|
|
340
467
|
}
|
|
341
468
|
}
|
|
342
|
-
provider, capability, _ := parseOperationID(call.ID)
|
|
343
|
-
options := call.Inputs
|
|
344
|
-
if options == nil {
|
|
345
|
-
options = map[string]any{}
|
|
346
|
-
}
|
|
347
|
-
calls = append(calls, map[string]any{"provider": provider, "capability": capability, "options": options})
|
|
348
469
|
}
|
|
349
470
|
requestID := input.RequestID
|
|
350
471
|
if requestID == "" {
|
|
@@ -354,63 +475,73 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
|
354
475
|
return nil, dataError("internal_error", "Could not generate a request identifier.")
|
|
355
476
|
}
|
|
356
477
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if
|
|
361
|
-
|
|
362
|
-
"retry": map[string]any{"tool": "execute", "arguments": ExecuteDataInput{
|
|
363
|
-
Calls: input.Calls, MaxCredits: cap, RequestID: requestID, Raw: input.Raw,
|
|
364
|
-
}},
|
|
365
|
-
}
|
|
478
|
+
if input.RequestID != "" {
|
|
479
|
+
if cached, err := cachedPaidReplay(requestID, input.Calls); err != nil {
|
|
480
|
+
return nil, err
|
|
481
|
+
} else if cached != nil {
|
|
482
|
+
return cached, nil
|
|
366
483
|
}
|
|
367
|
-
return nil, err
|
|
368
484
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
item["error"] = errValue
|
|
392
|
-
}
|
|
393
|
-
if i < len(input.Calls) {
|
|
394
|
-
if next := continuationForDataResult(id, input.Calls[i].Inputs, entry["data"], cap); next != nil {
|
|
395
|
-
item["next"] = next
|
|
485
|
+
results := make([]map[string]any, 0, len(input.Calls))
|
|
486
|
+
creditsUsed := 0.0
|
|
487
|
+
failed := 0
|
|
488
|
+
var rawResponses []any
|
|
489
|
+
for i, call := range input.Calls {
|
|
490
|
+
operationID := operationRequestID(requestID, i, call.ID, len(input.Calls))
|
|
491
|
+
item, charged, err := executeOneDataOperation(ctx, call, operationID, i, input.Raw)
|
|
492
|
+
if err != nil {
|
|
493
|
+
// Treat idempotency collisions and contract/terms failures as
|
|
494
|
+
// independent operation errors. Do not suggest replaying a
|
|
495
|
+
// conflicting payload with the same id.
|
|
496
|
+
var typed *DataError
|
|
497
|
+
if errorsAsData(err, &typed) {
|
|
498
|
+
if typed.Code != "duplicate_request" && typed.Code != "THIRD_PARTY_DATA_TERMS_REQUIRED" {
|
|
499
|
+
typed.Details = map[string]any{
|
|
500
|
+
"request_id": operationID,
|
|
501
|
+
"recovery": "For an uncertain network failure, retry the exact same operation and request_id. For a different query or page, omit request_id.",
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if len(input.Calls) == 1 {
|
|
506
|
+
return nil, err
|
|
396
507
|
}
|
|
508
|
+
failed++
|
|
509
|
+
results = append(results, operationErrorResult(call, i, operationID, err))
|
|
510
|
+
continue
|
|
397
511
|
}
|
|
512
|
+
creditsUsed += charged
|
|
398
513
|
results = append(results, item)
|
|
514
|
+
if input.Raw {
|
|
515
|
+
rawResponses = append(rawResponses, map[string]any{"operation_index": i, "provider_request_id": operationID, "result": item})
|
|
516
|
+
}
|
|
399
517
|
}
|
|
400
518
|
output := map[string]any{
|
|
401
|
-
"results":
|
|
402
|
-
"request_id":
|
|
403
|
-
"estimated_credits":
|
|
404
|
-
"
|
|
405
|
-
"
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
519
|
+
"results": results, "partial": failed > 0, "failed_operations": failed,
|
|
520
|
+
"request_id": requestID,
|
|
521
|
+
"estimated_credits": estimated,
|
|
522
|
+
"credits_used": creditsUsed,
|
|
523
|
+
"pricing_note": "credits_used totals reported Firecrawl charges from successful operations. Failed operations may have indeterminate billing; provider-native credits use their own units.",
|
|
524
|
+
"guidance": "Use each result.next to fetch its next page. Retry an identical operation only with its provider_request_id. A new query/page must use a new request ID.",
|
|
525
|
+
"replay_status": "upstream_replay_unverified",
|
|
526
|
+
"replayed": false,
|
|
527
|
+
"credits_charged_this_request": nil,
|
|
528
|
+
"original_credits_used": nil,
|
|
529
|
+
"billing_note": "Firecrawl idempotent replay may return the original credit amount; without a provider replay flag or durable accounting data, Origo cannot assert that a retry incurred a new charge.",
|
|
530
|
+
}
|
|
531
|
+
if failed > 0 {
|
|
532
|
+
output["status"] = "partial_success"
|
|
533
|
+
}
|
|
534
|
+
if failed == len(input.Calls) {
|
|
535
|
+
output["status"] = "all_failed"
|
|
536
|
+
}
|
|
537
|
+
if failed == 0 {
|
|
538
|
+
output["status"] = "success"
|
|
411
539
|
}
|
|
412
540
|
if input.Raw {
|
|
413
|
-
output["raw"] =
|
|
541
|
+
output["raw"] = rawResponses
|
|
542
|
+
}
|
|
543
|
+
if failed == 0 {
|
|
544
|
+
storePaidReplay(requestID, input.Calls, output)
|
|
414
545
|
}
|
|
415
546
|
return output, nil
|
|
416
547
|
}
|
|
@@ -422,3 +553,24 @@ func errorsAsData(err error, into **DataError) bool {
|
|
|
422
553
|
}
|
|
423
554
|
return ok
|
|
424
555
|
}
|
|
556
|
+
|
|
557
|
+
// Interpret only well-defined provider unit codes, and distinguish CPI index
|
|
558
|
+
// levels from annual inflation rates without transforming source values.
|
|
559
|
+
func dataMeasurementNote(raw any) string {
|
|
560
|
+
data := mapField(raw)
|
|
561
|
+
if data == nil {
|
|
562
|
+
return ""
|
|
563
|
+
}
|
|
564
|
+
series := strings.ToUpper(stringField(data["series_id"]))
|
|
565
|
+
if !strings.HasPrefix(series, "CPI") {
|
|
566
|
+
return ""
|
|
567
|
+
}
|
|
568
|
+
switch strings.ToLower(stringField(data["units"])) {
|
|
569
|
+
case "lin", "":
|
|
570
|
+
return "CPI observations are price-index levels, not annual inflation percentages. Do not label them as inflation rates; a year-over-year percentage requires a 12-month comparison or an appropriate FRED transformation."
|
|
571
|
+
case "pc1":
|
|
572
|
+
return "FRED units=pc1 represents the percent change from one year ago, not an index level."
|
|
573
|
+
default:
|
|
574
|
+
return "CPI observations use the requested FRED units transformation; inspect the units field before interpreting values as percentages."
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -72,33 +72,6 @@ func nativeInputTemplate(contract map[string]any) (map[string]any, []string) {
|
|
|
72
72
|
seen[name] = true
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
for _, group := range listField(contract["requiresOneOf"]) {
|
|
76
|
-
var matched bool
|
|
77
|
-
for _, field := range listField(group) {
|
|
78
|
-
name := stringField(field)
|
|
79
|
-
if _, already := template[name]; already && template[name] != nil {
|
|
80
|
-
matched = true
|
|
81
|
-
break
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if matched {
|
|
85
|
-
continue
|
|
86
|
-
}
|
|
87
|
-
// Show one candidate with a blank value rather than guessing user
|
|
88
|
-
// intent or silently filling provider-specific search terms.
|
|
89
|
-
for _, field := range listField(group) {
|
|
90
|
-
name := stringField(field)
|
|
91
|
-
if name == "" {
|
|
92
|
-
continue
|
|
93
|
-
}
|
|
94
|
-
template[name] = nil
|
|
95
|
-
if !seen[name] {
|
|
96
|
-
need = append(need, name)
|
|
97
|
-
seen[name] = true
|
|
98
|
-
}
|
|
99
|
-
break
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
75
|
return template, need
|
|
103
76
|
}
|
|
104
77
|
|
|
@@ -125,10 +98,9 @@ func InspectData(input InspectDataInput) (map[string]any, error) {
|
|
|
125
98
|
return nil, dataError("invalid_source", "Use an exact source ID returned by research.")
|
|
126
99
|
}
|
|
127
100
|
include := input.Include
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
list, err := ResearchData(ResearchDataInput{Sources: []string{id}, View: "operations", Include: include, Limit: 25, Raw: input.Raw})
|
|
101
|
+
// Provider-level discovery is intentionally lightweight: inspect a
|
|
102
|
+
// particular operation for full field-by-field contracts.
|
|
103
|
+
list, err := ResearchData(ResearchDataInput{Sources: []string{id}, View: "operations", Include: include, Limit: 12, Raw: input.Raw})
|
|
132
104
|
if err != nil {
|
|
133
105
|
return nil, err
|
|
134
106
|
}
|
|
@@ -144,10 +116,8 @@ func InspectData(input InspectDataInput) (map[string]any, error) {
|
|
|
144
116
|
}
|
|
145
117
|
provider, _, _ := parseOperationID(id)
|
|
146
118
|
inputs, missing := nativeInputTemplate(item)
|
|
147
|
-
budget := dataCreditCap()
|
|
148
119
|
execArguments := map[string]any{
|
|
149
|
-
"calls":
|
|
150
|
-
"max_credits": budget,
|
|
120
|
+
"calls": []any{map[string]any{"id": id, "inputs": inputs}},
|
|
151
121
|
}
|
|
152
122
|
response := map[string]any{
|
|
153
123
|
"id": id,
|
|
@@ -159,12 +129,23 @@ func InspectData(input InspectDataInput) (map[string]any, error) {
|
|
|
159
129
|
"credits_per_call": item["creditsCost"],
|
|
160
130
|
"per_record": item["perRecord"],
|
|
161
131
|
},
|
|
162
|
-
"inputs":
|
|
163
|
-
"output":
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
132
|
+
"inputs": map[string]any{"fields": item["options"], "normalized_fields": normalizedDataInputFields(item), "requires_one_of": item["requiresOneOf"]},
|
|
133
|
+
"output": item["response"],
|
|
134
|
+
"normalized_output_fields": normalizedDataOutputFields(item),
|
|
135
|
+
"examples": contractExamples(item),
|
|
136
|
+
"input_template": inputs,
|
|
137
|
+
"fill_one_of": item["requiresOneOf"],
|
|
138
|
+
"next": map[string]any{"tool": "execute", "arguments": execArguments},
|
|
139
|
+
"guidance": "Copy next.arguments into execute, fill any blank required inputs and adjust values according to inputs.fields. Execute uses credits.",
|
|
140
|
+
}
|
|
141
|
+
if cost, ok := numberField(item["creditsCost"]); ok && cost > float64(dataCreditCap()) {
|
|
142
|
+
response["availability"] = "exceeds_server_credit_limit"
|
|
143
|
+
response["executable"] = false
|
|
144
|
+
response["server_credit_limit"] = dataCreditCap()
|
|
145
|
+
delete(response, "next")
|
|
146
|
+
response["guidance"] = fmt.Sprintf("This operation costs %v credits per call and exceeds Origo's %d-credit request ceiling. It cannot execute under this server configuration.", cost, dataCreditCap())
|
|
147
|
+
} else {
|
|
148
|
+
response["executable"] = true
|
|
168
149
|
}
|
|
169
150
|
if len(missing) > 0 {
|
|
170
151
|
response["fill_before_execution"] = missing
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/url"
|
|
5
|
+
"strconv"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// providerContinuation uses native cursor and page metadata without
|
|
10
|
+
// reconstructing provider URLs. All previously supplied filters remain
|
|
11
|
+
// unchanged; only the relevant paging parameter is added/replaced.
|
|
12
|
+
func providerContinuation(id string, original map[string]any, data any) map[string]any {
|
|
13
|
+
value := mapField(data)
|
|
14
|
+
if value == nil {
|
|
15
|
+
return nil
|
|
16
|
+
}
|
|
17
|
+
makeNext := func(key string, token any) map[string]any {
|
|
18
|
+
if key == "" || token == nil || token == "" {
|
|
19
|
+
return nil
|
|
20
|
+
}
|
|
21
|
+
next := copyInputs(original)
|
|
22
|
+
if previous, exists := next[key]; exists && previous == token {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
next[key] = token
|
|
26
|
+
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
27
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
28
|
+
}}
|
|
29
|
+
}
|
|
30
|
+
for _, candidate := range []struct {
|
|
31
|
+
location map[string]any
|
|
32
|
+
names []string
|
|
33
|
+
input string
|
|
34
|
+
}{
|
|
35
|
+
{value, []string{"next_page_token", "nextPageToken", "page_token_next"}, "page_token"},
|
|
36
|
+
{mapField(value["pagination"]), []string{"next_page_token", "nextPageToken"}, "page_token"},
|
|
37
|
+
{value, []string{"continuation", "next_continuation"}, "continuation"},
|
|
38
|
+
{mapField(value["metadata"]), []string{"search_after"}, "search_after"},
|
|
39
|
+
{mapField(value["meta"]), []string{"search_after"}, "search_after"},
|
|
40
|
+
{value, []string{"search_after"}, "search_after"},
|
|
41
|
+
{mapField(value["pagination"]), []string{"next_cursor", "nextCursor"}, "cursor"},
|
|
42
|
+
{mapField(value["pageInfo"]), []string{"endCursor"}, "cursor"},
|
|
43
|
+
} {
|
|
44
|
+
for _, name := range candidate.names {
|
|
45
|
+
token := candidate.location[name]
|
|
46
|
+
if token == nil || token == "" {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
key := candidate.input
|
|
50
|
+
if key == "page_token" {
|
|
51
|
+
for _, alias := range []string{"page_token", "pageToken", "pageTokenNext"} {
|
|
52
|
+
if _, exists := original[alias]; exists {
|
|
53
|
+
key = alias
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if key == "cursor" {
|
|
59
|
+
for _, alias := range []string{"cursor", "after", "after_cursor"} {
|
|
60
|
+
if _, exists := original[alias]; exists {
|
|
61
|
+
key = alias
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if next := makeNext(key, token); next != nil {
|
|
67
|
+
return next
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Treasury and other API-backed providers expose the following page as
|
|
72
|
+
// a URL. We extract only recognized pagination parameters; we never
|
|
73
|
+
// fetch or execute that URL directly.
|
|
74
|
+
links := mapField(value["links"])
|
|
75
|
+
for _, source := range []any{links["next"], mapField(value["paging"])["next"]} {
|
|
76
|
+
target := stringField(source)
|
|
77
|
+
if target == "" {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
u, err := url.Parse(target)
|
|
81
|
+
if err != nil {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
q := u.Query()
|
|
85
|
+
for _, candidate := range []struct{ From, To string }{
|
|
86
|
+
{"page[number]", "page"}, {"pageNumber", "page"}, {"page", "page"},
|
|
87
|
+
{"page_number", "page_number"}, {"page[size]", "page_size"},
|
|
88
|
+
{"pageToken", "page_token"}, {"page_token", "page_token"},
|
|
89
|
+
{"offset", "offset"}, {"cursor", "cursor"},
|
|
90
|
+
} {
|
|
91
|
+
raw := q.Get(candidate.From)
|
|
92
|
+
if raw == "" {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
key := candidate.To
|
|
96
|
+
if candidate.From == "page[number]" {
|
|
97
|
+
for _, alias := range []string{"page_number", "page", "pageNumber"} {
|
|
98
|
+
if _, exists := original[alias]; exists {
|
|
99
|
+
key = alias
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
token := any(raw)
|
|
105
|
+
if n, e := strconv.Atoi(raw); e == nil {
|
|
106
|
+
token = n
|
|
107
|
+
}
|
|
108
|
+
if next := makeNext(key, token); next != nil {
|
|
109
|
+
return next
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Some providers only return has_more and a current page number.
|
|
114
|
+
if more, _ := value["has_more"].(bool); more {
|
|
115
|
+
for _, name := range []string{"page", "page_number", "pageNumber"} {
|
|
116
|
+
if n, ok := numberField(original[name]); ok && n >= 0 {
|
|
117
|
+
return makeNext(name, int(n)+1)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if link := stringField(value["next"]); link != "" &&
|
|
122
|
+
!strings.HasPrefix(strings.ToLower(link), "http") {
|
|
123
|
+
return makeNext("cursor", link)
|
|
124
|
+
}
|
|
125
|
+
return nil
|
|
126
|
+
}
|