@zenera/rag 1.1.3 → 1.1.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.
@@ -2,22 +2,32 @@ 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";
5
7
  import { stitch } from "./subgraph.js";
6
8
  // ---------------------------------------------------------------------------
7
9
  // The same index, given to an agent
8
10
  //
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.
11
+ // Five tools over one engine, and only one of them ranks anything. `search_api`
12
+ // is the way in when the question is vague; the other four are exact, because
13
+ // a model that has been told "no results" by a vector search has learned
14
+ // nothing a ranking returns the top of a list, so an empty answer and an
15
+ // absent thing look identical.
16
+ //
17
+ // `find_types_with_property` is for the moment after the compiler says
18
+ // `'password' does not exist in type 'PublicUserProfile'`. At that point the
19
+ // model does not need to be reminded what a password is — it needs the list of
20
+ // types that have one. `grep_api` is the same instinct widened: every literal
21
+ // occurrence, counted in full, so "it is not there" can actually be concluded.
15
22
  // ---------------------------------------------------------------------------
16
23
  const GROUP = 'schema';
17
24
  /** Kept small on purpose: a tool result is prompt, and the model asked for one thing. */
18
25
  const DEFAULT_LIMIT = 4;
19
26
  const DEFAULT_MAX_NODES = 60;
20
27
  const MAX_CANDIDATES = 25;
28
+ /** A listing is lines rather than subgraphs, so it can afford more of them. */
29
+ const DEFAULT_ROWS = 50;
30
+ const MAX_ROWS = 200;
21
31
  export function schemaTools(index, options = {}) {
22
32
  const fallback = options.format ?? 'text';
23
33
  const docs = options.docs ?? true;
@@ -178,43 +188,145 @@ export function schemaTools(index, options = {}) {
178
188
  : { found: candidates.length, candidates: candidates.slice(0, MAX_CANDIDATES) };
179
189
  },
180
190
  });
