webctx 0.2.4 → 0.2.6

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.
@@ -0,0 +1,435 @@
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) 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
+ }}
229
+ }
230
+ }
231
+ }
232
+ cursor := stringField(value["next_cursor"])
233
+ if cursor == "" {
234
+ cursor = stringField(value["nextCursor"])
235
+ }
236
+ if cursor == "" {
237
+ cursor = stringField(mapField(value["pagination"])["next_cursor"])
238
+ }
239
+ if cursor != "" {
240
+ next := copyInputs(originalInputs)
241
+ if _, exists := next["page_token"]; exists {
242
+ next["page_token"] = cursor
243
+ } else {
244
+ next["cursor"] = cursor
245
+ }
246
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
247
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
248
+ }}
249
+ }
250
+ if page, ok := numberField(value["next_page"]); ok && page > 0 {
251
+ next := copyInputs(originalInputs)
252
+ next["page"] = int(page)
253
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
254
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
255
+ }}
256
+ }
257
+ if offset, ok := numberField(value["next_offset"]); ok && offset >= 0 {
258
+ next := copyInputs(originalInputs)
259
+ previous, _ := numberField(next["offset"])
260
+ if offset > previous {
261
+ next["offset"] = int(offset)
262
+ return map[string]any{"tool": "execute", "arguments": map[string]any{
263
+ "calls": []any{map[string]any{"id": id, "inputs": next}},
264
+ }}
265
+ }
266
+ }
267
+ return nil
268
+ }
269
+
270
+ func copyInputs(original map[string]any) map[string]any {
271
+ out := make(map[string]any, len(original))
272
+ for k, v := range original {
273
+ out[k] = v
274
+ }
275
+ return out
276
+ }
277
+
278
+ // ExecuteData validates contracts and credit estimates before calling any
279
+ // paid provider. The native API accepts at most ten executions per request.
280
+ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
281
+ if len(input.Calls) == 0 || len(input.Calls) > 10 {
282
+ return nil, dataError("invalid_calls", "Provide 1-10 operation calls with id and inputs.")
283
+ }
284
+ cap := dataCreditCap()
285
+ if input.RequestID != "" && !safeRequestID.MatchString(input.RequestID) {
286
+ return nil, dataError("invalid_request_id", "request_id must have 8-128 letters, digits, hyphens or underscores.")
287
+ }
288
+ ctx, cancel := defaultDataContext()
289
+ defer cancel()
290
+ type reviewed struct {
291
+ Contract map[string]any
292
+ Err error
293
+ }
294
+ checked := make([]reviewed, len(input.Calls))
295
+ var wg sync.WaitGroup
296
+ for i, call := range input.Calls {
297
+ if _, _, err := parseOperationID(call.ID); err != nil {
298
+ return nil, err
299
+ }
300
+ wg.Add(1)
301
+ go func(i int, call DataCall) {
302
+ defer wg.Done()
303
+ contract, _, err := exactDataContract(ctx, call.ID, []string{"options", "response", "examples"})
304
+ checked[i] = reviewed{Contract: contract, Err: err}
305
+ }(i, call)
306
+ }
307
+ wg.Wait()
308
+ var estimated int
309
+ var calls []map[string]any
310
+ for i, call := range input.Calls {
311
+ if checked[i].Err != nil {
312
+ return nil, checked[i].Err
313
+ }
314
+ contract := checked[i].Contract
315
+ if err := validateDataInputs(contract, call.Inputs); err != nil {
316
+ return nil, err
317
+ }
318
+ cost, err := expectedCallCredits(contract, call.Inputs)
319
+ if err != nil {
320
+ return nil, err
321
+ }
322
+ estimated += cost
323
+ if estimated > cap {
324
+ return nil, &DataError{Code: "server_credit_limit_exceeded",
325
+ Message: fmt.Sprintf("This request is estimated at %d credits, above Origo's %d-credit server limit. Reduce the number of operations or requested records.", estimated, cap),
326
+ Details: map[string]any{"estimated_credits": estimated, "server_credit_limit": cap},
327
+ }
328
+ }
329
+ provider, capability, _ := parseOperationID(call.ID)
330
+ options := call.Inputs
331
+ if options == nil {
332
+ options = map[string]any{}
333
+ }
334
+ calls = append(calls, map[string]any{"provider": provider, "capability": capability, "options": options})
335
+ }
336
+ requestID := input.RequestID
337
+ if requestID == "" {
338
+ var err error
339
+ requestID, err = newRequestID()
340
+ if err != nil {
341
+ return nil, dataError("internal_error", "Could not generate a request identifier.")
342
+ }
343
+ }
344
+ response, err := doDataAPI(ctx, "/scrape", map[string]any{"alexandria": calls}, requestID)
345
+ if err != nil {
346
+ var typed *DataError
347
+ if errorsAsData(err, &typed) {
348
+ typed.Details = map[string]any{"request_id": requestID,
349
+ "retry": map[string]any{"tool": "execute", "arguments": ExecuteDataInput{
350
+ Calls: input.Calls, RequestID: requestID, Raw: input.Raw,
351
+ }},
352
+ }
353
+ }
354
+ return nil, err
355
+ }
356
+ payload := mapField(response["data"])
357
+ nativeResults := listField(payload["alexandria"])
358
+ if len(nativeResults) == 0 {
359
+ return nil, dataError("invalid_upstream_response", "Paid request returned no Alexandria execution results.")
360
+ }
361
+ results := make([]map[string]any, 0, len(nativeResults))
362
+ for i, value := range nativeResults {
363
+ entry := mapField(value)
364
+ if entry == nil {
365
+ continue
366
+ }
367
+ id := stringField(entry["provider"]) + "/" + stringField(entry["capability"])
368
+ item := map[string]any{
369
+ "id": id,
370
+ "source_id": entry["provider"],
371
+ "data": entry["data"],
372
+ "credits_used": entry["creditsCost"],
373
+ }
374
+ if explanation := dataMeasurementNote(entry["data"]); explanation != "" {
375
+ item["measurement_note"] = explanation
376
+ }
377
+ if token := entry["alexandriaId"]; token != nil {
378
+ item["data_id"] = token
379
+ }
380
+ if errValue := entry["error"]; errValue != nil {
381
+ item["error"] = errValue
382
+ }
383
+ if i < len(input.Calls) {
384
+ if next := continuationForDataResult(id, input.Calls[i].Inputs, entry["data"]); next != nil {
385
+ item["next"] = next
386
+ }
387
+ }
388
+ results = append(results, item)
389
+ }
390
+ output := map[string]any{
391
+ "results": results,
392
+ "request_id": requestID,
393
+ "estimated_credits": estimated,
394
+ "pricing_note": "Estimated credits are calculated before execution; the upstream provider reports actual charges. Origo enforces a server-managed per-request credit limit.",
395
+ "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.",
396
+ }
397
+ if cost := payload["creditsCost"]; cost != nil {
398
+ output["credits_used"] = cost
399
+ } else if cost := response["creditsUsed"]; cost != nil {
400
+ output["credits_used"] = cost
401
+ }
402
+ if input.Raw {
403
+ output["raw"] = response
404
+ }
405
+ return output, nil
406
+ }
407
+
408
+ func errorsAsData(err error, into **DataError) bool {
409
+ e, ok := err.(*DataError)
410
+ if ok {
411
+ *into = e
412
+ }
413
+ return ok
414
+ }
415
+
416
+ // Interpret only well-defined provider unit codes, and distinguish CPI index
417
+ // levels from annual inflation rates without transforming source values.
418
+ func dataMeasurementNote(raw any) string {
419
+ data := mapField(raw)
420
+ if data == nil {
421
+ return ""
422
+ }
423
+ series := strings.ToUpper(stringField(data["series_id"]))
424
+ if !strings.HasPrefix(series, "CPI") {
425
+ return ""
426
+ }
427
+ switch strings.ToLower(stringField(data["units"])) {
428
+ case "lin", "":
429
+ return "CPI observations are price-index levels, not annual inflation percentages. Do not label them as inflation rates; a year-over-year percentage requires a 12-month comparison or an appropriate FRED transformation."
430
+ case "pc1":
431
+ return "FRED units=pc1 represents the percent change from one year ago, not an index level."
432
+ default:
433
+ return "CPI observations use the requested FRED units transformation; inspect the units field before interpreting values as percentages."
434
+ }
435
+ }
@@ -0,0 +1,174 @@
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
+ execArguments := map[string]any{
148
+ "calls": []any{map[string]any{"id": id, "inputs": inputs}},
149
+ }
150
+ response := map[string]any{
151
+ "id": id,
152
+ "name": item["name"],
153
+ "source_id": provider,
154
+ "description": item["description"],
155
+ "cost_credits": 0,
156
+ "price": map[string]any{
157
+ "credits_per_call": item["creditsCost"],
158
+ "per_record": item["perRecord"],
159
+ },
160
+ "inputs": map[string]any{"fields": item["options"], "requires_one_of": item["requiresOneOf"]},
161
+ "output": item["response"],
162
+ "examples": contractExamples(item),
163
+ "input_template": inputs,
164
+ "next": map[string]any{"tool": "execute", "arguments": execArguments},
165
+ "guidance": "Copy next.arguments into execute, fill any blank required inputs and adjust values according to inputs.fields. Execute uses credits.",
166
+ }
167
+ if len(missing) > 0 {
168
+ response["fill_before_execution"] = missing
169
+ }
170
+ if input.Raw {
171
+ response["raw"] = raw
172
+ }
173
+ return response, nil
174
+ }