webctx 0.2.3 → 0.2.5
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/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +137 -0
- package/internal/app/data_client.go +220 -0
- package/internal/app/data_execute.go +424 -0
- package/internal/app/data_inspect.go +176 -0
- package/internal/app/data_research.go +323 -0
- package/internal/app/data_tools_test.go +336 -0
- package/internal/app/data_types.go +40 -0
- package/internal/app/firecrawl_fallback.go +4 -14
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +122 -33
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_test.go +4 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +10 -0
- package/internal/app/tools.go +16 -7
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +46 -0
- package/pkg/origo/server_test.go +11 -6
- package/pkg/retrieval/data.go +13 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"math"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func requiredFields(contract map[string]any) []string {
|
|
14
|
+
var fields []string
|
|
15
|
+
for _, v := range listField(contract["options"]) {
|
|
16
|
+
f := mapField(v)
|
|
17
|
+
if yes, _ := f["required"].(bool); yes {
|
|
18
|
+
fields = append(fields, stringField(f["name"]))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return fields
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func validateDataInputs(contract map[string]any, inputs map[string]any) error {
|
|
25
|
+
if inputs == nil {
|
|
26
|
+
inputs = map[string]any{}
|
|
27
|
+
}
|
|
28
|
+
id := stringField(contract["id"])
|
|
29
|
+
fields := map[string]map[string]any{}
|
|
30
|
+
for _, v := range listField(contract["options"]) {
|
|
31
|
+
f := mapField(v)
|
|
32
|
+
if name := stringField(f["name"]); name != "" {
|
|
33
|
+
fields[name] = f
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
var issues []string
|
|
37
|
+
for _, name := range requiredFields(contract) {
|
|
38
|
+
if inputs[name] == nil || inputs[name] == "" {
|
|
39
|
+
issues = append(issues, fmt.Sprintf("required field %s is missing", name))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for _, group := range listField(contract["requiresOneOf"]) {
|
|
43
|
+
found := false
|
|
44
|
+
var alternatives []string
|
|
45
|
+
for _, v := range listField(group) {
|
|
46
|
+
field := stringField(v)
|
|
47
|
+
if field == "" {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
alternatives = append(alternatives, field)
|
|
51
|
+
if inputs[field] != nil && inputs[field] != "" {
|
|
52
|
+
found = true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if !found && len(alternatives) > 0 {
|
|
56
|
+
issues = append(issues, "provide at least one of: "+strings.Join(alternatives, ", "))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for name, value := range inputs {
|
|
60
|
+
field, ok := fields[name]
|
|
61
|
+
if !ok {
|
|
62
|
+
issues = append(issues, fmt.Sprintf("unknown field %q", name))
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if value == nil {
|
|
66
|
+
issues = append(issues, fmt.Sprintf("field %q must be filled in", name))
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if !dataTypeMatches(value, stringField(field["type"])) {
|
|
70
|
+
issues = append(issues, fmt.Sprintf("field %q must be %s", name, stringField(field["type"])))
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if enum := listField(field["oneOf"]); len(enum) > 0 {
|
|
74
|
+
found := false
|
|
75
|
+
for _, allowed := range enum {
|
|
76
|
+
a, _ := json.Marshal(allowed)
|
|
77
|
+
b, _ := json.Marshal(value)
|
|
78
|
+
if string(a) == string(b) {
|
|
79
|
+
found = true
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
// Some upstream contracts declare a numeric type but encode
|
|
83
|
+
// its enum entries as strings. Accept the equivalent number.
|
|
84
|
+
if actual, ok := numberField(value); ok {
|
|
85
|
+
if candidate, ok := allowed.(string); ok {
|
|
86
|
+
if parsed, err := strconv.ParseFloat(candidate, 64); err == nil && parsed == actual {
|
|
87
|
+
found = true
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if !found {
|
|
94
|
+
issues = append(issues, fmt.Sprintf("field %q must be one of the values in its inspected oneOf list", name))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if number, ok := numberField(value); ok {
|
|
98
|
+
if lower, ok := numberField(field["min"]); ok && number < lower {
|
|
99
|
+
issues = append(issues, fmt.Sprintf("field %q must be >= %v", name, lower))
|
|
100
|
+
}
|
|
101
|
+
if upper, ok := numberField(field["max"]); ok && number > upper {
|
|
102
|
+
issues = append(issues, fmt.Sprintf("field %q must be <= %v", name, upper))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if pattern := stringField(field["pattern"]); pattern != "" {
|
|
106
|
+
if re, err := regexp.Compile(pattern); err == nil && re != nil {
|
|
107
|
+
if s, ok := value.(string); ok && !re.MatchString(s) {
|
|
108
|
+
issues = append(issues, fmt.Sprintf("field %q does not match its required pattern", name))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if len(issues) > 0 {
|
|
114
|
+
names := make([]string, 0, len(fields))
|
|
115
|
+
for name := range fields {
|
|
116
|
+
names = append(names, name)
|
|
117
|
+
}
|
|
118
|
+
sortStrings(names)
|
|
119
|
+
return &DataError{
|
|
120
|
+
Code: "invalid_inputs",
|
|
121
|
+
Message: "Input validation failed for " + id + ": " + strings.Join(issues, "; ") + ".",
|
|
122
|
+
Details: map[string]any{"available_inputs": names, "next": map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func dataTypeMatches(value any, kind string) bool {
|
|
129
|
+
switch strings.ToLower(kind) {
|
|
130
|
+
case "", "any", "json":
|
|
131
|
+
return true
|
|
132
|
+
case "string":
|
|
133
|
+
_, ok := value.(string)
|
|
134
|
+
return ok
|
|
135
|
+
case "number":
|
|
136
|
+
_, ok := numberField(value)
|
|
137
|
+
return ok
|
|
138
|
+
case "integer", "int":
|
|
139
|
+
f, ok := numberField(value)
|
|
140
|
+
return ok && !math.IsInf(f, 0) && f == math.Trunc(f)
|
|
141
|
+
case "boolean", "bool":
|
|
142
|
+
_, ok := value.(bool)
|
|
143
|
+
return ok
|
|
144
|
+
case "object":
|
|
145
|
+
_, ok := value.(map[string]any)
|
|
146
|
+
return ok
|
|
147
|
+
case "array":
|
|
148
|
+
return listField(value) != nil
|
|
149
|
+
case "string[]":
|
|
150
|
+
list := listField(value)
|
|
151
|
+
if list == nil {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
for _, v := range list {
|
|
155
|
+
if _, ok := v.(string); !ok {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return true
|
|
160
|
+
case "number[]":
|
|
161
|
+
list := listField(value)
|
|
162
|
+
if list == nil {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
for _, v := range list {
|
|
166
|
+
if _, ok := numberField(v); !ok {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true
|
|
171
|
+
default:
|
|
172
|
+
return true // Other upstream-specific types are validated by Firecrawl.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, error) {
|
|
177
|
+
cost, valid := numberField(contract["creditsCost"])
|
|
178
|
+
if !valid || cost < 0 {
|
|
179
|
+
return 0, dataError("price_unavailable", "The data source did not supply a usable credit price.")
|
|
180
|
+
}
|
|
181
|
+
if cost == 0 {
|
|
182
|
+
return 0, nil
|
|
183
|
+
}
|
|
184
|
+
estimated := cost
|
|
185
|
+
if perRecord, _ := contract["perRecord"].(bool); perRecord {
|
|
186
|
+
// A per-record price has no hard upper bound without a record limit.
|
|
187
|
+
count := 0.0
|
|
188
|
+
for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
|
|
189
|
+
if n, ok := numberField(inputs[key]); ok && n >= 1 {
|
|
190
|
+
count = n
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if count == 0 {
|
|
195
|
+
for _, v := range listField(contract["options"]) {
|
|
196
|
+
f := mapField(v)
|
|
197
|
+
for _, key := range []string{"limit", "per_page", "page_size", "max_results", "count"} {
|
|
198
|
+
if stringField(f["name"]) == key {
|
|
199
|
+
if n, ok := numberField(f["default"]); ok && n >= 1 {
|
|
200
|
+
count = n
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if count == 0 {
|
|
207
|
+
return 0, dataError("unbounded_per_record_cost",
|
|
208
|
+
"This operation charges per record but exposes no bounded record count. Inspect it and supply an explicit limit before execution.")
|
|
209
|
+
}
|
|
210
|
+
estimated = cost * count
|
|
211
|
+
}
|
|
212
|
+
if estimated > 1000000 {
|
|
213
|
+
return 0, dataError("price_exceeds_limit", "Estimated cost is too large. Reduce the requested record limit.")
|
|
214
|
+
}
|
|
215
|
+
return int(math.Ceil(estimated)), nil
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func continuationForDataResult(id string, originalInputs map[string]any, data any, maxCredits int) map[string]any {
|
|
219
|
+
value := mapField(data)
|
|
220
|
+
if value == nil {
|
|
221
|
+
return nil
|
|
222
|
+
}
|
|
223
|
+
if next := mapField(value["next"]); next != nil {
|
|
224
|
+
if provider := stringField(next["provider"]); provider != "" {
|
|
225
|
+
if capability := stringField(next["capability"]); capability != "" {
|
|
226
|
+
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
227
|
+
"calls": []any{map[string]any{"id": provider + "/" + capability, "inputs": mapField(next["options"])}},
|
|
228
|
+
"max_credits": maxCredits,
|
|
229
|
+
}}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
cursor := stringField(value["next_cursor"])
|
|
234
|
+
if cursor == "" {
|
|
235
|
+
cursor = stringField(value["nextCursor"])
|
|
236
|
+
}
|
|
237
|
+
if cursor == "" {
|
|
238
|
+
cursor = stringField(mapField(value["pagination"])["next_cursor"])
|
|
239
|
+
}
|
|
240
|
+
if cursor != "" {
|
|
241
|
+
next := copyInputs(originalInputs)
|
|
242
|
+
if _, exists := next["page_token"]; exists {
|
|
243
|
+
next["page_token"] = cursor
|
|
244
|
+
} else {
|
|
245
|
+
next["cursor"] = cursor
|
|
246
|
+
}
|
|
247
|
+
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
248
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
249
|
+
"max_credits": maxCredits,
|
|
250
|
+
}}
|
|
251
|
+
}
|
|
252
|
+
if page, ok := numberField(value["next_page"]); ok && page > 0 {
|
|
253
|
+
next := copyInputs(originalInputs)
|
|
254
|
+
next["page"] = int(page)
|
|
255
|
+
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
256
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
257
|
+
"max_credits": maxCredits,
|
|
258
|
+
}}
|
|
259
|
+
}
|
|
260
|
+
if offset, ok := numberField(value["next_offset"]); ok && offset >= 0 {
|
|
261
|
+
next := copyInputs(originalInputs)
|
|
262
|
+
previous, _ := numberField(next["offset"])
|
|
263
|
+
if offset > previous {
|
|
264
|
+
next["offset"] = int(offset)
|
|
265
|
+
return map[string]any{"tool": "execute", "arguments": map[string]any{
|
|
266
|
+
"calls": []any{map[string]any{"id": id, "inputs": next}},
|
|
267
|
+
"max_credits": maxCredits,
|
|
268
|
+
}}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return nil
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func copyInputs(original map[string]any) map[string]any {
|
|
275
|
+
out := make(map[string]any, len(original))
|
|
276
|
+
for k, v := range original {
|
|
277
|
+
out[k] = v
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ExecuteData validates contracts and credit estimates before calling any
|
|
283
|
+
// paid provider. The native API accepts at most ten executions per request.
|
|
284
|
+
func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
285
|
+
if len(input.Calls) == 0 || len(input.Calls) > 10 {
|
|
286
|
+
return nil, dataError("invalid_calls", "Provide 1-10 operation calls with id and inputs.")
|
|
287
|
+
}
|
|
288
|
+
if !paidDataEnabled() {
|
|
289
|
+
return nil, dataError("paid_data_disabled", "Paid data queries are disabled by WEBCTX_ALEXANDRIA_PAID=off.")
|
|
290
|
+
}
|
|
291
|
+
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
|
+
if input.RequestID != "" && !safeRequestID.MatchString(input.RequestID) {
|
|
299
|
+
return nil, dataError("invalid_request_id", "request_id must have 8-128 letters, digits, hyphens or underscores.")
|
|
300
|
+
}
|
|
301
|
+
ctx, cancel := defaultDataContext()
|
|
302
|
+
defer cancel()
|
|
303
|
+
type reviewed struct {
|
|
304
|
+
Contract map[string]any
|
|
305
|
+
Err error
|
|
306
|
+
}
|
|
307
|
+
checked := make([]reviewed, len(input.Calls))
|
|
308
|
+
var wg sync.WaitGroup
|
|
309
|
+
for i, call := range input.Calls {
|
|
310
|
+
if _, _, err := parseOperationID(call.ID); err != nil {
|
|
311
|
+
return nil, err
|
|
312
|
+
}
|
|
313
|
+
wg.Add(1)
|
|
314
|
+
go func(i int, call DataCall) {
|
|
315
|
+
defer wg.Done()
|
|
316
|
+
contract, _, err := exactDataContract(ctx, call.ID, []string{"options", "response", "examples"})
|
|
317
|
+
checked[i] = reviewed{Contract: contract, Err: err}
|
|
318
|
+
}(i, call)
|
|
319
|
+
}
|
|
320
|
+
wg.Wait()
|
|
321
|
+
var estimated int
|
|
322
|
+
var calls []map[string]any
|
|
323
|
+
for i, call := range input.Calls {
|
|
324
|
+
if checked[i].Err != nil {
|
|
325
|
+
return nil, checked[i].Err
|
|
326
|
+
}
|
|
327
|
+
contract := checked[i].Contract
|
|
328
|
+
if err := validateDataInputs(contract, call.Inputs); err != nil {
|
|
329
|
+
return nil, err
|
|
330
|
+
}
|
|
331
|
+
cost, err := expectedCallCredits(contract, call.Inputs)
|
|
332
|
+
if err != nil {
|
|
333
|
+
return nil, err
|
|
334
|
+
}
|
|
335
|
+
estimated += cost
|
|
336
|
+
if estimated > cap {
|
|
337
|
+
return nil, &DataError{Code: "credit_budget_exceeded",
|
|
338
|
+
Message: fmt.Sprintf("Preflight estimate %d credits exceeds the %d-credit request cap. Reduce calls/record limits, or configure WEBCTX_ALEXANDRIA_MAX_CREDITS.", estimated, cap),
|
|
339
|
+
Details: map[string]any{"estimated_credits": estimated, "max_credits": cap},
|
|
340
|
+
}
|
|
341
|
+
}
|
|
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
|
+
}
|
|
349
|
+
requestID := input.RequestID
|
|
350
|
+
if requestID == "" {
|
|
351
|
+
var err error
|
|
352
|
+
requestID, err = newRequestID()
|
|
353
|
+
if err != nil {
|
|
354
|
+
return nil, dataError("internal_error", "Could not generate a request identifier.")
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
response, err := doDataAPI(ctx, "/scrape", map[string]any{"alexandria": calls}, requestID)
|
|
358
|
+
if err != nil {
|
|
359
|
+
var typed *DataError
|
|
360
|
+
if errorsAsData(err, &typed) {
|
|
361
|
+
typed.Details = map[string]any{"request_id": requestID,
|
|
362
|
+
"retry": map[string]any{"tool": "execute", "arguments": ExecuteDataInput{
|
|
363
|
+
Calls: input.Calls, MaxCredits: cap, RequestID: requestID, Raw: input.Raw,
|
|
364
|
+
}},
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return nil, err
|
|
368
|
+
}
|
|
369
|
+
payload := mapField(response["data"])
|
|
370
|
+
nativeResults := listField(payload["alexandria"])
|
|
371
|
+
if len(nativeResults) == 0 {
|
|
372
|
+
return nil, dataError("invalid_upstream_response", "Paid request returned no Alexandria execution results.")
|
|
373
|
+
}
|
|
374
|
+
results := make([]map[string]any, 0, len(nativeResults))
|
|
375
|
+
for i, value := range nativeResults {
|
|
376
|
+
entry := mapField(value)
|
|
377
|
+
if entry == nil {
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
id := stringField(entry["provider"]) + "/" + stringField(entry["capability"])
|
|
381
|
+
item := map[string]any{
|
|
382
|
+
"id": id,
|
|
383
|
+
"source_id": entry["provider"],
|
|
384
|
+
"data": entry["data"],
|
|
385
|
+
"credits_used": entry["creditsCost"],
|
|
386
|
+
}
|
|
387
|
+
if token := entry["alexandriaId"]; token != nil {
|
|
388
|
+
item["data_id"] = token
|
|
389
|
+
}
|
|
390
|
+
if errValue := entry["error"]; errValue != nil {
|
|
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
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
results = append(results, item)
|
|
399
|
+
}
|
|
400
|
+
output := map[string]any{
|
|
401
|
+
"results": results,
|
|
402
|
+
"request_id": requestID,
|
|
403
|
+
"estimated_credits": estimated,
|
|
404
|
+
"pricing_note": "max_credits is a preflight estimate; actual per-record charges are determined by the upstream provider and the account's Firecrawl limits.",
|
|
405
|
+
"guidance": "Use next to fetch additional pages where available. Reuse request_id when retrying the same paid request, but generate a new ID for the next page.",
|
|
406
|
+
}
|
|
407
|
+
if cost := payload["creditsCost"]; cost != nil {
|
|
408
|
+
output["credits_used"] = cost
|
|
409
|
+
} else if cost := response["creditsUsed"]; cost != nil {
|
|
410
|
+
output["credits_used"] = cost
|
|
411
|
+
}
|
|
412
|
+
if input.Raw {
|
|
413
|
+
output["raw"] = response
|
|
414
|
+
}
|
|
415
|
+
return output, nil
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
func errorsAsData(err error, into **DataError) bool {
|
|
419
|
+
e, ok := err.(*DataError)
|
|
420
|
+
if ok {
|
|
421
|
+
*into = e
|
|
422
|
+
}
|
|
423
|
+
return ok
|
|
424
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"regexp"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
var dataSourceIDPattern = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$")
|
|
11
|
+
var dataCapabilityPattern = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9_./-]{0,249}$")
|
|
12
|
+
|
|
13
|
+
func parseOperationID(id string) (string, string, error) {
|
|
14
|
+
provider, capability, ok := strings.Cut(strings.TrimSpace(id), "/")
|
|
15
|
+
if !ok || !dataSourceIDPattern.MatchString(provider) ||
|
|
16
|
+
!dataCapabilityPattern.MatchString(capability) || strings.Contains(capability, "..") ||
|
|
17
|
+
strings.Contains(capability, "//") {
|
|
18
|
+
return "", "", dataError("invalid_operation", "Use an exact operation ID from research, such as particle/podcasts/episodes/search.")
|
|
19
|
+
}
|
|
20
|
+
return provider, capability, nil
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func exactDataContract(ctx context.Context, id string, expanded []string) (map[string]any, map[string]any, error) {
|
|
24
|
+
provider, capability, err := parseOperationID(id)
|
|
25
|
+
if err != nil {
|
|
26
|
+
return nil, nil, err
|
|
27
|
+
}
|
|
28
|
+
opts := map[string]any{
|
|
29
|
+
"providers": []string{provider},
|
|
30
|
+
"capabilities": []string{capability},
|
|
31
|
+
"level": "tools", "expand": expanded, "limit": 10,
|
|
32
|
+
}
|
|
33
|
+
catalogue, raw, err := freeDataBrowse(ctx, opts)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return nil, nil, err
|
|
36
|
+
}
|
|
37
|
+
for _, entry := range listField(catalogue["items"]) {
|
|
38
|
+
item := mapField(entry)
|
|
39
|
+
if item == nil {
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
actual := stringField(item["id"])
|
|
43
|
+
if actual == "" {
|
|
44
|
+
actual = stringField(item["provider"]) + "/" + stringField(item["capability"])
|
|
45
|
+
}
|
|
46
|
+
if actual == id {
|
|
47
|
+
return item, raw, nil
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return nil, nil, &DataError{
|
|
51
|
+
Code: "operation_not_found",
|
|
52
|
+
Message: fmt.Sprintf("Operation %q was not found. Use research to discover currently available sources and operations.", id),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func nativeInputTemplate(contract map[string]any) (map[string]any, []string) {
|
|
57
|
+
template := map[string]any{}
|
|
58
|
+
var need []string
|
|
59
|
+
seen := map[string]bool{}
|
|
60
|
+
for _, field := range listField(contract["options"]) {
|
|
61
|
+
option := mapField(field)
|
|
62
|
+
name := stringField(option["name"])
|
|
63
|
+
if name == "" {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if value, exists := option["default"]; exists {
|
|
67
|
+
template[name] = value
|
|
68
|
+
}
|
|
69
|
+
if required, _ := option["required"].(bool); required {
|
|
70
|
+
template[name] = nil
|
|
71
|
+
need = append(need, name)
|
|
72
|
+
seen[name] = true
|
|
73
|
+
}
|
|
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
|
+
return template, need
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func InspectData(input InspectDataInput) (map[string]any, error) {
|
|
106
|
+
id := strings.TrimSpace(input.ID)
|
|
107
|
+
if id == "" {
|
|
108
|
+
return nil, dataError("missing_id", "Provide an operation or source ID returned by research.")
|
|
109
|
+
}
|
|
110
|
+
expanded := []string{"options", "response", "examples"}
|
|
111
|
+
if len(input.Include) > 0 {
|
|
112
|
+
var err error
|
|
113
|
+
expanded, err = expandContract(input.Include)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return nil, err
|
|
116
|
+
}
|
|
117
|
+
// A useful execute template always requires the real input contract,
|
|
118
|
+
// even if the caller only asked to expand examples or output details.
|
|
119
|
+
if !containsString(expanded, "options") {
|
|
120
|
+
expanded = append(expanded, "options")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if !strings.Contains(id, "/") {
|
|
124
|
+
if !dataSourceIDPattern.MatchString(id) {
|
|
125
|
+
return nil, dataError("invalid_source", "Use an exact source ID returned by research.")
|
|
126
|
+
}
|
|
127
|
+
include := input.Include
|
|
128
|
+
if len(include) == 0 {
|
|
129
|
+
include = []string{"inputs", "output", "examples"}
|
|
130
|
+
}
|
|
131
|
+
list, err := ResearchData(ResearchDataInput{Sources: []string{id}, View: "operations", Include: include, Limit: 25, Raw: input.Raw})
|
|
132
|
+
if err != nil {
|
|
133
|
+
return nil, err
|
|
134
|
+
}
|
|
135
|
+
list["source_id"] = id
|
|
136
|
+
list["guidance"] = "Choose an operation ID and call inspect again for its exact contract and an execute template."
|
|
137
|
+
return list, nil
|
|
138
|
+
}
|
|
139
|
+
ctx, cancel := defaultDataContext()
|
|
140
|
+
defer cancel()
|
|
141
|
+
item, raw, err := exactDataContract(ctx, id, expanded)
|
|
142
|
+
if err != nil {
|
|
143
|
+
return nil, err
|
|
144
|
+
}
|
|
145
|
+
provider, _, _ := parseOperationID(id)
|
|
146
|
+
inputs, missing := nativeInputTemplate(item)
|
|
147
|
+
budget := dataCreditCap()
|
|
148
|
+
execArguments := map[string]any{
|
|
149
|
+
"calls": []any{map[string]any{"id": id, "inputs": inputs}},
|
|
150
|
+
"max_credits": budget,
|
|
151
|
+
}
|
|
152
|
+
response := map[string]any{
|
|
153
|
+
"id": id,
|
|
154
|
+
"name": item["name"],
|
|
155
|
+
"source_id": provider,
|
|
156
|
+
"description": item["description"],
|
|
157
|
+
"cost_credits": 0,
|
|
158
|
+
"price": map[string]any{
|
|
159
|
+
"credits_per_call": item["creditsCost"],
|
|
160
|
+
"per_record": item["perRecord"],
|
|
161
|
+
},
|
|
162
|
+
"inputs": map[string]any{"fields": item["options"], "requires_one_of": item["requiresOneOf"]},
|
|
163
|
+
"output": item["response"],
|
|
164
|
+
"examples": item["examples"],
|
|
165
|
+
"input_template": inputs,
|
|
166
|
+
"next": map[string]any{"tool": "execute", "arguments": execArguments},
|
|
167
|
+
"guidance": "Copy next.arguments into execute, fill any blank required inputs and adjust values according to inputs.fields. Execute uses credits.",
|
|
168
|
+
}
|
|
169
|
+
if len(missing) > 0 {
|
|
170
|
+
response["fill_before_execution"] = missing
|
|
171
|
+
}
|
|
172
|
+
if input.Raw {
|
|
173
|
+
response["raw"] = raw
|
|
174
|
+
}
|
|
175
|
+
return response, nil
|
|
176
|
+
}
|