181
- const listMethods = tool({
182
- name: 'list_methods',
191
+ const listApi = tool({
192
+ name: 'list_api',
183
193
  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.',
194
+ description: 'Lists operations, schemas or fields by name, with no searching and no ranking. ' +
195
+ 'The answer is complete: every match is counted, so `found` tells you how many ' +
196
+ 'exist even when the list was shortened. Use it to see the shape of the API ' +
197
+ 'before deciding what to ask for, and to settle whether something exists at all — ' +
198
+ 'search can only ever return its best guesses, so it cannot answer that.',
186
199
  parameters: {
187
200
  type: 'object',
188
201
  properties: {
189
- contains: { type: 'string', description: 'Only paths holding this text.' },
202
+ kind: {
203
+ type: 'string',
204
+ enum: ['methods', 'types', 'properties'],
205
+ description: 'What to list. Default methods.',
206
+ },
207
+ name: {
208
+ type: 'string',
209
+ description: 'Match the name. A plain word matches anywhere in it; use * and ? ' +
210
+ 'to match the whole name, e.g. "*Password*".',
211
+ },
212
+ path: { type: 'string', description: 'Match the route, e.g. "/users*".' },
190
213
  method_type: {
191
214
  type: 'string',
192
215
  enum: ['read_only', 'read_write', 'any'],
193
216
  },
217
+ direction: { type: 'string', enum: ['input', 'output', 'any'] },
218
+ limit: {
219
+ type: 'integer',
220
+ description: `Rows to return. Default ${DEFAULT_ROWS}.`,
221
+ },
194
222
  },
195
223
  additionalProperties: false,
196
224
  },
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() };
225
+ execute: async ({ kind, name, path, method_type, direction, limit }) => {
226
+ const subject = SUBJECTS[kind ?? 'methods'];
227
+ if (!subject) {
228
+ return { error: `cannot list "${kind}"`, hint: 'kind is methods, types or fields' };
229
+ }
230
+ let result;
231
+ try {
232
+ result = listNodes(index.graph, {
233
+ kind: subject,
234
+ name: name ? [loose(name)] : undefined,
235
+ path: path ? [loose(path)] : undefined,
236
+ methodType: enumerated(method_type),
237
+ direction: enumerated(direction),
238
+ limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
239
+ });
240
+ }
241
+ catch (err) {
242
+ return { error: err instanceof PatternError ? err.message : String(err) };
243
+ }
244
+ return {
245
+ found: result.found,
246
+ truncated: result.truncated,
247
+ [PLURALS[subject]]: result.rows.map((r) => line(index.graph, subject, r)),
248
+ };
213
249
  },
214
250
  });
215
- return [searchApi, describeTypes, findTypesWithProperty, listMethods];
251
+ const grepApi = tool({
252
+ name: 'grep_api',
253
+ group: GROUP,
254
+ description: 'Finds every literal occurrence of a string across the whole API description — ' +
255
+ 'operations, schemas and fields alike. No embeddings and no ranking, so ' +
256
+ 'nothing is missed for being an unusual word or an odd spelling. This is the ' +
257
+ 'tool for "does X exist anywhere", and for checking that a search which ' +
258
+ 'returned nothing really means there is nothing.',
259
+ parameters: {
260
+ type: 'object',
261
+ properties: {
262
+ pattern: {
263
+ type: 'string',
264
+ description: 'The text to find. Matched anywhere, ignoring case.',
265
+ },
266
+ regex: {
267
+ type: 'boolean',
268
+ description: 'Read the pattern as a regular expression instead.',
269
+ },
270
+ kind: { type: 'string', enum: ['method', 'type', 'property'] },
271
+ limit: {
272
+ type: 'integer',
273
+ description: `Matches to return. Default ${DEFAULT_ROWS}. \`found\` always counts them all.`,
274
+ },
275
+ },
276
+ required: ['pattern'],
277
+ additionalProperties: false,
278
+ },
279
+ execute: async ({ pattern, regex, kind, limit }) => {
280
+ let result;
281
+ try {
282
+ result = grepNodes(index.graph, matcher(pattern, { regex }), {
283
+ kinds: kind ? [kind] : undefined,
284
+ limit: Math.min(limit ?? DEFAULT_ROWS, MAX_ROWS),
285
+ });
286
+ }
287
+ catch (err) {
288
+ return { error: err instanceof PatternError ? err.message : String(err) };
289
+ }
290
+ if (result.found === 0) {
291
+ return {
292
+ found: 0,
293
+ hint: 'nothing in the description contains it — it is not there under this name',
294
+ };
295
+ }
296
+ return {
297
+ found: result.found,
298
+ truncated: result.truncated,
299
+ matches: result.matches.map((m) => ({ id: m.id, text: m.text })),
300
+ };
301
+ },
302
+ });
303
+ return [searchApi, describeTypes, findTypesWithProperty, listApi, grepApi];
216
304
  }
217
305
  // ---------------------------------------------------------------------------
306
+ const SUBJECTS = {
307
+ methods: 'method',
308
+ types: 'type',
309
+ properties: 'property',
310
+ fields: 'property',
311
+ };
312
+ const PLURALS = {
313
+ method: 'methods',
314
+ type: 'types',
315
+ property: 'properties',
316
+ };
317
+ /** One row, as the line a model reads rather than an object it has to walk. */
318
+ function line(graph, kind, row) {
319
+ if (kind === 'method') {
320
+ return `${row.httpMethod} ${row.path} ${row.name}${row.doc ? ` — ${row.doc}` : ''}`;
321
+ }
322
+ if (kind === 'type') {
323
+ const side = row.direction === 'none' ? '' : ` (${row.direction})`;
324
+ return `${row.name}${side} ${fields(propertyCount(graph, row.id))}${row.doc ? ` — ${row.doc}` : ''}`;
325
+ }
326
+ const owner = row.parent ? `${row.parent}.` : '';
327
+ return `${owner}${row.name}${row.required ? '' : '?'}: ${row.signature || 'unknown'}`;
328
+ }
329
+ const enumerated = (value) => value && value !== 'any' ? value : undefined;
218
330
  function list(description) {
219
331
  return { type: 'array', items: { type: 'string' }, description };
220
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/rag",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
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.3",
57
- "@zenera/neo": "^1.1.3"
56
+ "@zenera/cli": "^1.1.5",
57
+ "@zenera/neo": "^1.1.5"
58
58
  }
59
59
  }