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,340 @@
1
+ package app
2
+
3
+ import (
4
+ "sort"
5
+ "strings"
6
+ )
7
+
8
+ func researchView(view, query string) (string, error) {
9
+ switch strings.ToLower(strings.TrimSpace(view)) {
10
+ case "":
11
+ if strings.TrimSpace(query) == "" {
12
+ return "providers", nil
13
+ }
14
+ return "tools", nil
15
+ case "sources", "providers":
16
+ return "providers", nil
17
+ case "groups":
18
+ return "groups", nil
19
+ case "operations", "tools":
20
+ return "tools", nil
21
+ default:
22
+ return "", dataError("invalid_view", "Use view=sources, groups, or operations.")
23
+ }
24
+ }
25
+
26
+ func expandContract(include []string) ([]string, error) {
27
+ seen := map[string]bool{}
28
+ out := make([]string, 0, len(include))
29
+ for _, value := range include {
30
+ name := ""
31
+ switch strings.ToLower(strings.TrimSpace(value)) {
32
+ case "inputs", "options":
33
+ name = "options"
34
+ case "output", "response":
35
+ name = "response"
36
+ case "examples":
37
+ name = "examples"
38
+ default:
39
+ return nil, dataError("invalid_include", "Supported include values: inputs, output, examples.")
40
+ }
41
+ if !seen[name] {
42
+ out = append(out, name)
43
+ seen[name] = true
44
+ }
45
+ }
46
+ return out, nil
47
+ }
48
+
49
+ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string, []string, error) {
50
+ switch input.Mode {
51
+ case "", "ranked", "catalogue":
52
+ default:
53
+ return input, "", nil, dataError("invalid_mode", "Use mode=ranked or mode=catalogue.")
54
+ }
55
+ view, err := researchView(input.View, input.Query)
56
+ if err != nil {
57
+ return input, "", nil, err
58
+ }
59
+ expand, err := expandContract(input.Include)
60
+ if err != nil {
61
+ return input, "", nil, err
62
+ }
63
+ if input.Limit == 0 {
64
+ input.Limit = 10
65
+ }
66
+ if input.Limit < 1 || input.Limit > 100 {
67
+ return input, "", nil, dataError("invalid_limit", "limit must be between 1 and 100.")
68
+ }
69
+ if input.Offset < 0 || input.Offset > 100000 {
70
+ return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
71
+ }
72
+ for _, target := range input.URLs {
73
+ if err := validateSourceURL(target); err != nil {
74
+ return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
75
+ }
76
+ }
77
+ return input, view, expand, nil
78
+ }
79
+
80
+ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string) map[string]any {
81
+ o := map[string]any{"level": view, "limit": input.Limit, "offset": input.Offset}
82
+ if input.Query != "" {
83
+ o["query"] = input.Query
84
+ }
85
+ if len(input.URLs) > 0 {
86
+ o["urls"] = input.URLs
87
+ }
88
+ if len(input.Sources) > 0 {
89
+ o["providers"] = input.Sources
90
+ }
91
+ if len(input.Categories) > 0 {
92
+ o["categories"] = input.Categories
93
+ }
94
+ if len(input.Groups) > 0 {
95
+ o["groups"] = input.Groups
96
+ }
97
+ if len(input.Operations) > 0 {
98
+ var caps []string
99
+ sourceSet := map[string]bool{}
100
+ for _, source := range input.Sources {
101
+ sourceSet[source] = true
102
+ }
103
+ for _, id := range input.Operations {
104
+ provider, capability, err := parseOperationID(id)
105
+ if err == nil {
106
+ caps = append(caps, capability)
107
+ sourceSet[provider] = true
108
+ } else {
109
+ caps = append(caps, id)
110
+ }
111
+ }
112
+ o["capabilities"] = caps
113
+ if len(input.Sources) == 0 {
114
+ var providers []string
115
+ for provider := range sourceSet {
116
+ providers = append(providers, provider)
117
+ }
118
+ // Sorting keeps responses, cursors and test fixtures stable.
119
+ sortStrings(providers)
120
+ if len(providers) > 0 {
121
+ o["providers"] = providers
122
+ }
123
+ }
124
+ }
125
+ if len(expand) > 0 {
126
+ o["expand"] = expand
127
+ }
128
+ return o
129
+ }
130
+
131
+ func sortStrings(values []string) {
132
+ for i := 1; i < len(values); i++ {
133
+ for j := i; j > 0 && values[j] < values[j-1]; j-- {
134
+ values[j], values[j-1] = values[j-1], values[j]
135
+ }
136
+ }
137
+ }
138
+
139
+ // ResearchData supports ranked semantic search and the complete filterable,
140
+ // pageable catalogue. Each result includes the exact next tool/arguments.
141
+ func ResearchData(input ResearchDataInput) (map[string]any, error) {
142
+ input, view, expand, err := normalizeResearchInput(input)
143
+ if err != nil {
144
+ return nil, err
145
+ }
146
+ ctx, cancel := defaultDataContext()
147
+ defer cancel()
148
+ semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
149
+ len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
150
+ len(input.Groups) == 0 && len(input.Operations) == 0
151
+ var items []any
152
+ var total any
153
+ var next any
154
+ var raw map[string]any
155
+ mode := "catalogue"
156
+ if semantic {
157
+ mode = "ranked_search"
158
+ // Ask for full metadata even when the agent requested concise output:
159
+ // compact upstream matches may omit both price and operation name.
160
+ // We still return compact result cards unless include was requested.
161
+ detail := "full"
162
+ raw, err = doDataAPI(ctx, "/search", map[string]any{
163
+ "query": input.Query, "sources": []string{"alexandria"},
164
+ "limit": input.Limit, "toolDetail": detail,
165
+ }, "")
166
+ if err != nil {
167
+ return nil, err
168
+ }
169
+ data := mapField(raw["data"])
170
+ items = listField(data["tools"])
171
+ // Some upstream ranked matches arrive out of order despite including a
172
+ // similarity score. Present the highest-scoring candidate first, retaining
173
+ // 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
+ // Ranked search has no native cursor. Expose the free, complete
186
+ // catalogue with matching semantic query as an optional expansion.
187
+ next = map[string]any{"tool": "research", "arguments": map[string]any{
188
+ "query": input.Query, "view": "operations", "mode": "catalogue", "limit": input.Limit,
189
+ }, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
190
+ } else {
191
+ options := nativeBrowseOptions(input, view, expand)
192
+ var catalogue map[string]any
193
+ catalogue, raw, err = freeDataBrowse(ctx, options)
194
+ if err != nil {
195
+ return nil, err
196
+ }
197
+ items = listField(catalogue["items"])
198
+ total = catalogue["total"]
199
+ if native := mapField(catalogue["next"]); native != nil {
200
+ next = continuationFromCatalogue(native)
201
+ }
202
+ }
203
+ results := make([]map[string]any, 0, len(items))
204
+ for _, value := range items {
205
+ entry := mapField(value)
206
+ if entry == nil {
207
+ continue
208
+ }
209
+ id := stringField(entry["id"])
210
+ if id == "" && view == "tools" {
211
+ if provider := stringField(entry["provider"]); provider != "" {
212
+ id = provider + "/" + stringField(entry["capability"])
213
+ }
214
+ }
215
+ name := stringField(entry["name"])
216
+ if name == "" {
217
+ segments := strings.Split(id, "/")
218
+ name = strings.ReplaceAll(strings.ReplaceAll(segments[len(segments)-1], "_", " "), "-", " ")
219
+ }
220
+ result := map[string]any{"id": id, "name": name, "description": entry["description"]}
221
+ if provider := stringField(entry["provider"]); provider != "" {
222
+ result["source_id"] = provider
223
+ }
224
+ if cost, ok := entry["creditsCost"]; ok {
225
+ result["credits_per_call"] = cost
226
+ }
227
+ if cost, ok := entry["perRecord"]; ok {
228
+ result["per_record"] = cost
229
+ }
230
+ if count, ok := entry["toolCount"]; ok {
231
+ result["operation_count"] = count
232
+ }
233
+ if rank, ok := entry["similarity"]; ok {
234
+ result["relevance"] = rank
235
+ }
236
+ if len(expand) > 0 {
237
+ if selected := entry["options"]; selected != nil && containsString(expand, "options") {
238
+ result["inputs"] = selected
239
+ }
240
+ if selected := entry["response"]; selected != nil && containsString(expand, "response") {
241
+ result["output"] = selected
242
+ }
243
+ if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
244
+ result["examples"] = selected
245
+ }
246
+ }
247
+ if continuation := mapField(entry["next"]); continuation != nil {
248
+ result["next"] = continuationFromCatalogue(continuation)
249
+ } else if id != "" {
250
+ result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
251
+ }
252
+ results = append(results, result)
253
+ }
254
+ output := map[string]any{
255
+ "mode": mode, "view": niceDataView(view), "results": results,
256
+ "count": len(results), "cost_credits": 0,
257
+ "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
+ }
259
+ if total != nil {
260
+ output["total"] = total
261
+ }
262
+ if next != nil {
263
+ output["next"] = next
264
+ }
265
+ if input.Raw {
266
+ output["raw"] = raw
267
+ }
268
+ return output, nil
269
+ }
270
+
271
+ func niceDataView(view string) string {
272
+ switch view {
273
+ case "providers":
274
+ return "sources"
275
+ case "tools":
276
+ return "operations"
277
+ default:
278
+ return view
279
+ }
280
+ }
281
+
282
+ func containsString(items []string, needle string) bool {
283
+ for _, item := range items {
284
+ if item == needle {
285
+ return true
286
+ }
287
+ }
288
+ return false
289
+ }
290
+
291
+ func continuationFromCatalogue(native map[string]any) map[string]any {
292
+ options := mapField(native["options"])
293
+ if options == nil {
294
+ return map[string]any{"native_request": native}
295
+ }
296
+ args := map[string]any{"mode": "catalogue"}
297
+ for _, field := range []string{"query", "urls", "providers", "categories", "groups", "limit", "offset"} {
298
+ if value, ok := options[field]; ok {
299
+ name := field
300
+ if field == "providers" {
301
+ name = "sources"
302
+ }
303
+ args[name] = value
304
+ }
305
+ }
306
+ if level := stringField(options["level"]); level != "" {
307
+ args["view"] = niceDataView(level)
308
+ }
309
+ if caps := listField(options["capabilities"]); len(caps) > 0 {
310
+ var ids []string
311
+ sourceNames := listField(options["providers"])
312
+ for _, capability := range caps {
313
+ cap := stringField(capability)
314
+ if cap == "" {
315
+ continue
316
+ }
317
+ if len(sourceNames) == 1 {
318
+ ids = append(ids, stringField(sourceNames[0])+"/"+cap)
319
+ } else {
320
+ ids = append(ids, cap)
321
+ }
322
+ }
323
+ args["operations"] = ids
324
+ }
325
+ if exp := listField(options["expand"]); len(exp) > 0 {
326
+ var include []string
327
+ for _, value := range exp {
328
+ switch stringField(value) {
329
+ case "options":
330
+ include = append(include, "inputs")
331
+ case "response":
332
+ include = append(include, "output")
333
+ case "examples":
334
+ include = append(include, "examples")
335
+ }
336
+ }
337
+ args["include"] = include
338
+ }
339
+ return map[string]any{"tool": "research", "arguments": args, "native_request": native}
340
+ }