@zenera/rag 1.1.4 → 1.1.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.
@@ -2,25 +2,40 @@ import { tool } from '@zenera/neo';
2
2
  import { FORMATS, isFormat, present } from "../present.js";
3
3
  import { isEmpty, parseQuery, QueryError } from "../query.js";
4
4
  import { toTypeScript } from "./hydrate.js";
5
+ import { fields, grepNodes, listNodes, propertyCount } from "./lookup.js";
6
+ import { loose, matcher, PatternError } from "./match.js";
7
+ import { sourceTag } from "./render.js";
5
8
  import { stitch } from "./subgraph.js";
6
9
  // ---------------------------------------------------------------------------
7
10
  // The same index, given to an agent
8
11
  //
9
- // Four tools over one engine. Three of them search, and the fourth deliberately
10
- // does not: `find_types_with_property` is a graph lookup, for the moment after
11
- // the compiler says `'password' does not exist in type 'PublicUserProfile'`.
12
- // At that point the model does not need to be reminded what a password is
13
- // it needs the list of types that have one, and an embedding of the word will
14
- // only rank the guess it already made near the top again.
12
+ // Five tools over one engine, and only one of them ranks anything. `search_api`
13
+ // is the way in when the question is vague; the other four are exact, because
14
+ // a model that has been told "no results" by a vector search has learned
15
+ // nothing a ranking returns the top of a list, so an empty answer and an
16
+ // absent thing look identical.
17
+ //
18
+ // `find_types_with_property` is for the moment after the compiler says
19
+ // `'password' does not exist in type 'PublicUserProfile'`. At that point the
20
+ // model does not need to be reminded what a password is — it needs the list of
21
+ // types that have one. `grep_api` is the same instinct widened: every literal
22
+ // occurrence, counted in full, so "it is not there" can actually be concluded.
15
23
  // ---------------------------------------------------------------------------
16
24
  const GROUP = 'schema';
17
25
  /** Kept small on purpose: a tool result is prompt, and the model asked for one thing. */
18
26
  const DEFAULT_LIMIT = 4;
19
27
  const DEFAULT_MAX_NODES = 60;
20
28
  const MAX_CANDIDATES = 25;
29
+ /** A listing is lines rather than subgraphs, so it can afford more of them. */
30
+ const DEFAULT_ROWS = 50;
31
+ const MAX_ROWS = 200;
21
32
  export function schemaTools(index, options = {}) {
22
33
  const fallback = options.format ?? 'text';
23
34
  const docs = options.docs ?? true;
35
+ // With one document there is nothing to disambiguate and naming it on every
36
+ // line is prompt spent saying the same word; with several it is the only
37
+ // way to tell two revisions of one API apart.
38
+ const source = options.source ?? index.manifest.sources.length > 1;
24
39
  const searchApi = tool({
25
40
  name: 'search_api',
26
41
  group: GROUP,
@@ -97,7 +112,10 @@ export function schemaTools(index, options = {}) {
97
112
  found: result.subgraphs.length,
98
113
  ids: result.subgraphs.flatMap((s) => s.hits),
99
114
  truncated: result.subgraphs.some((s) => s.truncated),
100
- api: await present(index, result.subgraphs, chosen(format, fallback), { docs }),
115
+ api: await present(index, result.subgraphs, chosen(format, fallback), {
116
+ docs,
117
+ source,
118
+ }),
101
119
  };
102
120
  },
103
121
  });
@@ -178,43 +196,179 @@ export function schemaTools(index, options = {}) {
178
196
  : { found: candidates.length, candidates: candidates.slice(0, MAX_CANDIDATES) };
179
197
  },
180
198
  });
