@zenera/rag 1.1.0
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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +436 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/present.d.ts +17 -0
- package/dist/present.js +40 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +88 -0
- package/dist/repl.d.ts +8 -0
- package/dist/repl.js +119 -0
- package/dist/schema/build.d.ts +28 -0
- package/dist/schema/build.js +79 -0
- package/dist/schema/entities.d.ts +18 -0
- package/dist/schema/entities.js +71 -0
- package/dist/schema/files.d.ts +74 -0
- package/dist/schema/files.js +79 -0
- package/dist/schema/graph.d.ts +48 -0
- package/dist/schema/graph.js +320 -0
- package/dist/schema/hydrate.d.ts +19 -0
- package/dist/schema/hydrate.js +182 -0
- package/dist/schema/render.d.ts +11 -0
- package/dist/schema/render.js +254 -0
- package/dist/schema/schema.d.ts +11 -0
- package/dist/schema/schema.js +189 -0
- package/dist/schema/search.d.ts +50 -0
- package/dist/schema/search.js +142 -0
- package/dist/schema/spec.d.ts +58 -0
- package/dist/schema/spec.js +309 -0
- package/dist/schema/store.d.ts +32 -0
- package/dist/schema/store.js +126 -0
- package/dist/schema/subgraph.d.ts +42 -0
- package/dist/schema/subgraph.js +272 -0
- package/dist/schema/tools.d.ts +10 -0
- package/dist/schema/tools.js +242 -0
- package/dist/schema/typescript.d.ts +22 -0
- package/dist/schema/typescript.js +246 -0
- package/package.json +59 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CliError } from '@zenera/cli/lib';
|
|
2
|
+
import { type Dialect, type Schema } from './schema.ts';
|
|
3
|
+
export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
|
|
4
|
+
export type Method = (typeof METHODS)[number];
|
|
5
|
+
export type ParamIn = 'path' | 'query' | 'header' | 'cookie' | 'formData';
|
|
6
|
+
export type MethodType = 'read_only' | 'read_write';
|
|
7
|
+
/** GET/HEAD/OPTIONS answer a question; everything else changes something. */
|
|
8
|
+
export declare function methodTypeOf(method: Method): MethodType;
|
|
9
|
+
export interface ParamSpec {
|
|
10
|
+
name: string;
|
|
11
|
+
in: ParamIn;
|
|
12
|
+
required: boolean;
|
|
13
|
+
doc: string;
|
|
14
|
+
schema: Schema;
|
|
15
|
+
}
|
|
16
|
+
export interface ResponseSpec {
|
|
17
|
+
status: number;
|
|
18
|
+
doc: string;
|
|
19
|
+
schema: Schema;
|
|
20
|
+
}
|
|
21
|
+
export interface Operation {
|
|
22
|
+
source: string;
|
|
23
|
+
method: Method;
|
|
24
|
+
/** the template, `/users/{user_id}` */
|
|
25
|
+
path: string;
|
|
26
|
+
operationId: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
description: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
params: ParamSpec[];
|
|
31
|
+
requestBody?: {
|
|
32
|
+
required: boolean;
|
|
33
|
+
schema: Schema;
|
|
34
|
+
};
|
|
35
|
+
/** every 2xx that carries a body, in status order */
|
|
36
|
+
responses: ResponseSpec[];
|
|
37
|
+
}
|
|
38
|
+
export interface ApiDoc {
|
|
39
|
+
source: string;
|
|
40
|
+
sha256: string;
|
|
41
|
+
dialect: Dialect;
|
|
42
|
+
title: string;
|
|
43
|
+
version: string;
|
|
44
|
+
}
|
|
45
|
+
export interface Corpus {
|
|
46
|
+
docs: ApiDoc[];
|
|
47
|
+
operations: Operation[];
|
|
48
|
+
/** every named component schema in the corpus, by its settled id */
|
|
49
|
+
types: Record<string, Schema>;
|
|
50
|
+
/** which document each type id came from */
|
|
51
|
+
typeSource: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
54
|
+
export declare class SpecError extends CliError {
|
|
55
|
+
constructor(message: string, hint?: string);
|
|
56
|
+
}
|
|
57
|
+
export declare function loadSpecs(files: readonly string[]): Promise<Corpus>;
|
|
58
|
+
//# sourceMappingURL=spec.d.ts.map
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { basename, extname } from 'node:path';
|
|
4
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
5
|
+
import { docOf, isObject, normalize } from "./schema.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Documents, flattened — but not dereferenced
|
|
8
|
+
//
|
|
9
|
+
// `bundle` rather than `dereference`: it resolves external files and leaves
|
|
10
|
+
// internal `$ref`s standing. That is the opposite of what a mock server wants
|
|
11
|
+
// and exactly what a graph wants, because `#/components/schemas/User` is an
|
|
12
|
+
// edge and `User` is a node id.
|
|
13
|
+
//
|
|
14
|
+
// Two documents may both call a schema `User`. Rather than qualify every name
|
|
15
|
+
// and make the single-document case ugly, names are qualified only where they
|
|
16
|
+
// actually collide, and every `$ref` in the corpus is rewritten to the id that
|
|
17
|
+
// was settled on. Below this file there is one flat namespace of type ids.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options'];
|
|
20
|
+
/** GET/HEAD/OPTIONS answer a question; everything else changes something. */
|
|
21
|
+
export function methodTypeOf(method) {
|
|
22
|
+
return method === 'get' || method === 'head' || method === 'options'
|
|
23
|
+
? 'read_only'
|
|
24
|
+
: 'read_write';
|
|
25
|
+
}
|
|
26
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
27
|
+
export class SpecError extends CliError {
|
|
28
|
+
constructor(message, hint) {
|
|
29
|
+
super(message, EXIT.invalid, hint);
|
|
30
|
+
this.name = 'SpecError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Loading
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export async function loadSpecs(files) {
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
throw new SpecError('no document given', 'name at least one openapi/swagger file');
|
|
39
|
+
}
|
|
40
|
+
const loaded = [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
loaded.push(await loadSpec(file));
|
|
43
|
+
}
|
|
44
|
+
return settle(loaded);
|
|
45
|
+
}
|
|
46
|
+
async function loadSpec(file) {
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = (await SwaggerParser.bundle(file));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new SpecError(`${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`, 'the document must be a readable OpenAPI 3.x or Swagger 2.0 file');
|
|
53
|
+
}
|
|
54
|
+
const dialect = dialectOf(raw, file);
|
|
55
|
+
const source = raw.components?.schemas ?? raw.definitions ?? {};
|
|
56
|
+
const types = new Map();
|
|
57
|
+
for (const [name, schema] of Object.entries(source)) {
|
|
58
|
+
types.set(name, normalize(schema, dialect));
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
doc: {
|
|
62
|
+
source: file,
|
|
63
|
+
sha256: createHash('sha256').update(JSON.stringify(raw)).digest('hex'),
|
|
64
|
+
dialect,
|
|
65
|
+
title: raw.info?.title?.trim() || basename(file),
|
|
66
|
+
version: raw.info?.version?.trim() || '',
|
|
67
|
+
},
|
|
68
|
+
slug: slugOf(file),
|
|
69
|
+
operations: operationsOf(raw, dialect, file),
|
|
70
|
+
types,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function dialectOf(doc, file) {
|
|
74
|
+
if (doc.swagger?.startsWith('2.')) {
|
|
75
|
+
return 'swagger-2.0';
|
|
76
|
+
}
|
|
77
|
+
if (doc.openapi?.startsWith('3.1')) {
|
|
78
|
+
return 'openapi-3.1';
|
|
79
|
+
}
|
|
80
|
+
if (doc.openapi?.startsWith('3.')) {
|
|
81
|
+
return 'openapi-3.0';
|
|
82
|
+
}
|
|
83
|
+
throw new SpecError(`${file}: neither \`swagger: 2.0\` nor \`openapi: 3.x\` is declared`);
|
|
84
|
+
}
|
|
85
|
+
function slugOf(file) {
|
|
86
|
+
const name = basename(file, extname(file));
|
|
87
|
+
return name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'api';
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Operations
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
function operationsOf(raw, dialect, file) {
|
|
93
|
+
const prefix = dialect === 'swagger-2.0' ? (raw.basePath ?? '') : '';
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const [template, item] of Object.entries(raw.paths ?? {})) {
|
|
96
|
+
if (!isObject(item)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Path-level parameters apply to every method on it, and an operation
|
|
100
|
+
// may override one by repeating its name and location.
|
|
101
|
+
const shared = paramsOf(item.parameters, dialect);
|
|
102
|
+
for (const method of METHODS) {
|
|
103
|
+
const op = item[method];
|
|
104
|
+
if (!isObject(op)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const path = join(prefix, template);
|
|
108
|
+
const own = paramsOf(op.parameters, dialect);
|
|
109
|
+
out.push({
|
|
110
|
+
source: file,
|
|
111
|
+
method,
|
|
112
|
+
path,
|
|
113
|
+
operationId: text(op.operationId) || synthesizeId(method, path),
|
|
114
|
+
summary: text(op.summary),
|
|
115
|
+
description: text(op.description),
|
|
116
|
+
tags: Array.isArray(op.tags) ? op.tags.filter((t) => typeof t === 'string') : [],
|
|
117
|
+
params: override(shared, own).filter((p) => p.in !== 'body'),
|
|
118
|
+
requestBody: bodyOf(op, own, dialect),
|
|
119
|
+
responses: responsesOf(op, dialect),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function join(prefix, template) {
|
|
126
|
+
const base = prefix.replace(/\/+$/, '');
|
|
127
|
+
return `${base}${template.startsWith('/') ? '' : '/'}${template}` || '/';
|
|
128
|
+
}
|
|
129
|
+
function synthesizeId(method, path) {
|
|
130
|
+
const tail = path
|
|
131
|
+
.replace(/[{}]/g, '')
|
|
132
|
+
.split('/')
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join('_')
|
|
135
|
+
.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
136
|
+
return `${method}_${tail || 'root'}`;
|
|
137
|
+
}
|
|
138
|
+
const text = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
139
|
+
function paramsOf(value, dialect) {
|
|
140
|
+
if (!Array.isArray(value)) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const entry of value) {
|
|
145
|
+
if (!isObject(entry) || typeof entry.name !== 'string') {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const where = entry.in;
|
|
149
|
+
if (typeof where !== 'string') {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Swagger 2.0 puts the type keywords on the parameter itself; 3.x
|
|
153
|
+
// wraps them in `schema`, which is the shape everything below wants.
|
|
154
|
+
const schema = isObject(entry.schema) ? entry.schema : envelope(entry);
|
|
155
|
+
out.push({
|
|
156
|
+
name: entry.name,
|
|
157
|
+
in: where,
|
|
158
|
+
required: entry.required === true || where === 'path',
|
|
159
|
+
doc: docOf(entry) || docOf(schema),
|
|
160
|
+
schema: normalize(schema, dialect),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** What is left of a Swagger 2.0 parameter once it stops describing itself. */
|
|
166
|
+
const ENVELOPE = new Set([
|
|
167
|
+
'name',
|
|
168
|
+
'in',
|
|
169
|
+
'required',
|
|
170
|
+
'description',
|
|
171
|
+
'allowEmptyValue',
|
|
172
|
+
'collectionFormat',
|
|
173
|
+
'schema',
|
|
174
|
+
]);
|
|
175
|
+
function envelope(entry) {
|
|
176
|
+
return Object.fromEntries(Object.entries(entry).filter(([key]) => !ENVELOPE.has(key)));
|
|
177
|
+
}
|
|
178
|
+
/** An operation-level parameter replaces the path-level one it shadows. */
|
|
179
|
+
function override(shared, own) {
|
|
180
|
+
const key = (p) => `${p.in}:${p.name}`;
|
|
181
|
+
const taken = new Set(own.map(key));
|
|
182
|
+
return [...shared.filter((p) => !taken.has(key(p))), ...own];
|
|
183
|
+
}
|
|
184
|
+
function bodyOf(op, params, dialect) {
|
|
185
|
+
const body = params.find((p) => p.in === 'body');
|
|
186
|
+
if (body) {
|
|
187
|
+
return { required: body.required, schema: body.schema };
|
|
188
|
+
}
|
|
189
|
+
const request = op.requestBody;
|
|
190
|
+
if (!isObject(request)) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const schema = pickContent(request.content);
|
|
194
|
+
return schema
|
|
195
|
+
? { required: request.required === true, schema: normalize(schema, dialect) }
|
|
196
|
+
: undefined;
|
|
197
|
+
}
|
|
198
|
+
function responsesOf(op, dialect) {
|
|
199
|
+
const responses = op.responses;
|
|
200
|
+
if (!isObject(responses)) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const [code, value] of Object.entries(responses)) {
|
|
205
|
+
const status = Number(code);
|
|
206
|
+
if (!Number.isInteger(status) || status < 200 || status > 299 || !isObject(value)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// Swagger 2.0 hangs the schema straight off the response.
|
|
210
|
+
const schema = pickContent(value.content) ?? (isObject(value.schema) ? value.schema : undefined);
|
|
211
|
+
// A 204 says the call worked and nothing else; there is no node in it.
|
|
212
|
+
if (!schema) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
out.push({ status, doc: docOf(value), schema: normalize(schema, dialect) });
|
|
216
|
+
}
|
|
217
|
+
return out.sort((a, b) => a.status - b.status);
|
|
218
|
+
}
|
|
219
|
+
/** JSON first; anything else only if the operation speaks nothing else. */
|
|
220
|
+
function pickContent(content) {
|
|
221
|
+
if (!isObject(content)) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
const entries = Object.entries(content).filter(([, v]) => isObject(v) && isObject(v.schema));
|
|
225
|
+
const json = entries.find(([type]) => /\bjson\b/.test(type)) ?? entries[0];
|
|
226
|
+
return json ? json[1].schema : undefined;
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Settling names
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
/**
|
|
232
|
+
* Assigns every component schema its final id and rewrites the corpus to use
|
|
233
|
+
* it. A name owned by one document keeps it; a name two documents both claim
|
|
234
|
+
* becomes `<slug>.<Name>` on both sides, so an id never silently changes
|
|
235
|
+
* meaning depending on which file was read first.
|
|
236
|
+
*/
|
|
237
|
+
function settle(loaded) {
|
|
238
|
+
const owners = new Map();
|
|
239
|
+
for (const one of loaded) {
|
|
240
|
+
for (const name of one.types.keys()) {
|
|
241
|
+
owners.set(name, (owners.get(name) ?? 0) + 1);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const ids = loaded.map((one) => {
|
|
245
|
+
const map = new Map();
|
|
246
|
+
for (const name of one.types.keys()) {
|
|
247
|
+
map.set(name, owners.get(name) === 1 ? name : `${one.slug}.${name}`);
|
|
248
|
+
}
|
|
249
|
+
return map;
|
|
250
|
+
});
|
|
251
|
+
const types = {};
|
|
252
|
+
const typeSource = {};
|
|
253
|
+
const operations = [];
|
|
254
|
+
const seenOps = new Set();
|
|
255
|
+
loaded.forEach((one, index) => {
|
|
256
|
+
const map = ids[index];
|
|
257
|
+
for (const [name, schema] of one.types) {
|
|
258
|
+
const id = map.get(name);
|
|
259
|
+
types[id] = rewrite(schema, map);
|
|
260
|
+
typeSource[id] = one.doc.source;
|
|
261
|
+
}
|
|
262
|
+
for (const op of one.operations) {
|
|
263
|
+
operations.push({
|
|
264
|
+
...op,
|
|
265
|
+
operationId: unique(op.operationId, one.slug, seenOps),
|
|
266
|
+
params: op.params.map((p) => ({ ...p, schema: rewrite(p.schema, map) })),
|
|
267
|
+
requestBody: op.requestBody && {
|
|
268
|
+
...op.requestBody,
|
|
269
|
+
schema: rewrite(op.requestBody.schema, map),
|
|
270
|
+
},
|
|
271
|
+
responses: op.responses.map((r) => ({
|
|
272
|
+
...r,
|
|
273
|
+
schema: r.schema && rewrite(r.schema, map),
|
|
274
|
+
})),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return { docs: loaded.map((one) => one.doc), operations, types, typeSource };
|
|
279
|
+
}
|
|
280
|
+
function unique(id, slug, seen) {
|
|
281
|
+
let candidate = seen.has(id) ? `${slug}.${id}` : id;
|
|
282
|
+
for (let n = 2; seen.has(candidate); n++) {
|
|
283
|
+
candidate = `${slug}.${id}_${n}`;
|
|
284
|
+
}
|
|
285
|
+
seen.add(candidate);
|
|
286
|
+
return candidate;
|
|
287
|
+
}
|
|
288
|
+
/** Points every internal `$ref` at the settled id, in one flat `$defs` space. */
|
|
289
|
+
function rewrite(value, ids) {
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
return value.map((v) => rewrite(v, ids));
|
|
292
|
+
}
|
|
293
|
+
if (!isObject(value)) {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
298
|
+
if (key === '$ref' && typeof inner === 'string') {
|
|
299
|
+
const name = /^#\/(?:components\/schemas|definitions|\$defs)\/(.+)$/.exec(inner)?.[1];
|
|
300
|
+
const decoded = name && decodeURIComponent(name.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
301
|
+
out.$ref = decoded ? `#/$defs/${ids.get(decoded) ?? decoded}` : inner;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
out[key] = rewrite(inner, ids);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Connection, type Table } from '@lancedb/lancedb';
|
|
2
|
+
import type { EntityRecord } from './entities.ts';
|
|
3
|
+
export interface StoreFilter {
|
|
4
|
+
kinds?: readonly string[];
|
|
5
|
+
directions?: readonly string[];
|
|
6
|
+
methodTypes?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export interface Hit {
|
|
9
|
+
record: EntityRecord;
|
|
10
|
+
/** 0-based position in this query's own result list */
|
|
11
|
+
rank: number;
|
|
12
|
+
/** what the store thought, where it says; 0 when it does not */
|
|
13
|
+
relevance: number;
|
|
14
|
+
}
|
|
15
|
+
export interface WriteResult {
|
|
16
|
+
rows: number;
|
|
17
|
+
fts: boolean;
|
|
18
|
+
vector: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function writeStore(dir: string, rows: readonly EntityRecord[], vectors: readonly Float32Array[]): Promise<WriteResult>;
|
|
21
|
+
export declare class EntityStore {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(db: Connection, table: Table);
|
|
24
|
+
static open(dir: string): Promise<EntityStore>;
|
|
25
|
+
/**
|
|
26
|
+
* One hybrid query: the same string goes to the full-text side and, as a
|
|
27
|
+
* vector, to the nearest-neighbour side, and LanceDB fuses the two.
|
|
28
|
+
*/
|
|
29
|
+
search(text: string, vector: Float32Array, filter: StoreFilter, limit: number): Promise<Hit[]>;
|
|
30
|
+
close(): void;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { connect, Index } from '@lancedb/lancedb';
|
|
2
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
3
|
+
import { lancePath } from "./files.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// The hybrid index
|
|
6
|
+
//
|
|
7
|
+
// LanceDB holds one table: a row per graph node, a materialized `text` column
|
|
8
|
+
// carrying both the embedding and the full-text index, and the handful of
|
|
9
|
+
// enum columns a query filters on.
|
|
10
|
+
//
|
|
11
|
+
// Those enums are the *only* thing that reaches the SQL predicate. Exclusion
|
|
12
|
+
// lists — which arrive from a model, or from a shell — are applied afterwards
|
|
13
|
+
// in JavaScript. Escaping them into `where()` would work right up until it did
|
|
14
|
+
// not, and there is nothing here that a filter string buys.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const TABLE = 'entities';
|
|
17
|
+
/** Below this an IVF index has nothing to train on, and a flat scan is faster. */
|
|
18
|
+
const VECTOR_INDEX_MIN_ROWS = 2000;
|
|
19
|
+
const KINDS = new Set(['method', 'type', 'property']);
|
|
20
|
+
const DIRECTIONS = new Set(['input', 'output', 'both', 'none']);
|
|
21
|
+
const METHOD_TYPES = new Set(['read_only', 'read_write', 'n/a']);
|
|
22
|
+
export async function writeStore(dir, rows, vectors) {
|
|
23
|
+
if (rows.length === 0) {
|
|
24
|
+
throw new CliError('the documents describe nothing to index', EXIT.invalid, 'they have no operations and no component schemas');
|
|
25
|
+
}
|
|
26
|
+
const db = await connect(lancePath(dir));
|
|
27
|
+
// Every column is always populated — never null — so the Arrow schema is
|
|
28
|
+
// inferred from the first row without a declaration to keep in step.
|
|
29
|
+
const table = await db.createTable(TABLE, rows.map((row, i) => ({ ...row, vector: vectors[i] })), { mode: 'overwrite' });
|
|
30
|
+
await table.createIndex('text', { config: Index.fts() });
|
|
31
|
+
for (const column of ['kind', 'direction', 'methodType']) {
|
|
32
|
+
await table.createIndex(column, { config: Index.bitmap() });
|
|
33
|
+
}
|
|
34
|
+
const vector = rows.length >= VECTOR_INDEX_MIN_ROWS;
|
|
35
|
+
if (vector) {
|
|
36
|
+
await table.createIndex('vector');
|
|
37
|
+
}
|
|
38
|
+
db.close();
|
|
39
|
+
return { rows: rows.length, fts: true, vector };
|
|
40
|
+
}
|
|
41
|
+
export class EntityStore {
|
|
42
|
+
#db;
|
|
43
|
+
#table;
|
|
44
|
+
constructor(db, table) {
|
|
45
|
+
this.#db = db;
|
|
46
|
+
this.#table = table;
|
|
47
|
+
}
|
|
48
|
+
static async open(dir) {
|
|
49
|
+
const db = await connect(lancePath(dir));
|
|
50
|
+
try {
|
|
51
|
+
return new EntityStore(db, await db.openTable(TABLE));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
db.close();
|
|
55
|
+
throw new CliError(`${dir} holds no searchable table`, EXIT.invalid, 'rebuild it with `zen rag schema index`');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One hybrid query: the same string goes to the full-text side and, as a
|
|
60
|
+
* vector, to the nearest-neighbour side, and LanceDB fuses the two.
|
|
61
|
+
*/
|
|
62
|
+
async search(text, vector, filter, limit) {
|
|
63
|
+
const predicate = where(filter);
|
|
64
|
+
let query = this.#table.query().nearestToText(text).nearestTo(vector).limit(limit);
|
|
65
|
+
if (predicate) {
|
|
66
|
+
query = query.where(predicate);
|
|
67
|
+
}
|
|
68
|
+
const rows = (await query.toArray());
|
|
69
|
+
return rows.map((row, rank) => ({
|
|
70
|
+
record: strip(row),
|
|
71
|
+
rank,
|
|
72
|
+
relevance: score(row),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
close() {
|
|
76
|
+
this.#db.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/** Closed vocabularies only. Anything else is a bug, and is treated as one. */
|
|
81
|
+
function where(filter) {
|
|
82
|
+
const clauses = [
|
|
83
|
+
clause('kind', filter.kinds, KINDS),
|
|
84
|
+
clause('direction', filter.directions, DIRECTIONS),
|
|
85
|
+
clause('methodType', filter.methodTypes, METHOD_TYPES),
|
|
86
|
+
].filter(Boolean);
|
|
87
|
+
return clauses.join(' AND ');
|
|
88
|
+
}
|
|
89
|
+
function clause(column, values, allowed) {
|
|
90
|
+
if (!values || values.length === 0) {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
for (const value of values) {
|
|
94
|
+
if (!allowed.has(value)) {
|
|
95
|
+
throw new Error(`${column} cannot be ${JSON.stringify(value)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return `${column} IN (${values.map((v) => `'${v}'`).join(', ')})`;
|
|
99
|
+
}
|
|
100
|
+
/** A hybrid query reports relevance; a one-sided one reports a distance. */
|
|
101
|
+
function score(row) {
|
|
102
|
+
const relevance = row._relevance_score ?? row._score;
|
|
103
|
+
if (typeof relevance === 'number') {
|
|
104
|
+
return relevance;
|
|
105
|
+
}
|
|
106
|
+
const distance = row._distance;
|
|
107
|
+
return typeof distance === 'number' ? 1 / (1 + distance) : 0;
|
|
108
|
+
}
|
|
109
|
+
function strip(row) {
|
|
110
|
+
return {
|
|
111
|
+
id: row.id,
|
|
112
|
+
kind: row.kind,
|
|
113
|
+
name: row.name,
|
|
114
|
+
parent: row.parent,
|
|
115
|
+
doc: row.doc,
|
|
116
|
+
direction: row.direction,
|
|
117
|
+
methodType: row.methodType,
|
|
118
|
+
httpMethod: row.httpMethod,
|
|
119
|
+
path: row.path,
|
|
120
|
+
source: row.source,
|
|
121
|
+
signature: row.signature,
|
|
122
|
+
required: row.required,
|
|
123
|
+
text: row.text,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ApiGraph, NodeAttrs, Relation } from './graph.ts';
|
|
2
|
+
export interface Seed {
|
|
3
|
+
id: string;
|
|
4
|
+
/** the query string that found it */
|
|
5
|
+
term: string;
|
|
6
|
+
/** which field of the query that string came from */
|
|
7
|
+
field: string;
|
|
8
|
+
score: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SubgraphNode {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
attributes: NodeAttrs;
|
|
14
|
+
/** the search found this one; everything else is here to connect it */
|
|
15
|
+
hit: boolean;
|
|
16
|
+
score: number;
|
|
17
|
+
}
|
|
18
|
+
export interface SubgraphEdge {
|
|
19
|
+
source: string;
|
|
20
|
+
target: string;
|
|
21
|
+
relation: Relation;
|
|
22
|
+
status: number;
|
|
23
|
+
in: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Subgraph {
|
|
26
|
+
nodes: SubgraphNode[];
|
|
27
|
+
edges: SubgraphEdge[];
|
|
28
|
+
hits: string[];
|
|
29
|
+
score: number;
|
|
30
|
+
/** nodes were dropped to stay inside the budget */
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface StitchOptions {
|
|
34
|
+
maxHops?: number;
|
|
35
|
+
maxNodes?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare const DEFAULT_MAX_HOPS = 3;
|
|
38
|
+
export declare const DEFAULT_MAX_NODES = 200;
|
|
39
|
+
export declare function stitch(graph: ApiGraph, seeds: readonly Seed[], options?: StitchOptions): Subgraph[];
|
|
40
|
+
/** Breadth-first, ignoring edge direction, giving up past `maxHops`. */
|
|
41
|
+
export declare function path(graph: ApiGraph, from: string, to: string, maxHops: number): string[] | undefined;
|
|
42
|
+
//# sourceMappingURL=subgraph.d.ts.map
|