webctx 0.2.6 → 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 +1 -1
- package/internal/app/app.go +28 -1
- package/internal/app/data_audit_test.go +433 -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 +202 -61
- package/internal/app/data_inspect.go +20 -37
- package/internal/app/data_pagination.go +126 -0
- package/internal/app/data_replay.go +83 -0
- package/internal/app/data_research.go +171 -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 +256 -0
- package/internal/app/tools.go +16 -46
- package/package.json +1 -1
- package/pkg/origo/data_test.go +26 -0
- package/pkg/origo/server.go +17 -10
- package/pkg/retrieval/data.go +2 -0
|
@@ -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 {
|
|
@@ -74,6 +75,16 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
|
|
|
74
75
|
return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
|
|
75
76
|
}
|
|
76
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
|
+
}
|
|
77
88
|
return input, view, expand, nil
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -82,7 +93,7 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
|
|
|
82
93
|
if input.Query != "" {
|
|
83
94
|
o["query"] = input.Query
|
|
84
95
|
}
|
|
85
|
-
if len(input.URLs) > 0 {
|
|
96
|
+
if len(input.URLs) > 0 && len(input.Sources) == 0 {
|
|
86
97
|
o["urls"] = input.URLs
|
|
87
98
|
}
|
|
88
99
|
if len(input.Sources) > 0 {
|
|
@@ -123,6 +134,9 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
if len(expand) > 0 {
|
|
137
|
+
if containsString(expand, "examples") && !containsString(expand, "options") {
|
|
138
|
+
expand = append(append([]string(nil), expand...), "options")
|
|
139
|
+
}
|
|
126
140
|
o["expand"] = expand
|
|
127
141
|
}
|
|
128
142
|
return o
|
|
@@ -145,9 +159,19 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
145
159
|
}
|
|
146
160
|
ctx, cancel := defaultDataContext()
|
|
147
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
|
+
}
|
|
148
170
|
semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
|
|
149
171
|
len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
|
|
150
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)
|
|
151
175
|
var items []any
|
|
152
176
|
var total any
|
|
153
177
|
var next any
|
|
@@ -159,9 +183,16 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
159
183
|
// compact upstream matches may omit both price and operation name.
|
|
160
184
|
// We still return compact result cards unless include was requested.
|
|
161
185
|
detail := "full"
|
|
186
|
+
fetchLimit := input.Limit * 4
|
|
187
|
+
if fetchLimit < 20 {
|
|
188
|
+
fetchLimit = 20
|
|
189
|
+
}
|
|
190
|
+
if fetchLimit > 100 {
|
|
191
|
+
fetchLimit = 100
|
|
192
|
+
}
|
|
162
193
|
raw, err = doDataAPI(ctx, "/search", map[string]any{
|
|
163
194
|
"query": input.Query, "sources": []string{"alexandria"},
|
|
164
|
-
"limit":
|
|
195
|
+
"limit": fetchLimit, "toolDetail": detail,
|
|
165
196
|
}, "")
|
|
166
197
|
if err != nil {
|
|
167
198
|
return nil, err
|
|
@@ -171,17 +202,6 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
171
202
|
// Some upstream ranked matches arrive out of order despite including a
|
|
172
203
|
// similarity score. Present the highest-scoring candidate first, retaining
|
|
173
204
|
// 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
205
|
// Ranked search has no native cursor. Expose the free, complete
|
|
186
206
|
// catalogue with matching semantic query as an optional expansion.
|
|
187
207
|
next = map[string]any{"tool": "research", "arguments": map[string]any{
|
|
@@ -189,6 +209,22 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
189
209
|
}, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
|
|
190
210
|
} else {
|
|
191
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
|
+
}
|
|
192
228
|
var catalogue map[string]any
|
|
193
229
|
catalogue, raw, err = freeDataBrowse(ctx, options)
|
|
194
230
|
if err != nil {
|
|
@@ -200,6 +236,48 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
200
236
|
next = continuationFromCatalogue(native)
|
|
201
237
|
}
|
|
202
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
|
+
}
|
|
203
281
|
results := make([]map[string]any, 0, len(items))
|
|
204
282
|
for _, value := range items {
|
|
205
283
|
entry := mapField(value)
|
|
@@ -223,6 +301,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
223
301
|
}
|
|
224
302
|
if cost, ok := entry["creditsCost"]; ok {
|
|
225
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
|
+
}
|
|
226
313
|
}
|
|
227
314
|
if cost, ok := entry["perRecord"]; ok {
|
|
228
315
|
result["per_record"] = cost
|
|
@@ -230,8 +317,14 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
230
317
|
if count, ok := entry["toolCount"]; ok {
|
|
231
318
|
result["operation_count"] = count
|
|
232
319
|
}
|
|
320
|
+
if input.Query != "" && view == "tools" {
|
|
321
|
+
result["relevance"] = intentRelevance(entry, input.Query)
|
|
322
|
+
}
|
|
233
323
|
if rank, ok := entry["similarity"]; ok {
|
|
234
|
-
result["
|
|
324
|
+
result["semantic_similarity"] = rank
|
|
325
|
+
if input.Query == "" {
|
|
326
|
+
result["relevance"] = rank
|
|
327
|
+
}
|
|
235
328
|
}
|
|
236
329
|
if len(expand) > 0 {
|
|
237
330
|
if selected := entry["options"]; selected != nil && containsString(expand, "options") {
|
|
@@ -243,8 +336,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
243
336
|
if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
|
|
244
337
|
result["examples"] = selected
|
|
245
338
|
}
|
|
339
|
+
if containsString(expand, "examples") && result["examples"] == nil {
|
|
340
|
+
if entry["options"] != nil {
|
|
341
|
+
result["examples"] = contractExamples(entry)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
246
344
|
}
|
|
247
|
-
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 {
|
|
248
348
|
result["next"] = continuationFromCatalogue(continuation)
|
|
249
349
|
} else if id != "" {
|
|
250
350
|
result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
|
|
@@ -256,6 +356,12 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
256
356
|
"count": len(results), "cost_credits": 0,
|
|
257
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.",
|
|
258
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
|
+
}
|
|
259
365
|
if total != nil {
|
|
260
366
|
output["total"] = total
|
|
261
367
|
}
|
|
@@ -268,6 +374,55 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
268
374
|
return output, nil
|
|
269
375
|
}
|
|
270
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
|
+
|
|
271
426
|
func niceDataView(view string) string {
|
|
272
427
|
switch view {
|
|
273
428
|
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
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/url"
|
|
11
|
+
"os"
|
|
12
|
+
"sort"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"golang.org/x/sync/singleflight"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// MapSiteInput retrieves an agent-sized page rather than dumping thousands
|
|
21
|
+
// of URLs. An omitted language prefers English; "all" allows all locales.
|
|
22
|
+
type MapSiteInput struct {
|
|
23
|
+
URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
|
|
24
|
+
Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
|
|
25
|
+
Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
|
|
26
|
+
Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
|
|
27
|
+
PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
|
|
28
|
+
Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type siteMapCacheValue struct {
|
|
32
|
+
links []any
|
|
33
|
+
expires time.Time
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var siteMapCache = struct {
|
|
37
|
+
sync.Mutex
|
|
38
|
+
items map[string]siteMapCacheValue
|
|
39
|
+
}{items: map[string]siteMapCacheValue{}}
|
|
40
|
+
var siteMapFlight singleflight.Group
|
|
41
|
+
|
|
42
|
+
func rawMappedLinks(ctx context.Context, rawURL string) ([]any, error) {
|
|
43
|
+
if err := validateSourceURL(rawURL); err != nil {
|
|
44
|
+
return nil, &DataError{Code: "invalid_url", Message: err.Error()}
|
|
45
|
+
}
|
|
46
|
+
credential := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
|
|
47
|
+
if credential == "" {
|
|
48
|
+
return nil, missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
|
|
49
|
+
}
|
|
50
|
+
digest := sha256.Sum256([]byte(credential + "\x00" + rawURL))
|
|
51
|
+
cacheKey := hex.EncodeToString(digest[:])
|
|
52
|
+
useCache := !strings.HasPrefix(credential, "test")
|
|
53
|
+
if useCache {
|
|
54
|
+
siteMapCache.Lock()
|
|
55
|
+
cached, ok := siteMapCache.items[cacheKey]
|
|
56
|
+
siteMapCache.Unlock()
|
|
57
|
+
if ok && time.Now().Before(cached.expires) {
|
|
58
|
+
return cached.links, nil
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
fetch := func() (any, error) {
|
|
62
|
+
request := map[string]any{
|
|
63
|
+
"url": rawURL, "sitemap": "include",
|
|
64
|
+
"includeSubdomains": true, "ignoreQueryParameters": true, "limit": 5000,
|
|
65
|
+
}
|
|
66
|
+
body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
|
|
67
|
+
"Authorization": "Bearer " + credential, "Content-Type": "application/json",
|
|
68
|
+
}, request)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, fmt.Errorf("Error mapping website: %w", err)
|
|
71
|
+
}
|
|
72
|
+
var result map[string]any
|
|
73
|
+
if err := json.Unmarshal(body, &result); err != nil {
|
|
74
|
+
return nil, fmt.Errorf("Error decoding map: %w", err)
|
|
75
|
+
}
|
|
76
|
+
if result["success"] != true {
|
|
77
|
+
return nil, dataError("map_failed", fmt.Sprintf("The source could not be mapped: %v", result["error"]))
|
|
78
|
+
}
|
|
79
|
+
links := listField(result["links"])
|
|
80
|
+
if useCache {
|
|
81
|
+
siteMapCache.Lock()
|
|
82
|
+
if len(siteMapCache.items) > 50 {
|
|
83
|
+
siteMapCache.items = map[string]siteMapCacheValue{}
|
|
84
|
+
}
|
|
85
|
+
siteMapCache.items[cacheKey] = siteMapCacheValue{links: links, expires: time.Now().Add(3 * time.Minute)}
|
|
86
|
+
siteMapCache.Unlock()
|
|
87
|
+
}
|
|
88
|
+
return links, nil
|
|
89
|
+
}
|
|
90
|
+
if !useCache {
|
|
91
|
+
data, err := fetch()
|
|
92
|
+
if err != nil {
|
|
93
|
+
return nil, err
|
|
94
|
+
}
|
|
95
|
+
return data.([]any), nil
|
|
96
|
+
}
|
|
97
|
+
data, err, _ := siteMapFlight.Do(cacheKey, fetch)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
return data.([]any), nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var knownSiteLocales = map[string]bool{
|
|
105
|
+
"en": true, "fr": true, "es": true, "de": true, "ja": true, "zh": true, "zh-cn": true, "zh-tw": true,
|
|
106
|
+
"pt": true, "pt-br": true, "it": true, "ko": true, "ru": true, "ar": true, "hi": true, "id": true,
|
|
107
|
+
"nl": true, "pl": true, "tr": true, "vi": true, "th": true, "sv": true, "da": true,
|
|
108
|
+
"es-es": true, "en-us": true, "en-gb": true, "fr-fr": true, "fr-ca": true,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func mapPageLocale(raw string) string {
|
|
112
|
+
u, err := url.Parse(raw)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return "en"
|
|
115
|
+
}
|
|
116
|
+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
117
|
+
if len(parts) > 0 {
|
|
118
|
+
first := strings.ToLower(parts[0])
|
|
119
|
+
if knownSiteLocales[first] {
|
|
120
|
+
if strings.HasPrefix(first, "en") {
|
|
121
|
+
return "en"
|
|
122
|
+
}
|
|
123
|
+
if strings.HasPrefix(first, "zh") {
|
|
124
|
+
return "zh"
|
|
125
|
+
}
|
|
126
|
+
if strings.HasPrefix(first, "es") {
|
|
127
|
+
return "es"
|
|
128
|
+
}
|
|
129
|
+
if strings.HasPrefix(first, "fr") {
|
|
130
|
+
return "fr"
|
|
131
|
+
}
|
|
132
|
+
if strings.HasPrefix(first, "pt") {
|
|
133
|
+
return "pt"
|
|
134
|
+
}
|
|
135
|
+
return first
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return "en"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func MapSitePage(input MapSiteInput) (map[string]any, error) {
|
|
142
|
+
if input.Limit == 0 {
|
|
143
|
+
input.Limit = 30
|
|
144
|
+
}
|
|
145
|
+
if input.Limit < 1 || input.Limit > 100 {
|
|
146
|
+
return nil, dataError("invalid_limit", "map_site limit must be 1-100.")
|
|
147
|
+
}
|
|
148
|
+
if input.Offset < 0 || input.Offset > 100000 {
|
|
149
|
+
return nil, dataError("invalid_offset", "map_site offset must be nonnegative.")
|
|
150
|
+
}
|
|
151
|
+
if input.Language == "" {
|
|
152
|
+
input.Language = "en"
|
|
153
|
+
}
|
|
154
|
+
input.Language = strings.ToLower(strings.TrimSpace(input.Language))
|
|
155
|
+
if input.PathPrefix != "" && !strings.HasPrefix(input.PathPrefix, "/") {
|
|
156
|
+
input.PathPrefix = "/" + input.PathPrefix
|
|
157
|
+
}
|
|
158
|
+
if err := validateSourceURL(input.URL); err != nil {
|
|
159
|
+
return nil, &DataError{Code: "invalid_url", Message: err.Error()}
|
|
160
|
+
}
|
|
161
|
+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
162
|
+
defer cancel()
|
|
163
|
+
links, err := rawMappedLinks(ctx, input.URL)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return nil, err
|
|
166
|
+
}
|
|
167
|
+
type located struct {
|
|
168
|
+
entry map[string]any
|
|
169
|
+
score int
|
|
170
|
+
path string
|
|
171
|
+
}
|
|
172
|
+
var matched []located
|
|
173
|
+
seen := map[string]bool{}
|
|
174
|
+
terms := queryTerms(input.Query)
|
|
175
|
+
for _, v := range links {
|
|
176
|
+
item := map[string]any{}
|
|
177
|
+
if s, ok := v.(string); ok {
|
|
178
|
+
item["url"] = s
|
|
179
|
+
} else {
|
|
180
|
+
item = mapField(v)
|
|
181
|
+
}
|
|
182
|
+
raw := stringField(item["url"])
|
|
183
|
+
if raw == "" || seen[raw] {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
seen[raw] = true
|
|
187
|
+
u, e := url.Parse(raw)
|
|
188
|
+
if e != nil || u.Host == "" {
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if input.Language != "all" && mapPageLocale(raw) != input.Language {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if input.PathPrefix != "" && !strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(input.PathPrefix)) {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
name := stringField(item["title"])
|
|
198
|
+
desc := stringField(item["description"])
|
|
199
|
+
score := termScore(u.Path+" "+name, terms)*3 + termScore(desc, terms)
|
|
200
|
+
if len(terms) > 0 && score == 0 {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
clean := map[string]any{"url": raw}
|
|
204
|
+
if name != "" {
|
|
205
|
+
clean["title"] = boundedSiteText(name, 140)
|
|
206
|
+
}
|
|
207
|
+
if desc != "" {
|
|
208
|
+
clean["description"] = boundedSiteText(desc, 230)
|
|
209
|
+
}
|
|
210
|
+
matched = append(matched, located{entry: clean, score: score, path: u.Path})
|
|
211
|
+
}
|
|
212
|
+
sort.SliceStable(matched, func(i, j int) bool {
|
|
213
|
+
if len(terms) > 0 && matched[i].score != matched[j].score {
|
|
214
|
+
return matched[i].score > matched[j].score
|
|
215
|
+
}
|
|
216
|
+
if depthI, depthJ := strings.Count(matched[i].path, "/"), strings.Count(matched[j].path, "/"); depthI != depthJ {
|
|
217
|
+
return depthI < depthJ
|
|
218
|
+
}
|
|
219
|
+
return matched[i].path < matched[j].path
|
|
220
|
+
})
|
|
221
|
+
start := input.Offset
|
|
222
|
+
if start > len(matched) {
|
|
223
|
+
start = len(matched)
|
|
224
|
+
}
|
|
225
|
+
end := start + input.Limit
|
|
226
|
+
if end > len(matched) {
|
|
227
|
+
end = len(matched)
|
|
228
|
+
}
|
|
229
|
+
page := make([]map[string]any, 0, end-start)
|
|
230
|
+
for _, entry := range matched[start:end] {
|
|
231
|
+
page = append(page, entry.entry)
|
|
232
|
+
}
|
|
233
|
+
output := map[string]any{
|
|
234
|
+
"url": input.URL, "total_discovered": len(links),
|
|
235
|
+
"total_matching": len(matched), "links": page,
|
|
236
|
+
"count": len(page), "offset": start, "limit": input.Limit,
|
|
237
|
+
"language": input.Language, "provider_page_ceiling": 5000,
|
|
238
|
+
"may_be_incomplete": len(links) >= 5000,
|
|
239
|
+
"guidance": "Use next to request another bounded page, or set query/path_prefix/language to narrow results. Language=all includes translated pages.",
|
|
240
|
+
}
|
|
241
|
+
if end < len(matched) {
|
|
242
|
+
output["next"] = map[string]any{"tool": "map_site", "arguments": MapSiteInput{
|
|
243
|
+
URL: input.URL, Query: input.Query, Limit: input.Limit, Offset: end,
|
|
244
|
+
PathPrefix: input.PathPrefix, Language: input.Language,
|
|
245
|
+
}}
|
|
246
|
+
}
|
|
247
|
+
return output, nil
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func boundedSiteText(value string, max int) string {
|
|
251
|
+
value = strings.Join(strings.Fields(value), " ")
|
|
252
|
+
if len(value) > max {
|
|
253
|
+
return value[:max] + "…"
|
|
254
|
+
}
|
|
255
|
+
return value
|
|
256
|
+
}
|