181
- const listMethods = tool({
182
- name: 'list_methods',
199
+ const listApi = tool({
200
+ name: 'list_api',
183
201
  group: GROUP,
184
- description: 'Lists operations by path, with no searching. Use it to see the shape of the API ' +
185
- 'before deciding what to ask for.',
202
+ description: 'Lists operations, schemas or fields by name, with no searching and no ranking. ' +
203
+ 'The answer is complete: every match is counted, so `found` tells you how many ' +
204
+ 'exist even when the list was shortened. Use it to see the shape of the API ' +
205
+ 'before deciding what to ask for, and to settle whether something exists at all — ' +
206
+ 'search can only ever return its best guesses, so it cannot answer that.',
186
207
  parameters: {
187
208
  type: 'object',
188
209
  properties: {
189
- contains: { type: 'string', description: 'Only paths holding this text.' },
210
+ kind: {
211
+ type: 'string',
212
+ enum: ['methods', 'types', 'properties'],
213
+ description: 'What to list. Default methods.',
214
+ },
215
+ name: {
216
+ type: 'string',
217
+ description: 'Match the name. A plain word matches anywhere in it; use * and ? ' +
218
+ 'to match the whole name, e.g. "*Password*".',
219
+ },
220
+ path: {
221
+ type: 'string',
222
+ description: 'Match the route, e.g. "/users*". Keeps operations and their ' +
223
+ 'parameters; a schema sits on no one route, so it is left out.',
224
+ },
225
+ regex: {
226
+ type: 'boolean',
227
+ description: 'Read `name` and `path` as regular expressions instead.',
228
+ },
190
229
  method_type: {
191
230
  type: 'string',
192
231
  enum: ['read_only', 'read_write', 'any'],
193
232
  },
233
+ direction: { type: 'string', enum: ['input', 'output', 'any'] },
234
+ source: {
235
+ type: 'string',
236
+ description: 'Only this document, when the index holds more than one.',
237
+ },
238
+ limit: {
239
+ type: 'integer',
240
+ description: `Rows to return. Default ${DEFAULT_ROWS}.`,
241
+ },
194
242
  },
195
243
  additionalProperties: false,
196
244
  },
197
- execute: async ({ contains, method_type }) => {
198
- const needle = contains?.toLowerCase();
199
- const rows = [];
200
- index.graph.forEachNode((_id, a) => {
201
- if (a.kind !== 'method') {
202
- return;
203
- }
204
- if (needle && !a.path.toLowerCase().includes(needle)) {
205
- return;
206
- }
207
- if (method_type && method_type !== 'any' && a.methodType !== method_type) {
208
- return;
209
- }
210
- rows.push(`${a.httpMethod} ${a.path} ${a.name}${a.doc ? ` — ${a.doc}` : ''}`);
211
- });
212
- return { found: rows.length, methods: rows.sort() };
245
+ execute: async ({ kind, name, path, regex, method_type, direction, source: only, limit, }) => {
246
+ const subject = SUBJECTS[kind ?? 'methods'];
247
+ if (!subject) {
248
+ return { error: `cannot list "${kind}"`, hint: 'kind is methods, types or fields' };
249
+ }
250
+ let result;
251
+ try {
252
+ result = listNodes(index.graph, {
253
+ kind: subject,
254
+ name: name ? [loose(name, { regex })] : undefined,
255
+ path: path ? [loose(path, { regex })] : undefined,
256
+ source: only,
257
+ methodType: enumerated(method_type),
258
+ direction: enumerated(direction),
259
+ limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
260
+ });
261
+ }
262
+ catch (err) {
263
+ return { error: err instanceof PatternError ? err.message : String(err) };
264
+ }
265
+ return {
266
+ found: result.found,
267
+ truncated: result.truncated,
268
+ [PLURALS[subject]]: result.rows.map((r) => line(index.graph, subject, r, source)),
269
+ };
270
+ },
271
+ });
272
+ const grepApi = tool({
273
+ name: 'grep_api',
274
+ group: GROUP,
275
+ description: 'Finds every literal occurrence of a string across the whole API description — ' +
276
+ 'operations, schemas and fields alike. No embeddings and no ranking, so ' +
277
+ 'nothing is missed for being an unusual word or an odd spelling. This is the ' +
278
+ 'tool for "does X exist anywhere", and for checking that a search which ' +
279
+ 'returned nothing really means there is nothing. Narrow it with `path` or ' +
280
+ '`name` when the word is common and only one corner of the API is meant.',
281
+ parameters: {
282
+ type: 'object',
283
+ properties: {
284
+ pattern: {
285
+ type: 'string',
286
+ description: 'The text to find. Matched anywhere, ignoring case.',
287
+ },
288
+ regex: {
289
+ type: 'boolean',
290
+ description: 'Read the pattern as a regular expression instead.',
291
+ },
292
+ kind: { type: 'string', enum: ['method', 'type', 'property'] },
293
+ name: {
294
+ type: 'string',
295
+ description: 'Only nodes whose own name matches this. * and ? allowed.',
296
+ },
297
+ path: {
298
+ type: 'string',
299
+ description: 'Only what sits on a matching route, e.g. "/users*".',
300
+ },
301
+ source: {
302
+ type: 'string',
303
+ description: 'Only this document, when the index holds more than one.',
304
+ },
305
+ limit: {
306
+ type: 'integer',
307
+ description: `Matches to return. Default ${DEFAULT_ROWS}. \`found\` always counts them all.`,
308
+ },
309
+ },
310
+ required: ['pattern'],
311
+ additionalProperties: false,
312
+ },
313
+ execute: async ({ pattern, regex, kind, name, path, source: only, limit }) => {
314
+ let result;
315
+ try {
316
+ result = grepNodes(index.graph, matcher(pattern, { regex }), {
317
+ kinds: kind ? [kind] : undefined,
318
+ name: name ? [loose(name)] : undefined,
319
+ path: path ? [loose(path)] : undefined,
320
+ source: only,
321
+ limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
322
+ });
323
+ }
324
+ catch (err) {
325
+ return { error: err instanceof PatternError ? err.message : String(err) };
326
+ }
327
+ if (result.found === 0) {
328
+ return {
329
+ found: 0,
330
+ hint: 'nothing in the description contains it — it is not there under this name',
331
+ };
332
+ }
333
+ return {
334
+ found: result.found,
335
+ truncated: result.truncated,
336
+ matches: result.matches.map((m) => ({
337
+ id: m.id,
338
+ ...(source ? { source: m.attributes.source } : {}),
339
+ text: m.text,
340
+ })),
341
+ };
213
342
  },
214
343
  });
215
- return [searchApi, describeTypes, findTypesWithProperty, listMethods];
344
+ return [searchApi, describeTypes, findTypesWithProperty, listApi, grepApi];
216
345
  }
217
346
  // ---------------------------------------------------------------------------
347
+ const SUBJECTS = {
348
+ methods: 'method',
349
+ types: 'type',
350
+ properties: 'property',
351
+ fields: 'property',
352
+ };
353
+ const PLURALS = {
354
+ method: 'methods',
355
+ type: 'types',
356
+ property: 'properties',
357
+ };
358
+ /** One row, as the line a model reads rather than an object it has to walk. */
359
+ function line(graph, kind, row, source = false) {
360
+ const from = source ? ` ${sourceTag(row.source)}` : '';
361
+ if (kind === 'method') {
362
+ return `${row.httpMethod} ${row.path} ${row.name}${from}${row.doc ? ` — ${row.doc}` : ''}`;
363
+ }
364
+ if (kind === 'type') {
365
+ const side = row.direction === 'none' ? '' : ` (${row.direction})`;
366
+ return `${row.name}${side} ${fields(propertyCount(graph, row.id))}${from}${row.doc ? ` — ${row.doc}` : ''}`;
367
+ }
368
+ const owner = row.parent ? `${row.parent}.` : '';
369
+ return `${owner}${row.name}${row.required ? '' : '?'}: ${row.signature || 'unknown'}${from}`;
370
+ }
371
+ const enumerated = (value) => value && value !== 'any' ? value : undefined;
218
372
  function list(description) {
219
373
  return { type: 'array', items: { type: 'string' }, description };
220
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/rag",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Retrieval over API descriptions: openapi/swagger documents as a searchable graph.",
5
5
  "keywords": [
6
6
  "agents",
@@ -53,7 +53,7 @@
53
53
  "@apidevtools/swagger-parser": "^12.0.0",
54
54
  "@lancedb/lancedb": "^0.38.0",
55
55
  "graphology": "^0.26.0",
56
- "@zenera/cli": "^1.1.4",
57
- "@zenera/neo": "^1.1.4"
56
+ "@zenera/cli": "^1.1.6",
57
+ "@zenera/neo": "^1.1.6"
58
58
  }
59
59
  }