@remit/backend 0.0.68 → 0.0.70
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
|
@@ -341,4 +341,26 @@ describe("POST /system/update", () => {
|
|
|
341
341
|
assert.ok(!(forbidden in request));
|
|
342
342
|
}
|
|
343
343
|
});
|
|
344
|
+
|
|
345
|
+
it("stamps requestedAt with the moment of the request, in UTC", async () => {
|
|
346
|
+
// The updater installs a request only while it is still current (#587), so
|
|
347
|
+
// requestedAt is the field that decides it. A missing, fixed or non-UTC
|
|
348
|
+
// stamp would make every request expire on arrival.
|
|
349
|
+
writeState(okState);
|
|
350
|
+
const before = Date.now();
|
|
351
|
+
|
|
352
|
+
await applyUpdate("v1.5.0", buildEvent(USER));
|
|
353
|
+
|
|
354
|
+
const after = Date.now();
|
|
355
|
+
const request = JSON.parse(
|
|
356
|
+
readFileSync(join(controlDir, "request.json"), "utf8"),
|
|
357
|
+
) as { requestedAt: string };
|
|
358
|
+
|
|
359
|
+
assert.match(
|
|
360
|
+
request.requestedAt,
|
|
361
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
|
|
362
|
+
);
|
|
363
|
+
const stamped = Date.parse(request.requestedAt);
|
|
364
|
+
assert.ok(stamped >= before - 1000 && stamped <= after + 1000);
|
|
365
|
+
});
|
|
344
366
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CreateFilterAnchorInput,
|
|
5
|
+
FilterAnchorItem,
|
|
6
|
+
FilterItem,
|
|
7
|
+
} from "@remit/data-ports";
|
|
4
8
|
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
5
9
|
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
6
10
|
import type {
|
|
@@ -17,7 +21,10 @@ import {
|
|
|
17
21
|
type OrganizeMatchDeps,
|
|
18
22
|
type OrganizePredicate,
|
|
19
23
|
} from "./organize.js";
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
_resetSemanticCapabilityForTest,
|
|
26
|
+
isSemanticSearchUnavailable,
|
|
27
|
+
} from "./semantic-capability.js";
|
|
21
28
|
|
|
22
29
|
const moduleNotFound = (): Error => {
|
|
23
30
|
const error = new Error("Cannot find package 'sqlite-vec' imported from …");
|
|
@@ -28,11 +35,16 @@ const moduleNotFound = (): Error => {
|
|
|
28
35
|
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
29
36
|
const ANCHOR_VECTOR = [1, 0, 0, 0];
|
|
30
37
|
const ORTHOGONAL_VECTOR = [0, 1, 0, 0];
|
|
38
|
+
/** The model the fixtures' embedder is currently configured with. */
|
|
39
|
+
const CURRENT_EMBEDDING_ID = "test-model@4";
|
|
40
|
+
/** The model a persisted anchor was written under before a same-dimension swap. */
|
|
41
|
+
const STALE_EMBEDDING_ID = "older-model@4";
|
|
42
|
+
const ANCHOR_SOURCE_TEXT = "book me a table";
|
|
31
43
|
|
|
32
44
|
const anchorPayload: AnchorPayload = {
|
|
33
45
|
anchorEmbedding: ANCHOR_VECTOR,
|
|
34
|
-
anchorEmbeddingId:
|
|
35
|
-
anchorSourceText:
|
|
46
|
+
anchorEmbeddingId: CURRENT_EMBEDDING_ID,
|
|
47
|
+
anchorSourceText: ANCHOR_SOURCE_TEXT,
|
|
36
48
|
};
|
|
37
49
|
|
|
38
50
|
const metadata = (over: Partial<ChunkMetadata>): ChunkMetadata => ({
|
|
@@ -113,6 +125,7 @@ const trackingClient = (
|
|
|
113
125
|
const labeled: Array<{ messageId: string; labelId: string }> = [];
|
|
114
126
|
let filterWrites = 0;
|
|
115
127
|
let filterAnchorWrites = 0;
|
|
128
|
+
const anchorPuts: CreateFilterAnchorInput[] = [];
|
|
116
129
|
const activeFilters = seed.activeFilters ?? [];
|
|
117
130
|
const filterAnchorRows = seed.filterAnchorRows ?? [];
|
|
118
131
|
const threadMessages = seed.threadMessages ?? {};
|
|
@@ -162,9 +175,16 @@ const trackingClient = (
|
|
|
162
175
|
refreshExpiry: async (filter: FilterItem) => filter,
|
|
163
176
|
},
|
|
164
177
|
filterAnchor: {
|
|
165
|
-
put: async () => {
|
|
178
|
+
put: async (input: CreateFilterAnchorInput) => {
|
|
166
179
|
filterAnchorWrites += 1;
|
|
167
|
-
|
|
180
|
+
anchorPuts.push(input);
|
|
181
|
+
const row: FilterAnchorItem = { ...input, createdAt: 0, updatedAt: 1 };
|
|
182
|
+
const at = filterAnchorRows.findIndex(
|
|
183
|
+
(existing) => existing.filterId === input.filterId,
|
|
184
|
+
);
|
|
185
|
+
if (at === -1) filterAnchorRows.push(row);
|
|
186
|
+
else filterAnchorRows[at] = row;
|
|
187
|
+
return row;
|
|
168
188
|
},
|
|
169
189
|
get: async (_accountConfigId: string, filterId: string) =>
|
|
170
190
|
filterAnchorRows.find((row) => row.filterId === filterId) ?? null,
|
|
@@ -176,6 +196,7 @@ const trackingClient = (
|
|
|
176
196
|
labeled,
|
|
177
197
|
filterWrites: () => filterWrites,
|
|
178
198
|
filterAnchorWrites: () => filterAnchorWrites,
|
|
199
|
+
anchorPuts,
|
|
179
200
|
};
|
|
180
201
|
};
|
|
181
202
|
|
|
@@ -238,10 +259,16 @@ const matchDeps = (
|
|
|
238
259
|
vectorStore: store,
|
|
239
260
|
embed: async (text: string) =>
|
|
240
261
|
text.includes("reservation") ? ANCHOR_VECTOR : ORTHOGONAL_VECTOR,
|
|
262
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
241
263
|
};
|
|
242
264
|
},
|
|
243
265
|
listAccountFilterMessages: async () => corpus,
|
|
244
|
-
filterAnchors: {
|
|
266
|
+
filterAnchors: {
|
|
267
|
+
listByAccountConfig: async () => filterAnchorRows,
|
|
268
|
+
put: async () => {
|
|
269
|
+
throw new Error("matchDeps must not repair an anchor");
|
|
270
|
+
},
|
|
271
|
+
},
|
|
245
272
|
semanticBuilds: () => semanticBuilds,
|
|
246
273
|
};
|
|
247
274
|
};
|
|
@@ -273,10 +300,16 @@ const vectorlessDeps = (
|
|
|
273
300
|
embed: async () => {
|
|
274
301
|
throw moduleNotFound();
|
|
275
302
|
},
|
|
303
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
276
304
|
};
|
|
277
305
|
},
|
|
278
306
|
listAccountFilterMessages: async () => corpus,
|
|
279
|
-
filterAnchors: {
|
|
307
|
+
filterAnchors: {
|
|
308
|
+
listByAccountConfig: async () => [],
|
|
309
|
+
put: async () => {
|
|
310
|
+
throw moduleNotFound();
|
|
311
|
+
},
|
|
312
|
+
},
|
|
280
313
|
semanticUsed: () => semanticUsed,
|
|
281
314
|
};
|
|
282
315
|
};
|
|
@@ -391,8 +424,8 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
|
|
|
391
424
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
392
425
|
filterId: "filter-a",
|
|
393
426
|
anchorEmbedding: ANCHOR_VECTOR,
|
|
394
|
-
anchorEmbeddingId:
|
|
395
|
-
anchorSourceText:
|
|
427
|
+
anchorEmbeddingId: CURRENT_EMBEDDING_ID,
|
|
428
|
+
anchorSourceText: ANCHOR_SOURCE_TEXT,
|
|
396
429
|
anchorMessageId: "msg-anchor",
|
|
397
430
|
createdAt: 0,
|
|
398
431
|
updatedAt: 0,
|
|
@@ -409,9 +442,15 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
|
|
|
409
442
|
},
|
|
410
443
|
vectorStore: store,
|
|
411
444
|
embed: async () => ANCHOR_VECTOR,
|
|
445
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
412
446
|
}),
|
|
413
447
|
listAccountFilterMessages: async () => [],
|
|
414
|
-
filterAnchors: {
|
|
448
|
+
filterAnchors: {
|
|
449
|
+
listByAccountConfig: async () => [persistedAnchor],
|
|
450
|
+
put: async () => {
|
|
451
|
+
throw new Error("a current anchor must not be rewritten");
|
|
452
|
+
},
|
|
453
|
+
},
|
|
415
454
|
};
|
|
416
455
|
|
|
417
456
|
const { messageIds } = await matchOrganize(
|
|
@@ -542,6 +581,67 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
542
581
|
assert.equal(semanticUnavailable, true);
|
|
543
582
|
});
|
|
544
583
|
|
|
584
|
+
it("keeps widening from a drifted persisted anchor when no embedding model is available", async () => {
|
|
585
|
+
// Self-host: sqlite-vec is present, `@huggingface/transformers` is not.
|
|
586
|
+
// The anchor's stamp will never match — after a model swap, or forever
|
|
587
|
+
// for one pooled from pre-#349 chunks and stamped
|
|
588
|
+
// UNKNOWN_CHUNK_EMBEDDING_ID — and there is no embedder to repair it
|
|
589
|
+
// with, so the widen must query with the vector it has rather than go
|
|
590
|
+
// dark and take every later /search/semantic with it.
|
|
591
|
+
const store = createMemoryVectorStore();
|
|
592
|
+
await store.upsert([
|
|
593
|
+
bodyChunk("msg-1", ANCHOR_VECTOR),
|
|
594
|
+
bodyChunk("msg-stored-space", ORTHOGONAL_VECTOR),
|
|
595
|
+
]);
|
|
596
|
+
const drifted: FilterAnchorItem = {
|
|
597
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
598
|
+
filterId: "filter-a",
|
|
599
|
+
anchorEmbedding: ORTHOGONAL_VECTOR,
|
|
600
|
+
anchorEmbeddingId: STALE_EMBEDDING_ID,
|
|
601
|
+
anchorSourceText: ANCHOR_SOURCE_TEXT,
|
|
602
|
+
anchorMessageId: "msg-anchor",
|
|
603
|
+
createdAt: 0,
|
|
604
|
+
updatedAt: 0,
|
|
605
|
+
};
|
|
606
|
+
const deps: OrganizeMatchDeps = {
|
|
607
|
+
semantic: () => ({
|
|
608
|
+
buildAnchor: async () => {
|
|
609
|
+
throw new Error("a persisted anchor must not be re-derived");
|
|
610
|
+
},
|
|
611
|
+
vectorStore: store,
|
|
612
|
+
embed: async () => {
|
|
613
|
+
throw moduleNotFound();
|
|
614
|
+
},
|
|
615
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
616
|
+
}),
|
|
617
|
+
listAccountFilterMessages: async () => [],
|
|
618
|
+
filterAnchors: {
|
|
619
|
+
listByAccountConfig: async () => [drifted],
|
|
620
|
+
put: async () => {
|
|
621
|
+
throw new Error("an unrepairable anchor must not be rewritten");
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const { messageIds, semanticUnavailable } = await matchOrganize(
|
|
627
|
+
deps,
|
|
628
|
+
ACCOUNT_CONFIG_ID,
|
|
629
|
+
predicate(),
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
assert.deepEqual(
|
|
633
|
+
messageIds,
|
|
634
|
+
["msg-stored-space"],
|
|
635
|
+
"the kNN read needs no embedder, so the stored anchor vector still returns real hits",
|
|
636
|
+
);
|
|
637
|
+
assert.equal(semanticUnavailable, false);
|
|
638
|
+
assert.equal(
|
|
639
|
+
isSemanticSearchUnavailable(),
|
|
640
|
+
false,
|
|
641
|
+
"one unrepairable anchor must not disable free-text semantic search for the rest of the process",
|
|
642
|
+
);
|
|
643
|
+
});
|
|
644
|
+
|
|
545
645
|
it("propagates a genuine (non-capability) semantic failure loudly", async () => {
|
|
546
646
|
const deps: OrganizeMatchDeps = {
|
|
547
647
|
semantic: () => ({
|
|
@@ -553,9 +653,15 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
553
653
|
getByMessage: async () => [],
|
|
554
654
|
},
|
|
555
655
|
embed: async () => [],
|
|
656
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
556
657
|
}),
|
|
557
658
|
listAccountFilterMessages: async () => [],
|
|
558
|
-
filterAnchors: {
|
|
659
|
+
filterAnchors: {
|
|
660
|
+
listByAccountConfig: async () => [],
|
|
661
|
+
put: async () => {
|
|
662
|
+
throw new Error("unreachable");
|
|
663
|
+
},
|
|
664
|
+
},
|
|
559
665
|
};
|
|
560
666
|
|
|
561
667
|
await assert.rejects(
|
|
@@ -885,8 +991,8 @@ describe("applyOrganize resolves move precedence against current Active filters
|
|
|
885
991
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
886
992
|
filterId: "filter-newer-semantic",
|
|
887
993
|
anchorEmbedding: ANCHOR_VECTOR,
|
|
888
|
-
anchorEmbeddingId:
|
|
889
|
-
anchorSourceText:
|
|
994
|
+
anchorEmbeddingId: CURRENT_EMBEDDING_ID,
|
|
995
|
+
anchorSourceText: ANCHOR_SOURCE_TEXT,
|
|
890
996
|
anchorMessageId: "msg-anchor-2",
|
|
891
997
|
createdAt: 0,
|
|
892
998
|
updatedAt: 0,
|
|
@@ -923,6 +1029,186 @@ describe("applyOrganize resolves move precedence against current Active filters
|
|
|
923
1029
|
});
|
|
924
1030
|
});
|
|
925
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* A same-dimension embedding-model swap leaves every persisted FilterAnchor
|
|
1034
|
+
* stamped with the old model's id and its vector in the old model's space.
|
|
1035
|
+
* Index-time matching repairs that lazily (RFC 039 Decision 1a); the
|
|
1036
|
+
* back-apply paths must do the same, or they score a current-model vector
|
|
1037
|
+
* against a stale-model anchor and silently disagree with the pipeline they
|
|
1038
|
+
* claim to mirror (reader #399).
|
|
1039
|
+
*/
|
|
1040
|
+
describe("back-apply repairs a drifted anchor the way index-time matching does (reader #399)", () => {
|
|
1041
|
+
const staleAnchor = (
|
|
1042
|
+
filterId: string,
|
|
1043
|
+
// Written under the previous model: same dimensions, different space, so
|
|
1044
|
+
// nothing but the id stamp distinguishes it from a usable anchor.
|
|
1045
|
+
anchorEmbedding: number[] = ORTHOGONAL_VECTOR,
|
|
1046
|
+
): FilterAnchorItem => ({
|
|
1047
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
1048
|
+
filterId,
|
|
1049
|
+
anchorEmbedding,
|
|
1050
|
+
anchorEmbeddingId: STALE_EMBEDDING_ID,
|
|
1051
|
+
anchorSourceText: ANCHOR_SOURCE_TEXT,
|
|
1052
|
+
anchorMessageId: "msg-anchor",
|
|
1053
|
+
createdAt: 0,
|
|
1054
|
+
updatedAt: 0,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
/** Every text embeds into the current model's space. */
|
|
1058
|
+
const currentModelDeps = (
|
|
1059
|
+
store: ReturnType<typeof createMemoryVectorStore>,
|
|
1060
|
+
anchors: FilterAnchorItem[],
|
|
1061
|
+
embed: (text: string) => Promise<number[]> = async () => ANCHOR_VECTOR,
|
|
1062
|
+
): OrganizeMatchDeps & { anchorPuts: CreateFilterAnchorInput[] } => {
|
|
1063
|
+
const anchorPuts: CreateFilterAnchorInput[] = [];
|
|
1064
|
+
return {
|
|
1065
|
+
semantic: () => ({
|
|
1066
|
+
buildAnchor: async () => {
|
|
1067
|
+
throw new Error("a persisted anchor must be repaired, not replaced");
|
|
1068
|
+
},
|
|
1069
|
+
vectorStore: store,
|
|
1070
|
+
embed,
|
|
1071
|
+
embeddingId: CURRENT_EMBEDDING_ID,
|
|
1072
|
+
}),
|
|
1073
|
+
listAccountFilterMessages: async () => [],
|
|
1074
|
+
filterAnchors: {
|
|
1075
|
+
listByAccountConfig: async () => anchors,
|
|
1076
|
+
put: async (input: CreateFilterAnchorInput) => {
|
|
1077
|
+
anchorPuts.push(input);
|
|
1078
|
+
return { ...input, createdAt: 0, updatedAt: 1 };
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
anchorPuts,
|
|
1082
|
+
};
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
it("matchSemantic re-embeds a drifted persisted anchor instead of querying with the stale vector", async () => {
|
|
1086
|
+
const store = createMemoryVectorStore();
|
|
1087
|
+
await store.upsert([
|
|
1088
|
+
bodyChunk("msg-1", ANCHOR_VECTOR),
|
|
1089
|
+
bodyChunk("msg-stale-space", ORTHOGONAL_VECTOR),
|
|
1090
|
+
]);
|
|
1091
|
+
const deps = currentModelDeps(store, [staleAnchor("filter-a")]);
|
|
1092
|
+
|
|
1093
|
+
const { messageIds } = await matchOrganize(
|
|
1094
|
+
deps,
|
|
1095
|
+
ACCOUNT_CONFIG_ID,
|
|
1096
|
+
predicate(),
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
assert.deepEqual(
|
|
1100
|
+
messageIds,
|
|
1101
|
+
["msg-1"],
|
|
1102
|
+
"the widen runs on the re-embedded anchor, not the stale-space vector that would have matched msg-stale-space",
|
|
1103
|
+
);
|
|
1104
|
+
assert.deepEqual(
|
|
1105
|
+
deps.anchorPuts.map((put) => [put.filterId, put.anchorEmbeddingId]),
|
|
1106
|
+
[["filter-a", CURRENT_EMBEDDING_ID]],
|
|
1107
|
+
"the repair is written back in place under the current model's id",
|
|
1108
|
+
);
|
|
1109
|
+
assert.deepEqual(deps.anchorPuts[0].anchorEmbedding, ANCHOR_VECTOR);
|
|
1110
|
+
assert.equal(deps.anchorPuts[0].anchorSourceText, ANCHOR_SOURCE_TEXT);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("filterCurrentlyMatches re-embeds a drifted anchor, so the drifted filter still outranks the move", async () => {
|
|
1114
|
+
const store = createMemoryVectorStore();
|
|
1115
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
1116
|
+
const p = predicate({
|
|
1117
|
+
actionLabelId: "lbl-1",
|
|
1118
|
+
actionMailboxId: "mbox-old",
|
|
1119
|
+
});
|
|
1120
|
+
const newerSemanticFilter = filterItem({
|
|
1121
|
+
filterId: "filter-newer-semantic",
|
|
1122
|
+
ruleChangedAt: 1_000,
|
|
1123
|
+
actionChangedAt: 1_000,
|
|
1124
|
+
actionMailboxId: "mbox-new",
|
|
1125
|
+
hasAnchor: true,
|
|
1126
|
+
});
|
|
1127
|
+
const drifted = staleAnchor("filter-newer-semantic");
|
|
1128
|
+
|
|
1129
|
+
const { messageIds: matched } = await matchOrganize(
|
|
1130
|
+
matchDeps(store),
|
|
1131
|
+
ACCOUNT_CONFIG_ID,
|
|
1132
|
+
p,
|
|
1133
|
+
);
|
|
1134
|
+
const tracked = trackingClient({
|
|
1135
|
+
activeFilters: [newerSemanticFilter],
|
|
1136
|
+
filterAnchorRows: [drifted],
|
|
1137
|
+
threadMessages: { "msg-1": { subject: "Dinner reservation" } },
|
|
1138
|
+
});
|
|
1139
|
+
const mover = trackingMoveService();
|
|
1140
|
+
const result = await applyOrganize(
|
|
1141
|
+
{
|
|
1142
|
+
client: tracked.client,
|
|
1143
|
+
moveService: mover.moveService,
|
|
1144
|
+
match: currentModelDeps(store, [drifted]),
|
|
1145
|
+
},
|
|
1146
|
+
ACCOUNT_CONFIG_ID,
|
|
1147
|
+
matched,
|
|
1148
|
+
p,
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
assert.equal(result.applied, 1);
|
|
1152
|
+
assert.deepEqual(
|
|
1153
|
+
mover.moves,
|
|
1154
|
+
[],
|
|
1155
|
+
"scored against the re-embedded anchor the newer filter contests the move; against the stale one it would silently lose",
|
|
1156
|
+
);
|
|
1157
|
+
assert.deepEqual(
|
|
1158
|
+
tracked.anchorPuts.map((put) => [put.filterId, put.anchorEmbeddingId]),
|
|
1159
|
+
[["filter-newer-semantic", CURRENT_EMBEDDING_ID]],
|
|
1160
|
+
"the repair is written back in place, not left for the next pass",
|
|
1161
|
+
);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it("keeps a failed re-embed isolated to its own filter rather than throwing out of the arbitration", async () => {
|
|
1165
|
+
const store = createMemoryVectorStore();
|
|
1166
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
1167
|
+
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
1168
|
+
// The stale vector would score a match, so scoring against it — which is
|
|
1169
|
+
// exactly what this path did before the repair existed — suppresses the
|
|
1170
|
+
// move. A stamp that cannot be honoured is not a match: the filter is
|
|
1171
|
+
// skipped and loudly logged, and the rest of the pass is unaffected.
|
|
1172
|
+
const drifted = staleAnchor("filter-broken-anchor", ANCHOR_VECTOR);
|
|
1173
|
+
const tracked = trackingClient({
|
|
1174
|
+
activeFilters: [
|
|
1175
|
+
filterItem({
|
|
1176
|
+
filterId: "filter-broken-anchor",
|
|
1177
|
+
ruleChangedAt: 1_000,
|
|
1178
|
+
actionChangedAt: 1_000,
|
|
1179
|
+
actionMailboxId: "mbox-elsewhere",
|
|
1180
|
+
hasAnchor: true,
|
|
1181
|
+
}),
|
|
1182
|
+
],
|
|
1183
|
+
filterAnchorRows: [drifted],
|
|
1184
|
+
threadMessages: { "msg-1": { subject: "Dinner reservation" } },
|
|
1185
|
+
});
|
|
1186
|
+
const mover = trackingMoveService();
|
|
1187
|
+
|
|
1188
|
+
const result = await applyOrganize(
|
|
1189
|
+
{
|
|
1190
|
+
client: tracked.client,
|
|
1191
|
+
moveService: mover.moveService,
|
|
1192
|
+
match: currentModelDeps(store, [drifted], async (text) => {
|
|
1193
|
+
if (text === ANCHOR_SOURCE_TEXT) throw new Error("embedder refused");
|
|
1194
|
+
return ANCHOR_VECTOR;
|
|
1195
|
+
}),
|
|
1196
|
+
},
|
|
1197
|
+
ACCOUNT_CONFIG_ID,
|
|
1198
|
+
["msg-1"],
|
|
1199
|
+
p,
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
assert.equal(result.applied, 1);
|
|
1203
|
+
assert.equal(result.failed, 0);
|
|
1204
|
+
assert.deepEqual(
|
|
1205
|
+
mover.moves.map((move) => move.destinationMailboxId),
|
|
1206
|
+
["mbox-target"],
|
|
1207
|
+
"an un-repairable anchor skips its own filter — it neither decides the arbitration on a stale score nor aborts the pass",
|
|
1208
|
+
);
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
926
1212
|
describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
927
1213
|
const senderChunk = (
|
|
928
1214
|
messageId: string,
|
package/src/service/organize.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
1
2
|
import type {
|
|
3
|
+
FilterAnchorItem,
|
|
2
4
|
FilterItem,
|
|
3
5
|
IFilterAnchorRepository,
|
|
4
6
|
OrganizeJobRequestItem,
|
|
5
7
|
} from "@remit/data-ports";
|
|
6
8
|
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
9
|
import { FilterClauseField, FilterState } from "@remit/domain-enums";
|
|
10
|
+
import { logger } from "@remit/logger-lambda";
|
|
8
11
|
import {
|
|
12
|
+
type AnchorEmbedder,
|
|
9
13
|
buildMatchText,
|
|
10
14
|
cosineSimilarity,
|
|
11
15
|
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
@@ -13,6 +17,7 @@ import {
|
|
|
13
17
|
literalClausesMatch,
|
|
14
18
|
NO_ACTION,
|
|
15
19
|
PlacementMoveService,
|
|
20
|
+
refreshAnchorForEmbedder,
|
|
16
21
|
selectMoveWinner,
|
|
17
22
|
} from "@remit/mailbox-service";
|
|
18
23
|
import {
|
|
@@ -26,7 +31,10 @@ import {
|
|
|
26
31
|
buildVectorStoreFromEnv,
|
|
27
32
|
} from "@remit/search-service/from-env";
|
|
28
33
|
import type { RemitClient } from "./data-client.js";
|
|
29
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
isSemanticCapabilityAbsence,
|
|
36
|
+
noteSemanticCapabilityAbsence,
|
|
37
|
+
} from "./semantic-capability.js";
|
|
30
38
|
|
|
31
39
|
/**
|
|
32
40
|
* Hard cap on both the previewed and the applied set. A back-apply is a
|
|
@@ -96,6 +104,13 @@ export interface OrganizeSemanticDeps {
|
|
|
96
104
|
* kNN read (see `semantic-capability.ts`).
|
|
97
105
|
*/
|
|
98
106
|
embed: (text: string) => Promise<number[]>;
|
|
107
|
+
/**
|
|
108
|
+
* The configured model's `<modelId>@<dimensions>` id, compared against a
|
|
109
|
+
* persisted anchor's `anchorEmbeddingId` to catch a same-dimension model
|
|
110
|
+
* swap that would otherwise score a current-model vector against a
|
|
111
|
+
* stale-model anchor (RFC 039 Decision 1a, reader #399).
|
|
112
|
+
*/
|
|
113
|
+
readonly embeddingId: string;
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
/**
|
|
@@ -133,8 +148,10 @@ export interface OrganizeMatchDeps {
|
|
|
133
148
|
* "the standing filter this anchor came from," if one still exists, to
|
|
134
149
|
* read its fixed-at-save-time vector instead of re-deriving one from the
|
|
135
150
|
* anchor message's current chunks (reader #350 / RFC 039 Decision 1).
|
|
151
|
+
* `put` is the write half of the lazy anchor-drift repair
|
|
152
|
+
* ({@link refreshAnchorForEmbedder}), never a new standing rule.
|
|
136
153
|
*/
|
|
137
|
-
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">;
|
|
154
|
+
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig" | "put">;
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
/** The matched ids plus whether the semantic widen was skipped as unavailable. */
|
|
@@ -189,31 +206,67 @@ const filterMessageFromChunks = (
|
|
|
189
206
|
* rows, read-only, and never touches the vector store. Returns `undefined`
|
|
190
207
|
* when no standing filter was ever anchored on this message (or it has since
|
|
191
208
|
* been deleted), in which case the caller falls back to deriving the anchor
|
|
192
|
-
* live from the message's current chunk vectors, exactly as before.
|
|
209
|
+
* live from the message's current chunk vectors, exactly as before. The whole
|
|
210
|
+
* row is returned, not just its payload, so the caller can run the same
|
|
211
|
+
* anchor-drift repair index-time matching does.
|
|
193
212
|
*/
|
|
194
213
|
const findPersistedAnchor = async (
|
|
195
214
|
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
|
|
196
215
|
accountConfigId: string,
|
|
197
216
|
anchorMessageId: string,
|
|
198
|
-
): Promise<
|
|
199
|
-
|
|
200
|
-
const persisted = anchors.find(
|
|
217
|
+
): Promise<FilterAnchorItem | undefined> =>
|
|
218
|
+
(await filterAnchors.listByAccountConfig(accountConfigId)).find(
|
|
201
219
|
(anchor) => anchor.anchorMessageId === anchorMessageId,
|
|
202
220
|
);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The anchor the widen queries with: the persisted row, re-embedded in place
|
|
224
|
+
* when the embedding model has drifted since it was written. A deployment that
|
|
225
|
+
* ships no embedding model cannot repair it, and the kNN read itself needs
|
|
226
|
+
* none, so that case keeps querying with the stored vector rather than going
|
|
227
|
+
* dark — and is classified without recording the absence, which would
|
|
228
|
+
* otherwise disable every later `/search/semantic` (see
|
|
229
|
+
* `semantic-capability.ts`).
|
|
230
|
+
*
|
|
231
|
+
* The classifier is wider than a missing embedder: `LocalEmbeddingService`
|
|
232
|
+
* raises ERR_EMBEDDING_MODEL_UNAVAILABLE for anything that stops the pipeline
|
|
233
|
+
* loading, a corrupt model file included, and a write failure carrying one of
|
|
234
|
+
* those codes lands here too. Any of them falls back to the stale vector, so
|
|
235
|
+
* the fallback is logged — it is the wrong-score condition this repair exists
|
|
236
|
+
* to close, taken deliberately over returning nothing. Every other failure
|
|
237
|
+
* propagates.
|
|
238
|
+
*/
|
|
239
|
+
const anchorForWiden = async (
|
|
240
|
+
deps: OrganizeMatchDeps,
|
|
241
|
+
semantic: OrganizeSemanticDeps,
|
|
242
|
+
persisted: FilterAnchorItem,
|
|
243
|
+
): Promise<FilterAnchorItem> =>
|
|
244
|
+
refreshAnchorForEmbedder(
|
|
245
|
+
{ anchorRepository: deps.filterAnchors, embedder: semantic },
|
|
246
|
+
persisted,
|
|
247
|
+
).catch((error: unknown) => {
|
|
248
|
+
if (!isSemanticCapabilityAbsence(error)) throw error;
|
|
249
|
+
logger.warn(
|
|
250
|
+
{
|
|
251
|
+
alert: "filter_anchor_repair_unavailable",
|
|
252
|
+
filterId: persisted.filterId,
|
|
253
|
+
accountConfigId: persisted.accountConfigId,
|
|
254
|
+
errorName: (error as { name?: string })?.name,
|
|
255
|
+
error: inspect(error),
|
|
256
|
+
},
|
|
257
|
+
"Cannot re-embed a drifted anchor in this deployment; widening on the stored (previous-model) vector rather than returning nothing",
|
|
258
|
+
);
|
|
259
|
+
return persisted;
|
|
260
|
+
});
|
|
210
261
|
|
|
211
262
|
/**
|
|
212
263
|
* The semantic (anchor) arm: read the anchor vector — the persisted
|
|
213
264
|
* `FilterAnchor` for a message a standing filter was built from (fixed at
|
|
214
265
|
* save time, unaffected by the anchor message's later deletion or
|
|
215
|
-
* re-chunking
|
|
216
|
-
*
|
|
266
|
+
* re-chunking, and re-embedded in place when the embedding model has drifted
|
|
267
|
+
* since — {@link refreshAnchorForEmbedder}), or else pool it fresh from the
|
|
268
|
+
* anchor message's existing chunk vectors, the same as before this filter
|
|
269
|
+
* existed or ever had one.
|
|
217
270
|
* Fan out with a k-NN query gated on the cosine threshold, then refine by
|
|
218
271
|
* literal clauses reconstructed from the same chunk vectors. Every read here
|
|
219
272
|
* goes through the vector store; a deployment without the vector pipeline
|
|
@@ -228,13 +281,14 @@ const matchSemantic = async (
|
|
|
228
281
|
limit: number,
|
|
229
282
|
): Promise<string[] | null> => {
|
|
230
283
|
const semantic = deps.semantic();
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
284
|
+
const persisted = await findPersistedAnchor(
|
|
285
|
+
deps.filterAnchors,
|
|
286
|
+
accountConfigId,
|
|
287
|
+
predicate.anchorMessageId,
|
|
288
|
+
);
|
|
289
|
+
const anchor: AnchorPayload | null = persisted
|
|
290
|
+
? await anchorForWiden(deps, semantic, persisted)
|
|
291
|
+
: await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
|
|
238
292
|
if (!anchor) return null;
|
|
239
293
|
const threshold =
|
|
240
294
|
predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
@@ -408,6 +462,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
408
462
|
buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
|
|
409
463
|
vectorStore: store,
|
|
410
464
|
embed: async (text) => (await embedder.embed([text]))[0],
|
|
465
|
+
embeddingId: embedder.embeddingId,
|
|
411
466
|
};
|
|
412
467
|
return cachedSemantic;
|
|
413
468
|
};
|
|
@@ -548,14 +603,18 @@ const findFilterMessageForPrecedence = async (
|
|
|
548
603
|
* Whether one *other* Active filter with a move action currently matches this
|
|
549
604
|
* message — mirrors `FilterPipeline.filterMatches` (mailbox-service
|
|
550
605
|
* filters/pipeline.ts) exactly: literal clauses first, then, for a filter
|
|
551
|
-
* with a semantic anchor, its own persisted `FilterAnchor`
|
|
606
|
+
* with a semantic anchor, its own persisted `FilterAnchor` — re-embedded in
|
|
607
|
+
* place through the same {@link refreshAnchorForEmbedder} the pipeline calls
|
|
608
|
+
* when the model has drifted since the anchor was written — compared against
|
|
552
609
|
* the candidate's embedding. A stale/incompatible anchor on the *other*
|
|
553
610
|
* filter is isolated to that filter (skipped, not thrown) — the same
|
|
554
|
-
* resilience `filterMatches` gives index-time matching, so one bad anchor
|
|
555
|
-
*
|
|
611
|
+
* resilience `filterMatches` gives index-time matching, so one bad anchor,
|
|
612
|
+
* or one anchor that cannot be re-embedded, never breaks this back-apply's
|
|
613
|
+
* move.
|
|
556
614
|
*/
|
|
557
615
|
const filterCurrentlyMatches = async (
|
|
558
|
-
filterAnchorService: Pick<IFilterAnchorRepository, "get">,
|
|
616
|
+
filterAnchorService: Pick<IFilterAnchorRepository, "get" | "put">,
|
|
617
|
+
embedder: () => AnchorEmbedder,
|
|
559
618
|
accountConfigId: string,
|
|
560
619
|
filter: FilterItem,
|
|
561
620
|
msg: FilterMessage,
|
|
@@ -567,21 +626,37 @@ const filterCurrentlyMatches = async (
|
|
|
567
626
|
if (!filter.hasAnchor) {
|
|
568
627
|
return filter.literalClauses.length > 0;
|
|
569
628
|
}
|
|
570
|
-
const
|
|
629
|
+
const stored = await filterAnchorService.get(
|
|
571
630
|
accountConfigId,
|
|
572
631
|
filter.filterId,
|
|
573
632
|
);
|
|
574
|
-
if (!
|
|
633
|
+
if (!stored) return false;
|
|
575
634
|
const vector = await embed();
|
|
576
635
|
if (!vector) return false;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
636
|
+
const repairAnchor = async (): Promise<FilterAnchorItem> =>
|
|
637
|
+
refreshAnchorForEmbedder(
|
|
638
|
+
{ anchorRepository: filterAnchorService, embedder: embedder() },
|
|
639
|
+
stored,
|
|
581
640
|
);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
641
|
+
return repairAnchor()
|
|
642
|
+
.then(
|
|
643
|
+
(anchor) =>
|
|
644
|
+
cosineSimilarity(vector, anchor.anchorEmbedding) >=
|
|
645
|
+
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
646
|
+
)
|
|
647
|
+
.catch((error: unknown) => {
|
|
648
|
+
logger.error(
|
|
649
|
+
{
|
|
650
|
+
alert: "filter_anchor_match_failed",
|
|
651
|
+
filterId: filter.filterId,
|
|
652
|
+
accountConfigId,
|
|
653
|
+
errorName: (error as { name?: string })?.name,
|
|
654
|
+
error: inspect(error),
|
|
655
|
+
},
|
|
656
|
+
"Filter anchor comparison failed during back-apply precedence; skipping this filter, the rest still arbitrate (bad/stale anchor vector or a failed repair, non-fatal)",
|
|
657
|
+
);
|
|
658
|
+
return false;
|
|
659
|
+
});
|
|
585
660
|
};
|
|
586
661
|
|
|
587
662
|
/**
|
|
@@ -634,6 +709,7 @@ const findCurrentMoveWinner = async (
|
|
|
634
709
|
for (const filter of movers) {
|
|
635
710
|
const isMatch = await filterCurrentlyMatches(
|
|
636
711
|
deps.client.filterAnchor,
|
|
712
|
+
() => deps.match.semantic(),
|
|
637
713
|
accountConfigId,
|
|
638
714
|
filter,
|
|
639
715
|
msg,
|
|
@@ -20,7 +20,13 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
20
20
|
* model involved — so it needs only `sqlite-vec`, which the backend image now
|
|
21
21
|
* carries as a musl build (Dockerfile sqlite-vec-musl stage,
|
|
22
22
|
* SQLITE_VEC_EXTENSION_PATH). matchOrganize never consults the memoized flag
|
|
23
|
-
* below; a missing embedder therefore never disables the widen.
|
|
23
|
+
* below; a missing embedder therefore never disables the widen. The one place
|
|
24
|
+
* the widen would reach for an embedder is repairing a persisted FilterAnchor
|
|
25
|
+
* whose model has drifted (organize.ts matchSemantic): with no embedder there
|
|
26
|
+
* is no repair to be had, so it queries with the stored vector and classifies
|
|
27
|
+
* the failure through {@link isSemanticCapabilityAbsence}, which deliberately
|
|
28
|
+
* does not record the absence. Recording it there would take every later
|
|
29
|
+
* free-text semantic query down on the strength of one filter's stale stamp.
|
|
24
30
|
*
|
|
25
31
|
* The e2e-dev lane runs this backend from source rather than the container, so
|
|
26
32
|
* `@huggingface/transformers` IS present and the local embedder instead tries to
|
|
@@ -59,17 +65,25 @@ export const _resetSemanticCapabilityForTest = (): void => {
|
|
|
59
65
|
|
|
60
66
|
export const isSemanticSearchUnavailable = (): boolean => semanticUnavailable;
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Whether a failure is this deployment simply not carrying the semantic
|
|
70
|
+
* pipeline — the missing-module/extension shape on a self-host SQL backend.
|
|
71
|
+
* Records nothing, for the caller that must degrade one operation without
|
|
72
|
+
* disabling `/search/semantic` process-wide.
|
|
73
|
+
*/
|
|
74
|
+
export const isSemanticCapabilityAbsence = (error: unknown): boolean => {
|
|
75
|
+
if (!isSelfHostSqlBackend()) return false;
|
|
76
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
77
|
+
return typeof code === "string" && CAPABILITY_ABSENCE_CODES.has(code);
|
|
78
|
+
};
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Classify a semantic-search failure. Returns true — and remembers the
|
|
64
82
|
* absence — when it is the missing-module/extension shape on a self-host SQL
|
|
65
83
|
* backend; any other error is the caller's to rethrow.
|
|
66
84
|
*/
|
|
67
85
|
export const noteSemanticCapabilityAbsence = (error: unknown): boolean => {
|
|
68
|
-
if (!
|
|
69
|
-
const code = (error as { code?: unknown } | null)?.code;
|
|
70
|
-
if (typeof code !== "string" || !CAPABILITY_ABSENCE_CODES.has(code)) {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
86
|
+
if (!isSemanticCapabilityAbsence(error)) return false;
|
|
73
87
|
if (!semanticUnavailable) {
|
|
74
88
|
logger.warn(
|
|
75
89
|
{ error: error instanceof Error ? error.message : String(error) },
|