@romiluz/clawmongo 0.1.0-rc.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 +22 -0
- package/README.md +3 -0
- package/dist/cli/boundary-contract-smoke.js +108 -0
- package/dist/cli/embedding-policy-smoke.js +66 -0
- package/dist/cli/embedding-provider-live-smoke.js +94 -0
- package/dist/cli/embedding-provider-smoke.js +81 -0
- package/dist/cli/embedding-provider-voyage-batch-smoke.js +129 -0
- package/dist/cli/gateway-smoke.js +65 -0
- package/dist/cli/health.js +17 -0
- package/dist/cli/index-budget-smoke.js +14 -0
- package/dist/cli/key-schema-smoke.js +118 -0
- package/dist/cli/orchestrator-smoke.js +75 -0
- package/dist/cli/provider-adapter-smoke.js +61 -0
- package/dist/cli/replica-track-check.js +108 -0
- package/dist/cli/retrieval-compat-check.js +196 -0
- package/dist/cli/retrieval-contract-smoke.js +72 -0
- package/dist/cli/retrieval-eval.js +226 -0
- package/dist/cli/retrieval-provider-smoke.js +52 -0
- package/dist/cli/retrieval-seed-reembed-smoke.js +54 -0
- package/dist/cli/retrieval-seed.js +312 -0
- package/dist/cli/runtime-contract-smoke.js +201 -0
- package/dist/cli/session-key-smoke.js +62 -0
- package/dist/cli/sprint-checks.js +129 -0
- package/dist/cli/tool-runtime-smoke.js +68 -0
- package/dist/config/deployment-profiles.js +41 -0
- package/dist/config/env.js +49 -0
- package/dist/contracts/v1.js +1 -0
- package/dist/contracts/validators.js +153 -0
- package/dist/identity/key-schema.js +31 -0
- package/dist/main.js +97 -0
- package/dist/modules/eventing/index.js +58 -0
- package/dist/modules/eventing/service.js +139 -0
- package/dist/modules/gateway/index.js +44 -0
- package/dist/modules/gateway/service.js +118 -0
- package/dist/modules/ingestion/index.js +46 -0
- package/dist/modules/ingestion/service.js +56 -0
- package/dist/modules/mongo-store/index.js +21 -0
- package/dist/modules/observability/index.js +6 -0
- package/dist/modules/orchestrator/index.js +49 -0
- package/dist/modules/orchestrator/service.js +220 -0
- package/dist/modules/policy-engine/index.js +34 -0
- package/dist/modules/policy-engine/service.js +42 -0
- package/dist/modules/provider-adapter/index.js +37 -0
- package/dist/modules/provider-adapter/service.js +98 -0
- package/dist/modules/retrieval/index.js +64 -0
- package/dist/modules/stub.js +17 -0
- package/dist/modules/tool-runtime/index.js +30 -0
- package/dist/modules/tool-runtime/service.js +84 -0
- package/dist/retrieval/contracts.js +1 -0
- package/dist/retrieval/embeddings/policy.js +42 -0
- package/dist/retrieval/embeddings/provider.js +424 -0
- package/dist/retrieval/embeddings/query-vector.js +34 -0
- package/dist/retrieval/embeddings/voyage-remote-batch.js +312 -0
- package/dist/retrieval/engine.js +130 -0
- package/dist/retrieval/fixtures.js +123 -0
- package/dist/retrieval/providers/fusion.js +390 -0
- package/dist/retrieval/providers/lexical.js +267 -0
- package/dist/retrieval/providers/shared.js +88 -0
- package/dist/retrieval/providers/vector.js +274 -0
- package/dist/retrieval/reembed.js +116 -0
- package/dist/runtime/bootstrap.js +65 -0
- package/dist/runtime/types.js +1 -0
- package/dist/session/session-key.js +128 -0
- package/dist/store/mongo/bootstrap.js +129 -0
- package/dist/store/mongo/indexes.js +110 -0
- package/dist/store/mongo/validators.js +238 -0
- package/package.json +81 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const VOYAGE_BATCH_ENDPOINT = "/v1/embeddings";
|
|
2
|
+
const VOYAGE_BATCH_COMPLETION_WINDOW = "12h";
|
|
3
|
+
const VOYAGE_BATCH_MAX_REQUESTS = 50_000;
|
|
4
|
+
function normalizeBaseUrl(baseUrl) {
|
|
5
|
+
const normalized = baseUrl.trim().replace(/\/+$/, "");
|
|
6
|
+
if (!normalized) {
|
|
7
|
+
throw new Error("Voyage remote batch baseUrl is required.");
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
function parseRetryAfterMs(value) {
|
|
12
|
+
if (!value) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const seconds = Number(value);
|
|
16
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
17
|
+
return Math.round(seconds * 1000);
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
function splitRequests(requests) {
|
|
25
|
+
if (requests.length <= VOYAGE_BATCH_MAX_REQUESTS) {
|
|
26
|
+
return [requests];
|
|
27
|
+
}
|
|
28
|
+
const groups = [];
|
|
29
|
+
for (let index = 0; index < requests.length; index += VOYAGE_BATCH_MAX_REQUESTS) {
|
|
30
|
+
groups.push(requests.slice(index, index + VOYAGE_BATCH_MAX_REQUESTS));
|
|
31
|
+
}
|
|
32
|
+
return groups;
|
|
33
|
+
}
|
|
34
|
+
async function requestJsonWithRetry(options) {
|
|
35
|
+
const attempts = options.maxRetries + 1;
|
|
36
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
37
|
+
const response = await fetch(options.url, {
|
|
38
|
+
method: options.method ?? "GET",
|
|
39
|
+
headers: options.headers,
|
|
40
|
+
body: options.body
|
|
41
|
+
});
|
|
42
|
+
if ((response.status === 429 || response.status >= 500) && attempt < options.maxRetries) {
|
|
43
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
44
|
+
const waitMs = retryAfterMs ?? Math.pow(2, attempt) * 1000;
|
|
45
|
+
await sleep(waitMs);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const text = await response.text().catch(() => "");
|
|
50
|
+
throw new Error(`Voyage remote batch request failed (${response.status}): ${text}`);
|
|
51
|
+
}
|
|
52
|
+
return (await response.json());
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Voyage remote batch retry budget exhausted after ${attempts} attempts.`);
|
|
55
|
+
}
|
|
56
|
+
async function uploadBatchInputFile(params) {
|
|
57
|
+
const lines = params.requests.map((request) => JSON.stringify({
|
|
58
|
+
custom_id: request.customId,
|
|
59
|
+
body: { input: request.text }
|
|
60
|
+
}));
|
|
61
|
+
const jsonl = lines.join("\n");
|
|
62
|
+
const form = new FormData();
|
|
63
|
+
form.append("purpose", "batch");
|
|
64
|
+
form.append("file", new Blob([jsonl], { type: "application/jsonl" }), `clawmongo-memory-embeddings.${Date.now()}.jsonl`);
|
|
65
|
+
const response = await fetch(`${params.baseUrl}/files`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
authorization: `Bearer ${params.apiKey}`
|
|
69
|
+
},
|
|
70
|
+
body: form
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const text = await response.text().catch(() => "");
|
|
74
|
+
throw new Error(`Voyage batch file upload failed (${response.status}): ${text}`);
|
|
75
|
+
}
|
|
76
|
+
const payload = (await response.json());
|
|
77
|
+
if (!payload.id) {
|
|
78
|
+
throw new Error("Voyage batch file upload failed: missing file id.");
|
|
79
|
+
}
|
|
80
|
+
return payload.id;
|
|
81
|
+
}
|
|
82
|
+
async function createBatch(params) {
|
|
83
|
+
return requestJsonWithRetry({
|
|
84
|
+
url: `${params.baseUrl}/batches`,
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
authorization: `Bearer ${params.apiKey}`,
|
|
88
|
+
"content-type": "application/json"
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
input_file_id: params.inputFileId,
|
|
92
|
+
endpoint: VOYAGE_BATCH_ENDPOINT,
|
|
93
|
+
completion_window: VOYAGE_BATCH_COMPLETION_WINDOW,
|
|
94
|
+
request_params: {
|
|
95
|
+
model: params.model,
|
|
96
|
+
input_type: "document"
|
|
97
|
+
},
|
|
98
|
+
metadata: {
|
|
99
|
+
source: "clawmongo-memory"
|
|
100
|
+
}
|
|
101
|
+
}),
|
|
102
|
+
maxRetries: params.maxRetries
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function readBatchStatus(params) {
|
|
106
|
+
return requestJsonWithRetry({
|
|
107
|
+
url: `${params.baseUrl}/batches/${params.batchId}`,
|
|
108
|
+
headers: {
|
|
109
|
+
authorization: `Bearer ${params.apiKey}`,
|
|
110
|
+
"content-type": "application/json"
|
|
111
|
+
},
|
|
112
|
+
maxRetries: 0
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function readErrorFileMessage(params) {
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch(`${params.baseUrl}/files/${params.errorFileId}/content`, {
|
|
118
|
+
headers: {
|
|
119
|
+
authorization: `Bearer ${params.apiKey}`,
|
|
120
|
+
"content-type": "application/json"
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
const text = await response.text().catch(() => "");
|
|
125
|
+
return `error file unavailable (${response.status}): ${text}`;
|
|
126
|
+
}
|
|
127
|
+
const content = await response.text();
|
|
128
|
+
if (!content.trim()) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const lines = content
|
|
132
|
+
.split("\n")
|
|
133
|
+
.map((line) => line.trim())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.map((line) => JSON.parse(line));
|
|
136
|
+
const firstMessage = lines.find((line) => line.error?.message || line.response?.body?.error?.message);
|
|
137
|
+
return (firstMessage?.error?.message ??
|
|
138
|
+
firstMessage?.response?.body?.error?.message ??
|
|
139
|
+
undefined);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return error instanceof Error ? error.message : String(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function waitForBatchCompletion(params) {
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
let status = params.initialStatus;
|
|
148
|
+
while (true) {
|
|
149
|
+
const current = status ??
|
|
150
|
+
(await readBatchStatus({
|
|
151
|
+
baseUrl: params.baseUrl,
|
|
152
|
+
apiKey: params.apiKey,
|
|
153
|
+
batchId: params.batchId
|
|
154
|
+
}));
|
|
155
|
+
const state = current.status ?? "unknown";
|
|
156
|
+
if (state === "completed") {
|
|
157
|
+
if (!current.output_file_id) {
|
|
158
|
+
throw new Error(`Voyage batch ${params.batchId} completed without output file.`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
outputFileId: current.output_file_id,
|
|
162
|
+
...(current.error_file_id ? { errorFileId: current.error_file_id } : {})
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (["failed", "expired", "cancelled", "canceled"].includes(state)) {
|
|
166
|
+
const errorMessage = current.error_file_id
|
|
167
|
+
? await readErrorFileMessage({
|
|
168
|
+
baseUrl: params.baseUrl,
|
|
169
|
+
apiKey: params.apiKey,
|
|
170
|
+
errorFileId: current.error_file_id
|
|
171
|
+
})
|
|
172
|
+
: undefined;
|
|
173
|
+
throw new Error(`Voyage batch ${params.batchId} ${state}${errorMessage ? `: ${errorMessage}` : ""}`);
|
|
174
|
+
}
|
|
175
|
+
if (!params.wait) {
|
|
176
|
+
throw new Error(`Voyage batch ${params.batchId} still ${state}; wait mode is disabled.`);
|
|
177
|
+
}
|
|
178
|
+
if (Date.now() - startedAt > params.timeoutMs) {
|
|
179
|
+
throw new Error(`Voyage batch ${params.batchId} timed out after ${params.timeoutMs}ms.`);
|
|
180
|
+
}
|
|
181
|
+
await sleep(params.pollIntervalMs);
|
|
182
|
+
status = undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function readOutputEmbeddings(params) {
|
|
186
|
+
const response = await fetch(`${params.baseUrl}/files/${params.outputFileId}/content`, {
|
|
187
|
+
headers: {
|
|
188
|
+
authorization: `Bearer ${params.apiKey}`,
|
|
189
|
+
"content-type": "application/json"
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const text = await response.text().catch(() => "");
|
|
194
|
+
throw new Error(`Voyage batch output download failed (${response.status}): ${text}`);
|
|
195
|
+
}
|
|
196
|
+
const content = await response.text();
|
|
197
|
+
const lines = content
|
|
198
|
+
.split("\n")
|
|
199
|
+
.map((line) => line.trim())
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.map((line) => JSON.parse(line));
|
|
202
|
+
const requestedIds = new Set(params.requests.map((request) => request.customId));
|
|
203
|
+
const embeddings = new Map();
|
|
204
|
+
const errors = [];
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
const customId = line.custom_id;
|
|
207
|
+
if (!customId || !requestedIds.has(customId)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (line.error?.message) {
|
|
211
|
+
errors.push(`${customId}: ${line.error.message}`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const statusCode = line.response?.status_code ?? 0;
|
|
215
|
+
if (statusCode >= 400) {
|
|
216
|
+
const message = line.response?.body?.error?.message ??
|
|
217
|
+
"unknown voyage batch response error";
|
|
218
|
+
errors.push(`${customId}: ${message}`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const embedding = line.response?.body?.data?.[0]?.embedding;
|
|
222
|
+
if (!Array.isArray(embedding) || embedding.length === 0) {
|
|
223
|
+
errors.push(`${customId}: empty embedding`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const numeric = embedding.filter((value) => typeof value === "number");
|
|
227
|
+
if (numeric.length !== embedding.length) {
|
|
228
|
+
errors.push(`${customId}: non-numeric embedding values`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
embeddings.set(customId, numeric);
|
|
232
|
+
}
|
|
233
|
+
if (errors.length > 0) {
|
|
234
|
+
throw new Error(`Voyage batch output errors: ${errors.join("; ")}`);
|
|
235
|
+
}
|
|
236
|
+
const missing = params.requests.filter((request) => !embeddings.has(request.customId));
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
throw new Error(`Voyage batch output missing ${missing.length} embeddings.`);
|
|
239
|
+
}
|
|
240
|
+
return embeddings;
|
|
241
|
+
}
|
|
242
|
+
async function runWithConcurrency(tasks, concurrency) {
|
|
243
|
+
const safeConcurrency = Math.max(1, Math.floor(concurrency));
|
|
244
|
+
const results = new Array(tasks.length);
|
|
245
|
+
let nextIndex = 0;
|
|
246
|
+
async function worker() {
|
|
247
|
+
while (nextIndex < tasks.length) {
|
|
248
|
+
const current = nextIndex;
|
|
249
|
+
nextIndex += 1;
|
|
250
|
+
const task = tasks[current];
|
|
251
|
+
if (!task) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
results[current] = await task();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const workers = new Array(Math.min(safeConcurrency, tasks.length))
|
|
258
|
+
.fill(null)
|
|
259
|
+
.map(() => worker());
|
|
260
|
+
await Promise.all(workers);
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
export async function runVoyageRemoteEmbeddingBatches(options) {
|
|
264
|
+
if (options.requests.length === 0) {
|
|
265
|
+
return { embeddingsByCustomId: new Map(), batchIds: [] };
|
|
266
|
+
}
|
|
267
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
268
|
+
const groups = splitRequests(options.requests);
|
|
269
|
+
const embeddingsByCustomId = new Map();
|
|
270
|
+
const batchIds = [];
|
|
271
|
+
const tasks = groups.map((group) => async () => {
|
|
272
|
+
const inputFileId = await uploadBatchInputFile({
|
|
273
|
+
baseUrl,
|
|
274
|
+
apiKey: options.apiKey,
|
|
275
|
+
requests: group
|
|
276
|
+
});
|
|
277
|
+
const created = await createBatch({
|
|
278
|
+
baseUrl,
|
|
279
|
+
apiKey: options.apiKey,
|
|
280
|
+
inputFileId,
|
|
281
|
+
model: options.model,
|
|
282
|
+
maxRetries: options.maxRetries
|
|
283
|
+
});
|
|
284
|
+
if (!created.id) {
|
|
285
|
+
throw new Error("Voyage batch creation failed: missing batch id.");
|
|
286
|
+
}
|
|
287
|
+
batchIds.push(created.id);
|
|
288
|
+
const completed = await waitForBatchCompletion({
|
|
289
|
+
baseUrl,
|
|
290
|
+
apiKey: options.apiKey,
|
|
291
|
+
batchId: created.id,
|
|
292
|
+
wait: options.wait,
|
|
293
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
294
|
+
timeoutMs: options.timeoutMs,
|
|
295
|
+
initialStatus: created
|
|
296
|
+
});
|
|
297
|
+
const embeddings = await readOutputEmbeddings({
|
|
298
|
+
baseUrl,
|
|
299
|
+
apiKey: options.apiKey,
|
|
300
|
+
outputFileId: completed.outputFileId,
|
|
301
|
+
requests: group
|
|
302
|
+
});
|
|
303
|
+
for (const [customId, vector] of embeddings.entries()) {
|
|
304
|
+
embeddingsByCustomId.set(customId, vector);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
await runWithConcurrency(tasks, options.concurrency);
|
|
308
|
+
return {
|
|
309
|
+
embeddingsByCustomId,
|
|
310
|
+
batchIds
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { applyEmbeddingPolicyToScope, resolveEmbeddingPolicy } from "./embeddings/policy.js";
|
|
2
|
+
function normalizeScopeForDiagnostics(scopeFilters) {
|
|
3
|
+
const normalized = {};
|
|
4
|
+
for (const [key, value] of Object.entries(scopeFilters)) {
|
|
5
|
+
if (typeof value === "string" ||
|
|
6
|
+
typeof value === "number" ||
|
|
7
|
+
typeof value === "boolean") {
|
|
8
|
+
normalized[key] = value;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
function defaultStageTelemetry(stage, resultCount, scopeFilters) {
|
|
14
|
+
return {
|
|
15
|
+
stage,
|
|
16
|
+
strategy: `${stage}-default`,
|
|
17
|
+
degraded: false,
|
|
18
|
+
nativeAttempted: false,
|
|
19
|
+
nativeSucceeded: false,
|
|
20
|
+
fallbackUsed: false,
|
|
21
|
+
candidateCount: resultCount,
|
|
22
|
+
resultCount,
|
|
23
|
+
latencyMs: 0,
|
|
24
|
+
scopeFilter: normalizeScopeForDiagnostics(scopeFilters),
|
|
25
|
+
notes: ["Retriever does not expose telemetry; default stage telemetry applied."]
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function buildSnippet(text, maxLength = 220) {
|
|
29
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
30
|
+
if (compact.length <= maxLength) {
|
|
31
|
+
return compact;
|
|
32
|
+
}
|
|
33
|
+
return `${compact.slice(0, maxLength - 1)}…`;
|
|
34
|
+
}
|
|
35
|
+
function shapeResultEnvelope(hit) {
|
|
36
|
+
const metadata = hit.metadata ?? {};
|
|
37
|
+
const docId = typeof metadata.doc_id === "string" ? metadata.doc_id : null;
|
|
38
|
+
return {
|
|
39
|
+
id: hit.id,
|
|
40
|
+
score: Number(hit.score.toFixed(6)),
|
|
41
|
+
source: hit.source,
|
|
42
|
+
snippet: buildSnippet(hit.text),
|
|
43
|
+
citation: {
|
|
44
|
+
chunk_id: hit.id,
|
|
45
|
+
document_id: docId
|
|
46
|
+
},
|
|
47
|
+
sourceMetadata: metadata
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function readLexicalStage(retriever, query) {
|
|
51
|
+
if ("searchWithTelemetry" in retriever) {
|
|
52
|
+
const stageAware = retriever;
|
|
53
|
+
return stageAware.searchWithTelemetry(query);
|
|
54
|
+
}
|
|
55
|
+
const hits = await retriever.search(query);
|
|
56
|
+
return {
|
|
57
|
+
hits,
|
|
58
|
+
telemetry: defaultStageTelemetry("lexical", hits.length, query.scopeFilters)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function readVectorStage(retriever, query) {
|
|
62
|
+
if ("searchWithTelemetry" in retriever) {
|
|
63
|
+
const stageAware = retriever;
|
|
64
|
+
return stageAware.searchWithTelemetry(query);
|
|
65
|
+
}
|
|
66
|
+
const hits = await retriever.search(query);
|
|
67
|
+
return {
|
|
68
|
+
hits,
|
|
69
|
+
telemetry: defaultStageTelemetry("vector", hits.length, query.scopeFilters)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export class RetrievalEngine {
|
|
73
|
+
lexical;
|
|
74
|
+
vector;
|
|
75
|
+
fusion;
|
|
76
|
+
constructor(lexical, vector, fusion) {
|
|
77
|
+
this.lexical = lexical;
|
|
78
|
+
this.vector = vector;
|
|
79
|
+
this.fusion = fusion;
|
|
80
|
+
}
|
|
81
|
+
async retrieve(query) {
|
|
82
|
+
const embeddingPolicy = resolveEmbeddingPolicy(query.scopeFilters);
|
|
83
|
+
const scopedQuery = {
|
|
84
|
+
...query,
|
|
85
|
+
scopeFilters: applyEmbeddingPolicyToScope(query.scopeFilters, embeddingPolicy)
|
|
86
|
+
};
|
|
87
|
+
const lexicalStage = await readLexicalStage(this.lexical, scopedQuery);
|
|
88
|
+
const vectorStage = await readVectorStage(this.vector, scopedQuery);
|
|
89
|
+
const fusionResult = await this.fusion.fuse({
|
|
90
|
+
query: scopedQuery,
|
|
91
|
+
lexicalHits: lexicalStage.hits,
|
|
92
|
+
vectorHits: vectorStage.hits
|
|
93
|
+
});
|
|
94
|
+
const pipelineMode = scopedQuery.preferNativeFusion ? "hybrid" : "lexical";
|
|
95
|
+
const selectedHits = scopedQuery.preferNativeFusion
|
|
96
|
+
? fusionResult.hits
|
|
97
|
+
: lexicalStage.hits.slice(0, scopedQuery.topK);
|
|
98
|
+
const degraded = lexicalStage.telemetry.degraded ||
|
|
99
|
+
vectorStage.telemetry.degraded ||
|
|
100
|
+
fusionResult.telemetry.degraded;
|
|
101
|
+
const results = selectedHits.map(shapeResultEnvelope);
|
|
102
|
+
return {
|
|
103
|
+
mode: pipelineMode,
|
|
104
|
+
degraded,
|
|
105
|
+
hits: selectedHits,
|
|
106
|
+
results,
|
|
107
|
+
diagnostics: {
|
|
108
|
+
scope: normalizeScopeForDiagnostics(scopedQuery.scopeFilters),
|
|
109
|
+
lexical: lexicalStage.telemetry,
|
|
110
|
+
vector: vectorStage.telemetry,
|
|
111
|
+
fusion: fusionResult.telemetry
|
|
112
|
+
},
|
|
113
|
+
debug: {
|
|
114
|
+
lexicalCount: lexicalStage.hits.length,
|
|
115
|
+
vectorCount: vectorStage.hits.length,
|
|
116
|
+
fusionStrategy: fusionResult.telemetry.strategy,
|
|
117
|
+
embeddingPolicy: {
|
|
118
|
+
provider: embeddingPolicy.provider,
|
|
119
|
+
model: embeddingPolicy.model,
|
|
120
|
+
source: embeddingPolicy.source
|
|
121
|
+
},
|
|
122
|
+
modeContract: {
|
|
123
|
+
profile: scopedQuery.profile,
|
|
124
|
+
preferNativeFusion: scopedQuery.preferNativeFusion,
|
|
125
|
+
pipelineMode
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const retrievalQueryClassSchema = z.enum([
|
|
6
|
+
"semantic_paraphrase",
|
|
7
|
+
"exact_token",
|
|
8
|
+
"mixed_intent",
|
|
9
|
+
"recency_scoped",
|
|
10
|
+
"channel_thread_scoped"
|
|
11
|
+
]);
|
|
12
|
+
const corpusChunkSchema = z.object({
|
|
13
|
+
doc_id: z.string().min(1),
|
|
14
|
+
chunk_id: z.string().min(1),
|
|
15
|
+
tenant_id: z.string().min(1),
|
|
16
|
+
workspace_id: z.string().min(1),
|
|
17
|
+
source_type: z.enum(["memory", "conversation", "operational"]),
|
|
18
|
+
channel: z.string().min(1),
|
|
19
|
+
thread_key: z.string().min(1),
|
|
20
|
+
timestamp: z.string().datetime(),
|
|
21
|
+
language: z.string().min(1),
|
|
22
|
+
embedding_provider: z.string().min(1),
|
|
23
|
+
embedding_model: z.string().min(1),
|
|
24
|
+
schema_version: z.number().int().positive(),
|
|
25
|
+
text: z.string().min(1),
|
|
26
|
+
embedding: z.array(z.number()).min(4)
|
|
27
|
+
});
|
|
28
|
+
const fixtureCorpusSchema = z.object({
|
|
29
|
+
dataset_id: z.string().min(1),
|
|
30
|
+
version: z.number().int().positive(),
|
|
31
|
+
generated_at: z.string().datetime(),
|
|
32
|
+
chunks: z.array(corpusChunkSchema).min(1)
|
|
33
|
+
});
|
|
34
|
+
const fixtureQuerySchema = z.object({
|
|
35
|
+
query_id: z.string().min(1),
|
|
36
|
+
query_class: retrievalQueryClassSchema,
|
|
37
|
+
query: z.string().min(1),
|
|
38
|
+
tenant_id: z.string().min(1),
|
|
39
|
+
workspace_id: z.string().min(1),
|
|
40
|
+
channel: z.string().min(1).optional(),
|
|
41
|
+
thread_key: z.string().min(1).optional(),
|
|
42
|
+
time_range: z
|
|
43
|
+
.object({
|
|
44
|
+
from: z.string().datetime(),
|
|
45
|
+
to: z.string().datetime()
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
expected_chunk_ids: z.array(z.string().min(1)).min(1),
|
|
49
|
+
critical_exact: z.boolean(),
|
|
50
|
+
scope_valid: z.boolean()
|
|
51
|
+
});
|
|
52
|
+
const fixtureQueriesSchema = z.object({
|
|
53
|
+
dataset_id: z.string().min(1),
|
|
54
|
+
version: z.number().int().positive(),
|
|
55
|
+
queries: z.array(fixtureQuerySchema).min(1)
|
|
56
|
+
});
|
|
57
|
+
const requiredQueryClasses = retrievalQueryClassSchema.options;
|
|
58
|
+
function resolveFixturePath(candidatePath, cwd) {
|
|
59
|
+
return path.isAbsolute(candidatePath) ? candidatePath : path.resolve(cwd, candidatePath);
|
|
60
|
+
}
|
|
61
|
+
export function resolveFixturePaths(overrides = {}, cwd = process.cwd()) {
|
|
62
|
+
return {
|
|
63
|
+
corpusPath: resolveFixturePath(overrides.corpusPath ?? "fixtures/retrieval/corpus.v1.json", cwd),
|
|
64
|
+
queriesPath: resolveFixturePath(overrides.queriesPath ?? "fixtures/retrieval/queries.v1.json", cwd)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function countByKey(values) {
|
|
68
|
+
return values.reduce((acc, value) => {
|
|
69
|
+
acc[value] = (acc[value] ?? 0) + 1;
|
|
70
|
+
return acc;
|
|
71
|
+
}, {});
|
|
72
|
+
}
|
|
73
|
+
function ensureQueryTaxonomyCoverage(queries) {
|
|
74
|
+
const available = new Set(queries.queries.map((entry) => entry.query_class));
|
|
75
|
+
const missing = requiredQueryClasses.filter((queryClass) => !available.has(queryClass));
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
throw new Error(`Fixture queries missing required query classes: ${missing.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function ensureDatasetConsistency(corpus, queries) {
|
|
81
|
+
if (corpus.dataset_id !== queries.dataset_id) {
|
|
82
|
+
throw new Error(`Fixture dataset mismatch: corpus dataset_id='${corpus.dataset_id}' queries dataset_id='${queries.dataset_id}'.`);
|
|
83
|
+
}
|
|
84
|
+
if (corpus.version !== queries.version) {
|
|
85
|
+
throw new Error(`Fixture version mismatch: corpus version='${corpus.version}' queries version='${queries.version}'.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function buildFingerprint(corpusRaw, queriesRaw) {
|
|
89
|
+
return createHash("sha256").update(corpusRaw).update("\n").update(queriesRaw).digest("hex");
|
|
90
|
+
}
|
|
91
|
+
export async function loadRetrievalFixtureBundle(overrides = {}, cwd = process.cwd()) {
|
|
92
|
+
const { corpusPath, queriesPath } = resolveFixturePaths(overrides, cwd);
|
|
93
|
+
const [corpusRaw, queriesRaw] = await Promise.all([
|
|
94
|
+
readFile(corpusPath, "utf8"),
|
|
95
|
+
readFile(queriesPath, "utf8")
|
|
96
|
+
]);
|
|
97
|
+
const corpus = fixtureCorpusSchema.parse(JSON.parse(corpusRaw));
|
|
98
|
+
const queries = fixtureQueriesSchema.parse(JSON.parse(queriesRaw));
|
|
99
|
+
ensureDatasetConsistency(corpus, queries);
|
|
100
|
+
ensureQueryTaxonomyCoverage(queries);
|
|
101
|
+
return {
|
|
102
|
+
corpusPath,
|
|
103
|
+
queriesPath,
|
|
104
|
+
corpus,
|
|
105
|
+
queries,
|
|
106
|
+
fingerprint: buildFingerprint(corpusRaw, queriesRaw)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function summarizeRetrievalFixture(bundle) {
|
|
110
|
+
const sourceTypeCounts = countByKey(bundle.corpus.chunks.map((chunk) => chunk.source_type));
|
|
111
|
+
const queryClassCounts = countByKey(bundle.queries.queries.map((query) => query.query_class));
|
|
112
|
+
const tenantWorkspacePairs = new Set(bundle.corpus.chunks.map((chunk) => `${chunk.tenant_id}:${chunk.workspace_id}`)).size;
|
|
113
|
+
return {
|
|
114
|
+
datasetId: bundle.corpus.dataset_id,
|
|
115
|
+
version: bundle.corpus.version,
|
|
116
|
+
fingerprint: bundle.fingerprint,
|
|
117
|
+
chunkCount: bundle.corpus.chunks.length,
|
|
118
|
+
queryCount: bundle.queries.queries.length,
|
|
119
|
+
sourceTypeCounts,
|
|
120
|
+
queryClassCounts,
|
|
121
|
+
tenantWorkspacePairs
|
|
122
|
+
};
|
|
123
|
+
}
|