webctx 0.2.6 → 0.2.8
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 +1 -1
- package/internal/app/app.go +29 -1
- package/internal/app/data_audit_test.go +574 -0
- package/internal/app/data_client.go +193 -5
- package/internal/app/data_contract_constraints.go +65 -0
- package/internal/app/data_examples.go +79 -6
- package/internal/app/data_execute.go +224 -64
- package/internal/app/data_inspect.go +20 -37
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +118 -0
- package/internal/app/data_research.go +189 -16
- package/internal/app/data_tools_test.go +2 -2
- package/internal/app/data_types.go +35 -12
- package/internal/app/research.go +4 -1
- package/internal/app/research_answers.go +30 -0
- package/internal/app/site_map_page.go +275 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +27 -0
- package/pkg/origo/server.go +17 -10
- package/pkg/retrieval/data.go +2 -0
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
// In a single warm worker, serialize requests with the same idempotency key.
|
|
24
|
+
// Without this, two concurrent requests can both miss the replay cache and
|
|
25
|
+
// execute before either writes the receipt.
|
|
26
|
+
type paidReplayGate struct {
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
users int
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var paidReplayGates = struct {
|
|
32
|
+
sync.Mutex
|
|
33
|
+
items map[string]*paidReplayGate
|
|
34
|
+
}{items: map[string]*paidReplayGate{}}
|
|
35
|
+
|
|
36
|
+
func lockPaidRequest(id string) func() {
|
|
37
|
+
key := localReplayKey(id)
|
|
38
|
+
paidReplayGates.Lock()
|
|
39
|
+
gate := paidReplayGates.items[key]
|
|
40
|
+
if gate == nil {
|
|
41
|
+
gate = &paidReplayGate{}
|
|
42
|
+
paidReplayGates.items[key] = gate
|
|
43
|
+
}
|
|
44
|
+
gate.users++
|
|
45
|
+
paidReplayGates.Unlock()
|
|
46
|
+
gate.mu.Lock()
|
|
47
|
+
return func() {
|
|
48
|
+
gate.mu.Unlock()
|
|
49
|
+
paidReplayGates.Lock()
|
|
50
|
+
gate.users--
|
|
51
|
+
if gate.users == 0 {
|
|
52
|
+
delete(paidReplayGates.items, key)
|
|
53
|
+
}
|
|
54
|
+
paidReplayGates.Unlock()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func localReplayKey(id string) string {
|
|
59
|
+
h := sha256.Sum256([]byte(os.Getenv("FIRECRAWL_API_KEY") + "\x00" + id))
|
|
60
|
+
return hex.EncodeToString(h[:])
|
|
61
|
+
}
|
|
62
|
+
func paidRequestFingerprint(calls []DataCall) string {
|
|
63
|
+
body, _ := json.Marshal(calls)
|
|
64
|
+
h := sha256.Sum256(body)
|
|
65
|
+
return hex.EncodeToString(h[:])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// This ledger is only a warm-worker replay optimization. It cannot claim to
|
|
69
|
+
// verify charges or prevent replay across multiple Vercel instances; upstream
|
|
70
|
+
// x-request-id idempotency remains authoritative in that situation.
|
|
71
|
+
func cachedPaidReplay(requestID string, calls []DataCall) (map[string]any, error) {
|
|
72
|
+
key := localReplayKey(requestID)
|
|
73
|
+
fingerprint := paidRequestFingerprint(calls)
|
|
74
|
+
paidReplayMemory.Lock()
|
|
75
|
+
entry, ok := paidReplayMemory.items[key]
|
|
76
|
+
paidReplayMemory.Unlock()
|
|
77
|
+
if !ok || time.Now().After(entry.expires) {
|
|
78
|
+
return nil, nil
|
|
79
|
+
}
|
|
80
|
+
if entry.fingerprint != fingerprint {
|
|
81
|
+
return nil, &DataError{
|
|
82
|
+
Code: "duplicate_request",
|
|
83
|
+
Message: "This request_id already completed with different inputs. Restore the original calls to replay, or omit request_id for a new query.",
|
|
84
|
+
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."},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
raw, err := json.Marshal(entry.output)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return nil, nil
|
|
90
|
+
}
|
|
91
|
+
var result map[string]any
|
|
92
|
+
if json.Unmarshal(raw, &result) != nil {
|
|
93
|
+
return nil, nil
|
|
94
|
+
}
|
|
95
|
+
result["replayed"] = true
|
|
96
|
+
result["replay_status"] = "served_from_local_cache"
|
|
97
|
+
result["original_credits_used"] = result["credits_used"]
|
|
98
|
+
result["credits_charged_this_request"] = 0
|
|
99
|
+
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."
|
|
100
|
+
return result, nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func storePaidReplay(requestID string, calls []DataCall, output map[string]any) {
|
|
104
|
+
raw, err := json.Marshal(output)
|
|
105
|
+
if err != nil || len(raw) > 1<<20 {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
key := localReplayKey(requestID)
|
|
109
|
+
paidReplayMemory.Lock()
|
|
110
|
+
defer paidReplayMemory.Unlock()
|
|
111
|
+
if len(paidReplayMemory.items) > 192 {
|
|
112
|
+
paidReplayMemory.items = map[string]localPaidReplay{}
|
|
113
|
+
}
|
|
114
|
+
paidReplayMemory.items[key] = localPaidReplay{
|
|
115
|
+
fingerprint: paidRequestFingerprint(calls), output: output,
|
|
116
|
+
expires: time.Now().Add(15 * time.Minute),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"net/url"
|
|
4
5
|
"sort"
|
|
5
6
|
"strings"
|
|
6
7
|
)
|
|
@@ -60,7 +61,7 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
|
|
|
60
61
|
if err != nil {
|
|
61
62
|
return input, "", nil, err
|
|
62
63
|
}
|
|
63
|
-
if input.Limit == 0 {
|
|
64
|
+
if input.Limit == 0 && !input.limitProvided {
|
|
64
65
|
input.Limit = 10
|
|
65
66
|
}
|
|
66
67
|
if input.Limit < 1 || input.Limit > 100 {
|
|
@@ -69,11 +70,27 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
|
|
|
69
70
|
if input.Offset < 0 || input.Offset > 100000 {
|
|
70
71
|
return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
|
|
71
72
|
}
|
|
73
|
+
if input.Mode == "ranked" && input.Offset > 0 && strings.TrimSpace(input.Query) != "" &&
|
|
74
|
+
len(input.Sources) == 0 && len(input.URLs) == 0 && len(input.Groups) == 0 &&
|
|
75
|
+
len(input.Categories) == 0 && len(input.Operations) == 0 {
|
|
76
|
+
return input, "", nil, dataError("ranked_search_not_pageable",
|
|
77
|
+
"Ranked semantic search has no native pagination cursor. Use mode=catalogue with offset, or supply a source/category filter to browse and rerank catalogue pages.")
|
|
78
|
+
}
|
|
72
79
|
for _, target := range input.URLs {
|
|
73
80
|
if err := validateSourceURL(target); err != nil {
|
|
74
81
|
return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
|
|
75
82
|
}
|
|
76
83
|
}
|
|
84
|
+
if len(input.URLs) > 0 && len(input.Sources) == 0 {
|
|
85
|
+
for _, target := range input.URLs {
|
|
86
|
+
u, _ := url.Parse(target)
|
|
87
|
+
host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
|
|
88
|
+
if host != "" {
|
|
89
|
+
source := strings.ReplaceAll(host, ".", "-")
|
|
90
|
+
input.Sources = append(input.Sources, source)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
77
94
|
return input, view, expand, nil
|
|
78
95
|
}
|
|
79
96
|
|
|
@@ -82,7 +99,7 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
|
|
|
82
99
|
if input.Query != "" {
|
|
83
100
|
o["query"] = input.Query
|
|
84
101
|
}
|
|
85
|
-
if len(input.URLs) > 0 {
|
|
102
|
+
if len(input.URLs) > 0 && len(input.Sources) == 0 {
|
|
86
103
|
o["urls"] = input.URLs
|
|
87
104
|
}
|
|
88
105
|
if len(input.Sources) > 0 {
|
|
@@ -123,6 +140,9 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
|
|
|
123
140
|
}
|
|
124
141
|
}
|
|
125
142
|
if len(expand) > 0 {
|
|
143
|
+
if containsString(expand, "examples") && !containsString(expand, "options") {
|
|
144
|
+
expand = append(append([]string(nil), expand...), "options")
|
|
145
|
+
}
|
|
126
146
|
o["expand"] = expand
|
|
127
147
|
}
|
|
128
148
|
return o
|
|
@@ -145,9 +165,19 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
145
165
|
}
|
|
146
166
|
ctx, cancel := defaultDataContext()
|
|
147
167
|
defer cancel()
|
|
168
|
+
explicitExact := len(input.Operations) > 0
|
|
169
|
+
// An exact operation ID is authoritative. A descriptive query helps
|
|
170
|
+
// communicate intent, but must not exclude a known operation.
|
|
171
|
+
note := ""
|
|
172
|
+
if explicitExact && input.Query != "" {
|
|
173
|
+
note = "Exact operation IDs take precedence over descriptive query terms; the query was not used as a restrictive upstream filter."
|
|
174
|
+
input.Query = ""
|
|
175
|
+
}
|
|
148
176
|
semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
|
|
149
177
|
len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
|
|
150
178
|
len(input.Groups) == 0 && len(input.Operations) == 0
|
|
179
|
+
localFilteredRanking := view == "tools" && input.Query != "" &&
|
|
180
|
+
(len(input.URLs) > 0 || len(input.Sources) > 0 || len(input.Groups) > 0 || len(input.Categories) > 0)
|
|
151
181
|
var items []any
|
|
152
182
|
var total any
|
|
153
183
|
var next any
|
|
@@ -159,9 +189,16 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
159
189
|
// compact upstream matches may omit both price and operation name.
|
|
160
190
|
// We still return compact result cards unless include was requested.
|
|
161
191
|
detail := "full"
|
|
192
|
+
fetchLimit := input.Limit * 4
|
|
193
|
+
if fetchLimit < 20 {
|
|
194
|
+
fetchLimit = 20
|
|
195
|
+
}
|
|
196
|
+
if fetchLimit > 100 {
|
|
197
|
+
fetchLimit = 100
|
|
198
|
+
}
|
|
162
199
|
raw, err = doDataAPI(ctx, "/search", map[string]any{
|
|
163
200
|
"query": input.Query, "sources": []string{"alexandria"},
|
|
164
|
-
"limit":
|
|
201
|
+
"limit": fetchLimit, "toolDetail": detail,
|
|
165
202
|
}, "")
|
|
166
203
|
if err != nil {
|
|
167
204
|
return nil, err
|
|
@@ -171,17 +208,6 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
171
208
|
// Some upstream ranked matches arrive out of order despite including a
|
|
172
209
|
// similarity score. Present the highest-scoring candidate first, retaining
|
|
173
210
|
// upstream order for exact ties or missing scores.
|
|
174
|
-
sort.SliceStable(items, func(i, j int) bool {
|
|
175
|
-
a, aok := numberField(mapField(items[i])["similarity"])
|
|
176
|
-
b, bok := numberField(mapField(items[j])["similarity"])
|
|
177
|
-
if aok != bok {
|
|
178
|
-
return aok
|
|
179
|
-
}
|
|
180
|
-
if !aok {
|
|
181
|
-
return false
|
|
182
|
-
}
|
|
183
|
-
return a > b
|
|
184
|
-
})
|
|
185
211
|
// Ranked search has no native cursor. Expose the free, complete
|
|
186
212
|
// catalogue with matching semantic query as an optional expansion.
|
|
187
213
|
next = map[string]any{"tool": "research", "arguments": map[string]any{
|
|
@@ -189,6 +215,24 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
189
215
|
}, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
|
|
190
216
|
} else {
|
|
191
217
|
options := nativeBrowseOptions(input, view, expand)
|
|
218
|
+
if localFilteredRanking {
|
|
219
|
+
// Provider-specified lexical filters can exclude operations whose
|
|
220
|
+
// descriptions describe the requested data indirectly. Fetch a
|
|
221
|
+
// catalogue page from the actual source, then rank that page.
|
|
222
|
+
// Do not reset every continuation to offset zero: large sources
|
|
223
|
+
// can have hundreds of operations.
|
|
224
|
+
delete(options, "query")
|
|
225
|
+
options["limit"] = 100
|
|
226
|
+
options["offset"] = (input.Offset / 100) * 100
|
|
227
|
+
note = "Source/category filters select candidates first. The question ranks each catalogue page locally (up to 100 operations per page); this is not a global ranking across unvisited pages."
|
|
228
|
+
if input.Mode != "catalogue" {
|
|
229
|
+
mode = "ranked_filtered"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if explicitExact {
|
|
233
|
+
delete(options, "query")
|
|
234
|
+
delete(options, "urls")
|
|
235
|
+
}
|
|
192
236
|
var catalogue map[string]any
|
|
193
237
|
catalogue, raw, err = freeDataBrowse(ctx, options)
|
|
194
238
|
if err != nil {
|
|
@@ -200,6 +244,52 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
200
244
|
next = continuationFromCatalogue(native)
|
|
201
245
|
}
|
|
202
246
|
}
|
|
247
|
+
if view == "tools" {
|
|
248
|
+
termsQuery := input.Query
|
|
249
|
+
for _, op := range input.Operations {
|
|
250
|
+
termsQuery += " " + op
|
|
251
|
+
}
|
|
252
|
+
if strings.TrimSpace(termsQuery) != "" {
|
|
253
|
+
sort.SliceStable(items, func(i, j int) bool {
|
|
254
|
+
return intentRelevance(mapField(items[i]), termsQuery) > intentRelevance(mapField(items[j]), termsQuery)
|
|
255
|
+
})
|
|
256
|
+
} else {
|
|
257
|
+
sort.SliceStable(items, func(i, j int) bool {
|
|
258
|
+
a, aok := numberField(mapField(items[i])["similarity"])
|
|
259
|
+
b, bok := numberField(mapField(items[j])["similarity"])
|
|
260
|
+
if aok != bok {
|
|
261
|
+
return aok
|
|
262
|
+
}
|
|
263
|
+
return a > b
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if semantic && len(items) > input.Limit {
|
|
268
|
+
items = items[:input.Limit]
|
|
269
|
+
}
|
|
270
|
+
if localFilteredRanking {
|
|
271
|
+
count := len(items)
|
|
272
|
+
start := input.Offset % 100
|
|
273
|
+
if start > count {
|
|
274
|
+
start = count
|
|
275
|
+
}
|
|
276
|
+
end := start + input.Limit
|
|
277
|
+
if end > count {
|
|
278
|
+
end = count
|
|
279
|
+
}
|
|
280
|
+
items = items[start:end]
|
|
281
|
+
sourceTotal, hasTotal := numberField(total)
|
|
282
|
+
more := count > end || (hasTotal && sourceTotal > float64((input.Offset/100)*100+count))
|
|
283
|
+
if more {
|
|
284
|
+
next = map[string]any{"tool": "research", "arguments": ResearchDataInput{
|
|
285
|
+
Query: input.Query, Mode: input.Mode, URLs: input.URLs, Sources: input.Sources,
|
|
286
|
+
Categories: input.Categories, Groups: input.Groups, View: niceDataView(view),
|
|
287
|
+
Include: input.Include, Limit: input.Limit, Offset: input.Offset + len(items),
|
|
288
|
+
}}
|
|
289
|
+
} else {
|
|
290
|
+
next = nil
|
|
291
|
+
}
|
|
292
|
+
}
|
|
203
293
|
results := make([]map[string]any, 0, len(items))
|
|
204
294
|
for _, value := range items {
|
|
205
295
|
entry := mapField(value)
|
|
@@ -223,6 +313,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
223
313
|
}
|
|
224
314
|
if cost, ok := entry["creditsCost"]; ok {
|
|
225
315
|
result["credits_per_call"] = cost
|
|
316
|
+
if listed, ok := numberField(cost); ok {
|
|
317
|
+
if listed > float64(dataCreditCap()) {
|
|
318
|
+
result["executable"] = false
|
|
319
|
+
result["availability"] = "exceeds_server_credit_limit"
|
|
320
|
+
result["server_credit_limit"] = dataCreditCap()
|
|
321
|
+
} else if view == "tools" {
|
|
322
|
+
result["executable"] = true
|
|
323
|
+
}
|
|
324
|
+
}
|
|
226
325
|
}
|
|
227
326
|
if cost, ok := entry["perRecord"]; ok {
|
|
228
327
|
result["per_record"] = cost
|
|
@@ -230,8 +329,14 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
230
329
|
if count, ok := entry["toolCount"]; ok {
|
|
231
330
|
result["operation_count"] = count
|
|
232
331
|
}
|
|
332
|
+
if input.Query != "" && view == "tools" {
|
|
333
|
+
result["relevance"] = intentRelevance(entry, input.Query)
|
|
334
|
+
}
|
|
233
335
|
if rank, ok := entry["similarity"]; ok {
|
|
234
|
-
result["
|
|
336
|
+
result["semantic_similarity"] = rank
|
|
337
|
+
if input.Query == "" {
|
|
338
|
+
result["relevance"] = rank
|
|
339
|
+
}
|
|
235
340
|
}
|
|
236
341
|
if len(expand) > 0 {
|
|
237
342
|
if selected := entry["options"]; selected != nil && containsString(expand, "options") {
|
|
@@ -243,8 +348,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
243
348
|
if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
|
|
244
349
|
result["examples"] = selected
|
|
245
350
|
}
|
|
351
|
+
if containsString(expand, "examples") && result["examples"] == nil {
|
|
352
|
+
if entry["options"] != nil {
|
|
353
|
+
result["examples"] = contractExamples(entry)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
246
356
|
}
|
|
247
|
-
if
|
|
357
|
+
if view == "tools" && id != "" {
|
|
358
|
+
result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
|
|
359
|
+
} else if continuation := mapField(entry["next"]); continuation != nil {
|
|
248
360
|
result["next"] = continuationFromCatalogue(continuation)
|
|
249
361
|
} else if id != "" {
|
|
250
362
|
result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
|
|
@@ -256,6 +368,18 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
256
368
|
"count": len(results), "cost_credits": 0,
|
|
257
369
|
"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.",
|
|
258
370
|
}
|
|
371
|
+
if note != "" {
|
|
372
|
+
output["mode_note"] = note
|
|
373
|
+
}
|
|
374
|
+
if localFilteredRanking {
|
|
375
|
+
output["ranking_scope"] = "catalogue_page"
|
|
376
|
+
if sourceTotal, ok := numberField(total); ok {
|
|
377
|
+
output["ranking_incomplete"] = sourceTotal > 100
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if len(input.URLs) > 0 && len(input.Sources) > 0 {
|
|
381
|
+
output["resolved_url_sources"] = input.Sources
|
|
382
|
+
}
|
|
259
383
|
if total != nil {
|
|
260
384
|
output["total"] = total
|
|
261
385
|
}
|
|
@@ -268,6 +392,55 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
268
392
|
return output, nil
|
|
269
393
|
}
|
|
270
394
|
|
|
395
|
+
// Rank operations by their ability to deliver the requested data, not just
|
|
396
|
+
// semantic similarity. Keep the provider similarity separately for auditing.
|
|
397
|
+
func intentRelevance(entry map[string]any, query string) float64 {
|
|
398
|
+
if entry == nil {
|
|
399
|
+
return 0
|
|
400
|
+
}
|
|
401
|
+
base, _ := numberField(entry["similarity"])
|
|
402
|
+
if query == "" {
|
|
403
|
+
return base
|
|
404
|
+
}
|
|
405
|
+
q := strings.ToLower(query)
|
|
406
|
+
id := strings.ToLower(stringField(entry["id"]))
|
|
407
|
+
name := strings.ToLower(stringField(entry["name"]))
|
|
408
|
+
desc := strings.ToLower(stringField(entry["description"]))
|
|
409
|
+
p := id + " " + name
|
|
410
|
+
searchIntent := strings.Contains(q, "search") || strings.Contains(q, "find") ||
|
|
411
|
+
strings.Contains(q, "papers") || strings.Contains(q, "podcast") ||
|
|
412
|
+
strings.Contains(q, "trials") || strings.Contains(q, "about")
|
|
413
|
+
historyIntent := strings.Contains(q, "historical") || strings.Contains(q, "history") ||
|
|
414
|
+
strings.Contains(q, "time series") || strings.Contains(q, "over time") ||
|
|
415
|
+
strings.Contains(q, "inflation")
|
|
416
|
+
if historyIntent && (strings.Contains(p, "observations") ||
|
|
417
|
+
strings.Contains(p, "history") || strings.Contains(p, "rates") ||
|
|
418
|
+
strings.Contains(p, "timeseries")) {
|
|
419
|
+
base += 0.23
|
|
420
|
+
}
|
|
421
|
+
if searchIntent && (strings.Contains(p, "/search") || strings.Contains(p, "/studies") ||
|
|
422
|
+
strings.Contains(p, "episode-search")) {
|
|
423
|
+
base += 0.20
|
|
424
|
+
}
|
|
425
|
+
if searchIntent && strings.Contains(p, "/list") && !strings.Contains(q, "list") {
|
|
426
|
+
base -= 0.04
|
|
427
|
+
}
|
|
428
|
+
for _, ancillary := range []string{"suggest", "related", "directory", "releases", "/source", "metadata", "browse"} {
|
|
429
|
+
if strings.Contains(p, ancillary) && !strings.Contains(q, strings.TrimLeft(ancillary, "/")) {
|
|
430
|
+
base -= 0.14
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
terms := queryTerms(query)
|
|
434
|
+
if len(terms) > 0 {
|
|
435
|
+
fit := termScore(name+" "+desc, terms)
|
|
436
|
+
if fit > 8 {
|
|
437
|
+
fit = 8
|
|
438
|
+
}
|
|
439
|
+
base += float64(fit) * 0.007
|
|
440
|
+
}
|
|
441
|
+
return float64(int(base*1000000+0.5)) / 1000000
|
|
442
|
+
}
|
|
443
|
+
|
|
271
444
|
func niceDataView(view string) string {
|
|
272
445
|
switch view {
|
|
273
446
|
case "providers":
|
|
@@ -286,8 +286,8 @@ func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
|
|
|
286
286
|
mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
|
|
287
287
|
t.Fatalf("terms condition lost: %+v", err)
|
|
288
288
|
}
|
|
289
|
-
if mapField(typed.Details)["
|
|
290
|
-
t.Fatalf("
|
|
289
|
+
if mapField(typed.Details)["retry"] != nil {
|
|
290
|
+
t.Fatalf("terms rejection must not recommend automatic paid retry: %+v", typed)
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
@@ -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
|
package/internal/app/research.go
CHANGED
|
@@ -88,6 +88,9 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
88
88
|
return "", err
|
|
89
89
|
}
|
|
90
90
|
primary = copyableCurlExamples(primary)
|
|
91
|
+
if answer, ok := directlyAnsweredMetadata(rawURL, question, primary); ok {
|
|
92
|
+
return answer, nil
|
|
93
|
+
}
|
|
91
94
|
|
|
92
95
|
// Combine on-page links and authoritative Markdown index links instead of
|
|
93
96
|
// filling the entire budget from whichever links happen to appear first.
|
|
@@ -95,7 +98,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
95
98
|
// SQLite, webhook, and API references over incidental alarm/concept pages.
|
|
96
99
|
links := researchCandidates(rawURL, primary, question, maxRelatedSources)
|
|
97
100
|
structuredDone := make(chan string, 1)
|
|
98
|
-
if strings.Contains(primary, "**Markdown source:**") {
|
|
101
|
+
if strings.Contains(primary, "**Markdown source:**") || !asksForAPIContracts(question) {
|
|
99
102
|
structuredDone <- ""
|
|
100
103
|
} else {
|
|
101
104
|
go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var githubLicenseField = regexp.MustCompile(`(?mi)^license:\s*["']?([A-Za-z0-9.+-]+)["']?\s*$`)
|
|
10
|
+
var repositoryLicenseURL = regexp.MustCompile(`https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/blob/[A-Za-z0-9_.-]+/(?:LICENSE|COPYING)(?:\.md|\.txt)?`)
|
|
11
|
+
|
|
12
|
+
func directlyAnsweredMetadata(rawURL, question, markdown string) (string, bool) {
|
|
13
|
+
q := strings.ToLower(question)
|
|
14
|
+
if !strings.Contains(q, "license") || !strings.Contains(strings.ToLower(rawURL), "github.com/") {
|
|
15
|
+
return "", false
|
|
16
|
+
}
|
|
17
|
+
match := githubLicenseField.FindStringSubmatch(markdown)
|
|
18
|
+
if len(match) != 2 {
|
|
19
|
+
return "", false
|
|
20
|
+
}
|
|
21
|
+
value := match[1]
|
|
22
|
+
evidenceURL := rawURL
|
|
23
|
+
if source := repositoryLicenseURL.FindString(markdown); source != "" {
|
|
24
|
+
evidenceURL = source
|
|
25
|
+
}
|
|
26
|
+
return fmt.Sprintf(
|
|
27
|
+
"## Answer\n\nThe repository lists its license as **%s**.\n\n**Source:** %s\n\n**Evidence:** GitHub repository metadata lists `license: %q`.\n",
|
|
28
|
+
value, evidenceURL, value,
|
|
29
|
+
), true
|
|
30
|
+
}
|