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
|
@@ -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
|
|
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":
|
|
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["
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
230
|
-
|
|
231
|
-
code string
|
|
257
|
+
inputs map[string]any
|
|
258
|
+
code string
|
|
232
259
|
}{
|
|
233
|
-
{map[string]any{},
|
|
234
|
-
{map[string]any{"semantic_search": "AI agents", "limit": 101},
|
|
235
|
-
{map[string]any{"semantic_search": "AI agents", "unknown_field": 123},
|
|
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}}
|
|
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)["
|
|
264
|
-
t.Fatalf("
|
|
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") ||
|
|
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}
|
|
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
|
|
7
|
-
Mode
|
|
8
|
-
URLs
|
|
9
|
-
Sources
|
|
10
|
-
Categories
|
|
11
|
-
Groups
|
|
12
|
-
Operations
|
|
13
|
-
View
|
|
14
|
-
Include
|
|
15
|
-
Limit
|
|
16
|
-
Offset
|
|
17
|
-
Raw
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|