@remit/backend 0.0.23 → 0.0.25
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/package.json
CHANGED
package/src/handlers/organize.ts
CHANGED
|
@@ -157,14 +157,19 @@ export const OrganizeOperations: Record<
|
|
|
157
157
|
const client = await getClient();
|
|
158
158
|
await assertAccount(client, accountId, accountConfigId, "read");
|
|
159
159
|
|
|
160
|
-
const messageIds = await matchOrganize(
|
|
160
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
161
161
|
buildOrganizeMatchDeps(client),
|
|
162
162
|
accountConfigId,
|
|
163
163
|
predicateFromInput(input),
|
|
164
164
|
ORGANIZE_MATCH_LIMIT,
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
const response: OrganizePreviewResponse = {
|
|
168
|
+
matchedCount: messageIds.length,
|
|
169
|
+
messageIds,
|
|
170
|
+
};
|
|
171
|
+
if (semanticUnavailable) response.semanticUnavailable = true;
|
|
172
|
+
return response;
|
|
168
173
|
},
|
|
169
174
|
};
|
|
170
175
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
3
|
import { FilterMatchOperator } from "@remit/domain-enums";
|
|
4
4
|
import type {
|
|
5
5
|
AnchorPayload,
|
|
@@ -11,9 +11,17 @@ import type { RemitClient } from "./dynamodb.js";
|
|
|
11
11
|
import {
|
|
12
12
|
applyOrganize,
|
|
13
13
|
matchOrganize,
|
|
14
|
+
type OrganizeCandidate,
|
|
14
15
|
type OrganizeMatchDeps,
|
|
15
16
|
type OrganizePredicate,
|
|
16
17
|
} from "./organize.js";
|
|
18
|
+
import { _resetSemanticCapabilityForTest } from "./semantic-capability.js";
|
|
19
|
+
|
|
20
|
+
const moduleNotFound = (): Error => {
|
|
21
|
+
const error = new Error("Cannot find package 'sqlite-vec' imported from …");
|
|
22
|
+
(error as Error & { code: string }).code = "ERR_MODULE_NOT_FOUND";
|
|
23
|
+
return error;
|
|
24
|
+
};
|
|
17
25
|
|
|
18
26
|
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
19
27
|
const ANCHOR_VECTOR = [1, 0, 0, 0];
|
|
@@ -148,12 +156,64 @@ const trackingMoveService = () => {
|
|
|
148
156
|
};
|
|
149
157
|
};
|
|
150
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Deps whose semantic side is the in-memory vector store — the vector-backed
|
|
161
|
+
* deployment. `listAccountFilterMessages` returns the given corpus (empty by
|
|
162
|
+
* default), and constructing the semantic side is tracked so a literal-only
|
|
163
|
+
* predicate can be shown never to build it.
|
|
164
|
+
*/
|
|
151
165
|
const matchDeps = (
|
|
152
166
|
store: ReturnType<typeof createMemoryVectorStore>,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
corpus: OrganizeCandidate[] = [],
|
|
168
|
+
): OrganizeMatchDeps & { semanticBuilds: () => number } => {
|
|
169
|
+
let semanticBuilds = 0;
|
|
170
|
+
return {
|
|
171
|
+
semantic: () => {
|
|
172
|
+
semanticBuilds += 1;
|
|
173
|
+
return { buildAnchor: async () => anchorPayload, vectorStore: store };
|
|
174
|
+
},
|
|
175
|
+
listAccountFilterMessages: async () => corpus,
|
|
176
|
+
semanticBuilds: () => semanticBuilds,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Deps modelling a deployment that ships no vector pipeline: any attempt to
|
|
182
|
+
* build or use the semantic side raises the missing-module shape, and the
|
|
183
|
+
* literal corpus is served from plain rows.
|
|
184
|
+
*/
|
|
185
|
+
const vectorlessDeps = (
|
|
186
|
+
corpus: OrganizeCandidate[],
|
|
187
|
+
): OrganizeMatchDeps & { semanticUsed: () => boolean } => {
|
|
188
|
+
let semanticUsed = false;
|
|
189
|
+
return {
|
|
190
|
+
semantic: () => {
|
|
191
|
+
semanticUsed = true;
|
|
192
|
+
return {
|
|
193
|
+
buildAnchor: async () => {
|
|
194
|
+
throw moduleNotFound();
|
|
195
|
+
},
|
|
196
|
+
vectorStore: {
|
|
197
|
+
query: async () => {
|
|
198
|
+
throw moduleNotFound();
|
|
199
|
+
},
|
|
200
|
+
getByMessage: async () => {
|
|
201
|
+
throw moduleNotFound();
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
listAccountFilterMessages: async () => corpus,
|
|
207
|
+
semanticUsed: () => semanticUsed,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const candidate = (
|
|
212
|
+
messageId: string,
|
|
213
|
+
over: Partial<OrganizeCandidate["message"]> = {},
|
|
214
|
+
): OrganizeCandidate => ({
|
|
215
|
+
messageId,
|
|
216
|
+
message: { from: "", fromName: "", subject: "", text: "", ...over },
|
|
157
217
|
});
|
|
158
218
|
|
|
159
219
|
describe("matchOrganize", () => {
|
|
@@ -165,25 +225,30 @@ describe("matchOrganize", () => {
|
|
|
165
225
|
bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
|
|
166
226
|
]);
|
|
167
227
|
|
|
168
|
-
const
|
|
228
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
169
229
|
matchDeps(store),
|
|
170
230
|
ACCOUNT_CONFIG_ID,
|
|
171
231
|
predicate({ actionLabelId: "lbl-1" }),
|
|
172
232
|
);
|
|
173
233
|
|
|
174
|
-
assert.deepEqual([...
|
|
234
|
+
assert.deepEqual([...messageIds].sort(), matching);
|
|
235
|
+
assert.equal(semanticUnavailable, false);
|
|
175
236
|
});
|
|
176
237
|
|
|
177
238
|
it("matches nothing when the predicate has neither an anchor nor a clause", async () => {
|
|
178
239
|
const store = createMemoryVectorStore();
|
|
179
240
|
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
180
241
|
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
242
|
+
const { messageIds } = await matchOrganize(
|
|
243
|
+
matchDeps(store),
|
|
244
|
+
ACCOUNT_CONFIG_ID,
|
|
245
|
+
{
|
|
246
|
+
...predicate(),
|
|
247
|
+
anchorMessageId: "None",
|
|
248
|
+
},
|
|
249
|
+
);
|
|
185
250
|
|
|
186
|
-
assert.deepEqual(
|
|
251
|
+
assert.deepEqual(messageIds, []);
|
|
187
252
|
});
|
|
188
253
|
|
|
189
254
|
it("refines the semantic set by literal clauses", async () => {
|
|
@@ -199,12 +264,146 @@ describe("matchOrganize", () => {
|
|
|
199
264
|
}),
|
|
200
265
|
]);
|
|
201
266
|
|
|
202
|
-
const
|
|
267
|
+
const { messageIds } = await matchOrganize(
|
|
268
|
+
matchDeps(store),
|
|
269
|
+
ACCOUNT_CONFIG_ID,
|
|
270
|
+
{
|
|
271
|
+
...predicate(),
|
|
272
|
+
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("never builds the semantic side for a purely-literal predicate", async () => {
|
|
280
|
+
const store = createMemoryVectorStore();
|
|
281
|
+
const deps = matchDeps(store, [
|
|
282
|
+
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
283
|
+
candidate("msg-2", { subject: "Newsletter" }),
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
203
287
|
...predicate(),
|
|
288
|
+
anchorMessageId: "None",
|
|
204
289
|
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
205
290
|
});
|
|
206
291
|
|
|
207
|
-
assert.deepEqual(
|
|
292
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
293
|
+
assert.equal(
|
|
294
|
+
deps.semanticBuilds(),
|
|
295
|
+
0,
|
|
296
|
+
"a literal-only predicate must not construct the vector-backed side",
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
302
|
+
const ORIGINAL = process.env.DATA_BACKEND;
|
|
303
|
+
beforeEach(() => {
|
|
304
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
305
|
+
_resetSemanticCapabilityForTest();
|
|
306
|
+
});
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
if (ORIGINAL === undefined) delete process.env.DATA_BACKEND;
|
|
309
|
+
else process.env.DATA_BACKEND = ORIGINAL;
|
|
310
|
+
_resetSemanticCapabilityForTest();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("matches a literal predicate from the corpus without touching the vector store", async () => {
|
|
314
|
+
const deps = vectorlessDeps([
|
|
315
|
+
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
316
|
+
candidate("msg-2", { subject: "Weekly newsletter" }),
|
|
317
|
+
candidate("msg-3", { subject: "Table reservation confirmed" }),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
321
|
+
deps,
|
|
322
|
+
ACCOUNT_CONFIG_ID,
|
|
323
|
+
{
|
|
324
|
+
...predicate(),
|
|
325
|
+
anchorMessageId: "None",
|
|
326
|
+
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
assert.deepEqual(messageIds, ["msg-1", "msg-3"]);
|
|
331
|
+
assert.equal(semanticUnavailable, false);
|
|
332
|
+
assert.equal(
|
|
333
|
+
deps.semanticUsed(),
|
|
334
|
+
false,
|
|
335
|
+
"a literal-only predicate must never reach the vector store",
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("fails loud on a body-content (HasWords) clause rather than matching it against a preview", async () => {
|
|
340
|
+
const deps = vectorlessDeps([
|
|
341
|
+
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
() =>
|
|
346
|
+
matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
347
|
+
...predicate(),
|
|
348
|
+
anchorMessageId: "None",
|
|
349
|
+
literalClauses: [{ field: "HasWords", value: "invoice" }],
|
|
350
|
+
}),
|
|
351
|
+
/HasWords/,
|
|
352
|
+
"the vector-free literal path must not silently narrow a body match to a preview",
|
|
353
|
+
);
|
|
354
|
+
assert.equal(deps.semanticUsed(), false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("degrades an anchor+clauses widen to the literal matches, flagged, instead of crashing", async () => {
|
|
358
|
+
const deps = vectorlessDeps([
|
|
359
|
+
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
360
|
+
candidate("msg-2", { subject: "Weekly newsletter" }),
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
364
|
+
deps,
|
|
365
|
+
ACCOUNT_CONFIG_ID,
|
|
366
|
+
{
|
|
367
|
+
...predicate(),
|
|
368
|
+
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
373
|
+
assert.equal(semanticUnavailable, true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("degrades an anchor-only widen to an empty flagged result instead of crashing", async () => {
|
|
377
|
+
const deps = vectorlessDeps([candidate("msg-1"), candidate("msg-2")]);
|
|
378
|
+
|
|
379
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
380
|
+
deps,
|
|
381
|
+
ACCOUNT_CONFIG_ID,
|
|
382
|
+
predicate(),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
assert.deepEqual(messageIds, []);
|
|
386
|
+
assert.equal(semanticUnavailable, true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("propagates a genuine (non-capability) semantic failure loudly", async () => {
|
|
390
|
+
const deps: OrganizeMatchDeps = {
|
|
391
|
+
semantic: () => ({
|
|
392
|
+
buildAnchor: async () => {
|
|
393
|
+
throw new Error("SQLITE_BUSY");
|
|
394
|
+
},
|
|
395
|
+
vectorStore: {
|
|
396
|
+
query: async () => [],
|
|
397
|
+
getByMessage: async () => [],
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
listAccountFilterMessages: async () => [],
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
await assert.rejects(
|
|
404
|
+
() => matchOrganize(deps, ACCOUNT_CONFIG_ID, predicate()),
|
|
405
|
+
/SQLITE_BUSY/,
|
|
406
|
+
);
|
|
208
407
|
});
|
|
209
408
|
});
|
|
210
409
|
|
|
@@ -233,7 +432,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
233
432
|
const result = await applyOrganize(
|
|
234
433
|
{ client: tracked.client },
|
|
235
434
|
ACCOUNT_CONFIG_ID,
|
|
236
|
-
applied,
|
|
435
|
+
applied.messageIds,
|
|
237
436
|
p,
|
|
238
437
|
);
|
|
239
438
|
|
|
@@ -260,7 +459,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
260
459
|
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
261
460
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
262
461
|
|
|
263
|
-
const matched = await matchOrganize(
|
|
462
|
+
const { messageIds: matched } = await matchOrganize(
|
|
463
|
+
matchDeps(store),
|
|
464
|
+
ACCOUNT_CONFIG_ID,
|
|
465
|
+
p,
|
|
466
|
+
);
|
|
264
467
|
const tracked = trackingClient();
|
|
265
468
|
const result = await applyOrganize(
|
|
266
469
|
{ client: tracked.client },
|
|
@@ -282,7 +485,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
282
485
|
]);
|
|
283
486
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
284
487
|
|
|
285
|
-
const matched = await matchOrganize(
|
|
488
|
+
const { messageIds: matched } = await matchOrganize(
|
|
489
|
+
matchDeps(store),
|
|
490
|
+
ACCOUNT_CONFIG_ID,
|
|
491
|
+
p,
|
|
492
|
+
);
|
|
286
493
|
const tracked = trackingClient();
|
|
287
494
|
const mover = trackingMoveService();
|
|
288
495
|
const result = await applyOrganize(
|
|
@@ -324,7 +531,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
324
531
|
actionMailboxId: "mbox-target",
|
|
325
532
|
});
|
|
326
533
|
|
|
327
|
-
const matched = await matchOrganize(
|
|
534
|
+
const { messageIds: matched } = await matchOrganize(
|
|
535
|
+
matchDeps(store),
|
|
536
|
+
ACCOUNT_CONFIG_ID,
|
|
537
|
+
p,
|
|
538
|
+
);
|
|
328
539
|
const tracked = trackingClient();
|
|
329
540
|
const mover = trackingMoveService();
|
|
330
541
|
const result = await applyOrganize(
|
|
@@ -351,7 +562,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
351
562
|
await store.upsert(matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)));
|
|
352
563
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
353
564
|
|
|
354
|
-
const matched = await matchOrganize(
|
|
565
|
+
const { messageIds: matched } = await matchOrganize(
|
|
566
|
+
matchDeps(store),
|
|
567
|
+
ACCOUNT_CONFIG_ID,
|
|
568
|
+
p,
|
|
569
|
+
);
|
|
355
570
|
const tracked = trackingClient();
|
|
356
571
|
const mover = trackingMoveService();
|
|
357
572
|
|
package/src/service/organize.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FilterItem, OrganizeJobRequestItem } from "@remit/data-ports";
|
|
2
|
+
import { FilterClauseField } from "@remit/domain-enums";
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
4
5
|
type FilterMessage,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
buildVectorStoreFromEnv,
|
|
18
19
|
} from "@remit/search-service/from-env";
|
|
19
20
|
import type { RemitClient } from "./dynamodb.js";
|
|
21
|
+
import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Hard cap on both the previewed and the applied set. A back-apply is a
|
|
@@ -63,16 +65,54 @@ export const predicateFromJob = (
|
|
|
63
65
|
actionMailboxId: job.actionMailboxId,
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The vector-backed semantic side of the matcher — the only part that reaches
|
|
70
|
+
* `sqlite-vec`/`@huggingface/transformers`. Constructed lazily (see
|
|
71
|
+
* {@link OrganizeMatchDeps.semantic}) so a literal-only predicate never touches
|
|
72
|
+
* the vector extension, and so a deployment that ships no vector pipeline only
|
|
73
|
+
* fails on the import when a predicate actually asks to widen — where the
|
|
74
|
+
* failure is caught and degraded rather than surfaced as a 500 (#226/#201).
|
|
75
|
+
*/
|
|
76
|
+
export interface OrganizeSemanticDeps {
|
|
67
77
|
buildAnchor: (
|
|
68
78
|
accountConfigId: string,
|
|
69
79
|
anchorMessageId: string,
|
|
70
80
|
) => Promise<AnchorPayload | null>;
|
|
71
81
|
vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
|
|
72
|
-
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One corpus message projected onto the fields a literal clause matches
|
|
86
|
+
* against, paired with its id. Sourced from the core message rows, never the
|
|
87
|
+
* vector store, so literal matching runs on any deployment.
|
|
88
|
+
*/
|
|
89
|
+
export interface OrganizeCandidate {
|
|
90
|
+
messageId: string;
|
|
91
|
+
message: FilterMessage;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface OrganizeMatchDeps {
|
|
95
|
+
/**
|
|
96
|
+
* Build the semantic side on demand. Invoked only for an anchored predicate,
|
|
97
|
+
* so constructing the embedder and vector store — and, on first use, the
|
|
98
|
+
* lazy import of the vector extension — is deferred until a widen is actually
|
|
99
|
+
* requested.
|
|
100
|
+
*/
|
|
101
|
+
semantic: () => OrganizeSemanticDeps;
|
|
102
|
+
/**
|
|
103
|
+
* The account's messages as literal-match candidates, bounded and vector-free
|
|
104
|
+
* — the corpus slice a purely-literal (or degraded) pass scans.
|
|
105
|
+
*/
|
|
106
|
+
listAccountFilterMessages: (
|
|
73
107
|
accountConfigId: string,
|
|
74
108
|
limit: number,
|
|
75
|
-
) => Promise<
|
|
109
|
+
) => Promise<OrganizeCandidate[]>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The matched ids plus whether the semantic widen was skipped as unavailable. */
|
|
113
|
+
export interface OrganizeMatchResult {
|
|
114
|
+
messageIds: string[];
|
|
115
|
+
semanticUnavailable: boolean;
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
const hasAnchor = (predicate: OrganizePredicate): boolean =>
|
|
@@ -110,62 +150,50 @@ const filterMessageFromChunks = (
|
|
|
110
150
|
};
|
|
111
151
|
|
|
112
152
|
/**
|
|
113
|
-
* The
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* - Literal clauses (RFC 031) refine the candidate set; a purely-literal
|
|
120
|
-
* back-apply scans a bounded slice of the corpus instead.
|
|
121
|
-
* - A predicate with neither an anchor nor a clause matches nothing, mirroring
|
|
122
|
-
* the index-time filter matcher.
|
|
153
|
+
* The semantic (anchor) arm: pool the anchor vector from the anchor message's
|
|
154
|
+
* existing chunk vectors, fan out with a k-NN query gated on the cosine
|
|
155
|
+
* threshold, then refine by literal clauses reconstructed from the same chunk
|
|
156
|
+
* vectors. Every read here goes through the vector store; a deployment without
|
|
157
|
+
* the vector pipeline fails on the first call, which {@link matchOrganize}
|
|
158
|
+
* catches. Returns null when the anchor message has no vectors to pool.
|
|
123
159
|
*/
|
|
124
|
-
|
|
125
|
-
|
|
160
|
+
const matchSemantic = async (
|
|
161
|
+
semantic: OrganizeSemanticDeps,
|
|
126
162
|
accountConfigId: string,
|
|
127
163
|
predicate: OrganizePredicate,
|
|
128
|
-
limit: number
|
|
129
|
-
): Promise<string[]> => {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const bestScore = new Map<string, number>();
|
|
149
|
-
for (const match of matches) {
|
|
150
|
-
const messageId = match.metadata.messageId;
|
|
151
|
-
const prev = bestScore.get(messageId);
|
|
152
|
-
if (prev === undefined || match.score > prev) {
|
|
153
|
-
bestScore.set(messageId, match.score);
|
|
154
|
-
}
|
|
164
|
+
limit: number,
|
|
165
|
+
): Promise<string[] | null> => {
|
|
166
|
+
const anchor = await semantic.buildAnchor(
|
|
167
|
+
accountConfigId,
|
|
168
|
+
predicate.anchorMessageId,
|
|
169
|
+
);
|
|
170
|
+
if (!anchor) return null;
|
|
171
|
+
const threshold =
|
|
172
|
+
predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
173
|
+
const matches = await semantic.vectorStore.query({
|
|
174
|
+
vector: anchor.anchorEmbedding,
|
|
175
|
+
topK: limit * VECTOR_CHUNK_FACTOR,
|
|
176
|
+
filter: { accountConfigId },
|
|
177
|
+
});
|
|
178
|
+
const bestScore = new Map<string, number>();
|
|
179
|
+
for (const match of matches) {
|
|
180
|
+
const messageId = match.metadata.messageId;
|
|
181
|
+
const prev = bestScore.get(messageId);
|
|
182
|
+
if (prev === undefined || match.score > prev) {
|
|
183
|
+
bestScore.set(messageId, match.score);
|
|
155
184
|
}
|
|
156
|
-
base = [...bestScore.entries()]
|
|
157
|
-
.filter(([, score]) => score >= threshold)
|
|
158
|
-
.map(([messageId]) => messageId);
|
|
159
|
-
} else {
|
|
160
|
-
base = await deps.listAccountMessageIds(accountConfigId, limit);
|
|
161
185
|
}
|
|
186
|
+
const base = [...bestScore.entries()]
|
|
187
|
+
.filter(([, score]) => score >= threshold)
|
|
188
|
+
.map(([messageId]) => messageId);
|
|
162
189
|
|
|
190
|
+
const clauses = predicate.literalClauses;
|
|
163
191
|
if (clauses.length === 0) return base.slice(0, limit);
|
|
164
192
|
|
|
165
193
|
const matched: string[] = [];
|
|
166
194
|
for (const messageId of base) {
|
|
167
195
|
if (matched.length >= limit) break;
|
|
168
|
-
const records = await
|
|
196
|
+
const records = await semantic.vectorStore.getByMessage(messageId);
|
|
169
197
|
const message = filterMessageFromChunks(records);
|
|
170
198
|
if (!message) continue;
|
|
171
199
|
if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
|
|
@@ -175,75 +203,205 @@ export const matchOrganize = async (
|
|
|
175
203
|
return matched;
|
|
176
204
|
};
|
|
177
205
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
206
|
+
/**
|
|
207
|
+
* A `HasWords` clause matches the message body (RFC 031, `clauseMatches`). The
|
|
208
|
+
* live index-time filter evaluates it against the full parsed body
|
|
209
|
+
* (`body-sync.ts` `toFilterMessage`); the vector-free corpus slice carries only
|
|
210
|
+
* `From`/`Subject` at full fidelity and no faithful body, so it cannot serve a
|
|
211
|
+
* body-content clause without silently narrowing the match to whatever preview
|
|
212
|
+
* happened to be indexed. Body-content matching therefore requires the widen
|
|
213
|
+
* (vector) path — {@link matchSemantic} reconstructs body text from chunk
|
|
214
|
+
* previews there. This guard keeps the two matchers from diverging silently: a
|
|
215
|
+
* body-content clause reaching the vector-free path fails loud instead of
|
|
216
|
+
* returning a wrong set. It is unreachable today — no product surface emits a
|
|
217
|
+
* `HasWords` clause (the organize UI sends empty `literalClauses`, the filter
|
|
218
|
+
* builder emits none) — and stays a fail-fast for any future surface that does.
|
|
219
|
+
*/
|
|
220
|
+
const assertNoBodyContentClause = (
|
|
221
|
+
clauses: OrganizePredicate["literalClauses"],
|
|
222
|
+
): void => {
|
|
223
|
+
if (clauses.some((clause) => clause.field === FilterClauseField.HasWords)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
182
229
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
230
|
+
/**
|
|
231
|
+
* The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
|
|
232
|
+
* the messages whose literal clauses match. Used both for a purely-literal
|
|
233
|
+
* predicate and as the degraded fallback when a widen is requested on a
|
|
234
|
+
* deployment without the vector pipeline. Serves `From`/`Subject` clauses at
|
|
235
|
+
* full fidelity from the core thread rows; body-content (`HasWords`) clauses are
|
|
236
|
+
* rejected up front (see {@link assertNoBodyContentClause}).
|
|
237
|
+
*/
|
|
238
|
+
const matchLiteral = async (
|
|
239
|
+
deps: OrganizeMatchDeps,
|
|
240
|
+
accountConfigId: string,
|
|
241
|
+
predicate: OrganizePredicate,
|
|
242
|
+
limit: number,
|
|
243
|
+
): Promise<string[]> => {
|
|
244
|
+
const clauses = predicate.literalClauses;
|
|
245
|
+
assertNoBodyContentClause(clauses);
|
|
246
|
+
const candidates = await deps.listAccountFilterMessages(
|
|
247
|
+
accountConfigId,
|
|
248
|
+
limit,
|
|
249
|
+
);
|
|
250
|
+
if (clauses.length === 0) {
|
|
251
|
+
return candidates.slice(0, limit).map((c) => c.messageId);
|
|
252
|
+
}
|
|
253
|
+
const matched: string[] = [];
|
|
254
|
+
for (const { messageId, message } of candidates) {
|
|
255
|
+
if (matched.length >= limit) break;
|
|
256
|
+
if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
|
|
257
|
+
matched.push(messageId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return matched;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The matcher shared by preview and apply — the previewed set equals the applied
|
|
265
|
+
* set for the same input. Read-only. Returns the matching message ids, bounded,
|
|
266
|
+
* and whether the semantic widen was skipped as unavailable.
|
|
267
|
+
*
|
|
268
|
+
* - Semantic anchor: {@link matchSemantic}, built lazily so a literal-only
|
|
269
|
+
* predicate never touches the vector extension.
|
|
270
|
+
* - Literal clauses (RFC 031) refine the candidate set; a purely-literal
|
|
271
|
+
* back-apply scans a bounded vector-free slice of the corpus instead.
|
|
272
|
+
* - A predicate with neither an anchor nor a clause matches nothing, mirroring
|
|
273
|
+
* the index-time filter matcher.
|
|
274
|
+
* - When a widen is requested but this deployment ships no vector pipeline, the
|
|
275
|
+
* capability-absence is absorbed exactly as `/search/semantic` absorbs it:
|
|
276
|
+
* the literal matches are returned (empty for an anchor-only predicate) with
|
|
277
|
+
* `semanticUnavailable` set, never a 500. Any other failure propagates.
|
|
278
|
+
*/
|
|
279
|
+
export const matchOrganize = async (
|
|
280
|
+
deps: OrganizeMatchDeps,
|
|
281
|
+
accountConfigId: string,
|
|
282
|
+
predicate: OrganizePredicate,
|
|
283
|
+
limit: number = ORGANIZE_MATCH_LIMIT,
|
|
284
|
+
): Promise<OrganizeMatchResult> => {
|
|
285
|
+
const anchored = hasAnchor(predicate);
|
|
286
|
+
const clauses = predicate.literalClauses;
|
|
287
|
+
if (!anchored && clauses.length === 0) {
|
|
288
|
+
return { messageIds: [], semanticUnavailable: false };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!anchored) {
|
|
292
|
+
const messageIds = await matchLiteral(
|
|
293
|
+
deps,
|
|
294
|
+
accountConfigId,
|
|
295
|
+
predicate,
|
|
296
|
+
limit,
|
|
297
|
+
);
|
|
298
|
+
return { messageIds, semanticUnavailable: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const semanticIds = await matchSemantic(
|
|
303
|
+
deps.semantic(),
|
|
304
|
+
accountConfigId,
|
|
305
|
+
predicate,
|
|
306
|
+
limit,
|
|
307
|
+
);
|
|
308
|
+
return { messageIds: semanticIds ?? [], semanticUnavailable: false };
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!noteSemanticCapabilityAbsence(error)) throw error;
|
|
311
|
+
// The widen cannot run on this deployment. Fall back to the literal
|
|
312
|
+
// clauses over the corpus — nothing when the predicate is anchor-only —
|
|
313
|
+
// and flag the absence so the client can say so.
|
|
314
|
+
if (clauses.length === 0) {
|
|
315
|
+
return { messageIds: [], semanticUnavailable: true };
|
|
316
|
+
}
|
|
317
|
+
const messageIds = await matchLiteral(
|
|
318
|
+
deps,
|
|
319
|
+
accountConfigId,
|
|
320
|
+
predicate,
|
|
321
|
+
limit,
|
|
322
|
+
);
|
|
323
|
+
return { messageIds, semanticUnavailable: true };
|
|
190
324
|
}
|
|
191
|
-
return cachedVectorStore;
|
|
192
325
|
};
|
|
193
326
|
|
|
194
|
-
|
|
327
|
+
let cachedSemantic: OrganizeSemanticDeps | null = null;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Construct the semantic side from env on first use and memoize it. The embedder
|
|
331
|
+
* and vector store are selected by env, independent of `DATA_BACKEND`; building
|
|
332
|
+
* them is cheap (the vector extension imports lazily on the first store call),
|
|
333
|
+
* but deferring construction keeps a literal-only preview clear of the whole
|
|
334
|
+
* semantic graph.
|
|
335
|
+
*/
|
|
336
|
+
const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
337
|
+
if (cachedSemantic) return cachedSemantic;
|
|
195
338
|
const embedder = buildEmbeddingServiceFromEnv();
|
|
196
339
|
const store = buildVectorStoreFromEnv(embedder.dimensions);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
340
|
+
cachedSemantic = {
|
|
341
|
+
buildAnchor: (accountConfigId, anchorMessageId) =>
|
|
342
|
+
buildMessageAnchor(
|
|
343
|
+
{ store, embedder },
|
|
344
|
+
{ accountConfigId, anchorMessageId },
|
|
345
|
+
),
|
|
346
|
+
vectorStore: store,
|
|
347
|
+
};
|
|
348
|
+
return cachedSemantic;
|
|
202
349
|
};
|
|
203
350
|
|
|
204
351
|
/**
|
|
205
|
-
* A bounded corpus slice for a
|
|
206
|
-
*
|
|
207
|
-
*
|
|
352
|
+
* A bounded, vector-free corpus slice for a literal back-apply: the account's
|
|
353
|
+
* messages projected onto the literal-match fields, gathered newest-first across
|
|
354
|
+
* every mailbox and capped. Reads the core thread rows, so it runs on a
|
|
355
|
+
* deployment that ships no vector pipeline. Deduped by message id — the same
|
|
356
|
+
* mail filed in two folders is one candidate.
|
|
357
|
+
*
|
|
358
|
+
* `From`/`Subject` come from the row verbatim (full fidelity). `text` (the body)
|
|
359
|
+
* is left empty: the thread row carries only a truncated `snippet`, and matching
|
|
360
|
+
* a body-content clause against a preview would silently diverge from the live
|
|
361
|
+
* index-time filter's full-body match. Body-content (`HasWords`) clauses are
|
|
362
|
+
* rejected before this is read (see {@link assertNoBodyContentClause}), so the
|
|
363
|
+
* empty `text` is never matched against.
|
|
208
364
|
*/
|
|
209
|
-
const
|
|
210
|
-
(client: RemitClient): OrganizeMatchDeps["
|
|
365
|
+
const listAccountFilterMessagesFromClient =
|
|
366
|
+
(client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
|
|
211
367
|
async (accountConfigId, limit) => {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
368
|
+
const candidates: OrganizeCandidate[] = [];
|
|
369
|
+
const seen = new Set<string>();
|
|
370
|
+
let continuationToken: string | undefined;
|
|
371
|
+
do {
|
|
372
|
+
const page = await client.threadMessage.listByDate(accountConfigId, {
|
|
373
|
+
limit,
|
|
374
|
+
continuationToken,
|
|
375
|
+
excludeDeleted: true,
|
|
376
|
+
});
|
|
377
|
+
for (const row of page.items) {
|
|
378
|
+
if (seen.has(row.messageId)) continue;
|
|
379
|
+
seen.add(row.messageId);
|
|
380
|
+
candidates.push({
|
|
381
|
+
messageId: row.messageId,
|
|
382
|
+
message: {
|
|
383
|
+
from: row.fromEmail ?? "",
|
|
384
|
+
fromName: row.fromName ?? "",
|
|
385
|
+
subject: row.subject ?? "",
|
|
386
|
+
text: "",
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
if (candidates.length >= limit) return candidates;
|
|
232
390
|
}
|
|
233
|
-
|
|
234
|
-
|
|
391
|
+
continuationToken = page.continuationToken;
|
|
392
|
+
} while (continuationToken);
|
|
393
|
+
return candidates;
|
|
235
394
|
};
|
|
236
395
|
|
|
237
396
|
/**
|
|
238
|
-
* The env-wired matcher deps for the running backend/worker. The
|
|
239
|
-
*
|
|
397
|
+
* The env-wired matcher deps for the running backend/worker. The semantic side
|
|
398
|
+
* is built lazily so a literal-only preview never constructs it.
|
|
240
399
|
*/
|
|
241
400
|
export const buildOrganizeMatchDeps = (
|
|
242
401
|
client: RemitClient,
|
|
243
402
|
): OrganizeMatchDeps => ({
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
listAccountMessageIds: listAccountMessageIdsFromClient(client),
|
|
403
|
+
semantic: buildSemanticFromEnv,
|
|
404
|
+
listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
|
|
247
405
|
});
|
|
248
406
|
|
|
249
407
|
/**
|
|
@@ -14,6 +14,14 @@ const moduleNotFound = (): Error => {
|
|
|
14
14
|
return error;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const modelUnavailable = (): Error => {
|
|
18
|
+
const error = new Error(
|
|
19
|
+
'Embedding model "Xenova/paraphrase-multilingual-MiniLM-L12-v2" could not be loaded',
|
|
20
|
+
);
|
|
21
|
+
(error as Error & { code: string }).code = "ERR_EMBEDDING_MODEL_UNAVAILABLE";
|
|
22
|
+
return error;
|
|
23
|
+
};
|
|
24
|
+
|
|
17
25
|
describe("noteSemanticCapabilityAbsence", () => {
|
|
18
26
|
const ORIGINAL = process.env.DATA_BACKEND;
|
|
19
27
|
beforeEach(() => {
|
|
@@ -44,6 +52,16 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
44
52
|
assert.equal(noteSemanticCapabilityAbsence(error), true);
|
|
45
53
|
});
|
|
46
54
|
|
|
55
|
+
it("absorbs an embedding-model load failure (e2e-dev from source, HuggingFace fetch failed) and remembers it", () => {
|
|
56
|
+
for (const backend of ["sqlite", "postgres"]) {
|
|
57
|
+
_resetSemanticCapabilityForTest();
|
|
58
|
+
process.env.DATA_BACKEND = backend;
|
|
59
|
+
assert.equal(isSemanticSearchUnavailable(), false);
|
|
60
|
+
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
|
|
61
|
+
assert.equal(isSemanticSearchUnavailable(), true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
47
65
|
it("rethrows genuine query errors on the self-host SQL backends", () => {
|
|
48
66
|
process.env.DATA_BACKEND = "sqlite";
|
|
49
67
|
assert.equal(
|
|
@@ -57,8 +75,10 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
57
75
|
it("never engages on the AWS DynamoDB path — a missing module there is a broken deploy", () => {
|
|
58
76
|
delete process.env.DATA_BACKEND;
|
|
59
77
|
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), false);
|
|
78
|
+
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), false);
|
|
60
79
|
process.env.DATA_BACKEND = "dynamodb";
|
|
61
80
|
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), false);
|
|
81
|
+
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), false);
|
|
62
82
|
assert.equal(isSemanticSearchUnavailable(), false);
|
|
63
83
|
});
|
|
64
84
|
});
|
|
@@ -13,6 +13,15 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
13
13
|
* fetches semantic hits alongside every literal search), retried by the client
|
|
14
14
|
* on top.
|
|
15
15
|
*
|
|
16
|
+
* The e2e-dev lane runs this backend from source rather than the container, so
|
|
17
|
+
* `@huggingface/transformers` IS present and the local embedder instead tries to
|
|
18
|
+
* lazily download the model from HuggingFace; a transient `fetch failed` there
|
|
19
|
+
* surfaces the same way. The search-service raises it as a typed
|
|
20
|
+
* `EmbeddingModelUnavailableError` (code ERR_EMBEDDING_MODEL_UNAVAILABLE), which
|
|
21
|
+
* this gate treats as the same capability absence — the model is not available,
|
|
22
|
+
* empty semantic results are the truthful answer, and the lane never depends on
|
|
23
|
+
* a live external download.
|
|
24
|
+
*
|
|
16
25
|
* When the pipeline is genuinely absent, empty hits are the truthful answer:
|
|
17
26
|
* the FTS/literal engine is the primary search surface on these profiles and is
|
|
18
27
|
* unaffected. The absence is remembered so subsequent requests short-circuit.
|
|
@@ -29,6 +38,7 @@ const CAPABILITY_ABSENCE_CODES = new Set([
|
|
|
29
38
|
"ERR_MODULE_NOT_FOUND",
|
|
30
39
|
"MODULE_NOT_FOUND",
|
|
31
40
|
"ERR_DLOPEN_FAILED",
|
|
41
|
+
"ERR_EMBEDDING_MODEL_UNAVAILABLE",
|
|
32
42
|
]);
|
|
33
43
|
|
|
34
44
|
let semanticUnavailable = false;
|