@remit/mailbox-service 0.0.33 → 0.0.34
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 +1 -1
- package/src/filters/pipeline.test.ts +191 -0
- package/src/filters/pipeline.ts +31 -1
package/package.json
CHANGED
|
@@ -61,6 +61,7 @@ const buildPipeline = (filter: FilterItem) => {
|
|
|
61
61
|
state.embedCalls += 1;
|
|
62
62
|
return [];
|
|
63
63
|
},
|
|
64
|
+
embeddingId: "test-model@0",
|
|
64
65
|
},
|
|
65
66
|
};
|
|
66
67
|
return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
|
|
@@ -101,3 +102,193 @@ describe("FilterPipeline — anchorless From/Or filter at index time", () => {
|
|
|
101
102
|
assert.equal(state.embedCalls, 0);
|
|
102
103
|
});
|
|
103
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("FilterPipeline — anchor lazy re-embed on model drift (reader #295)", () => {
|
|
107
|
+
const anchoredFilter = (): FilterItem =>
|
|
108
|
+
({
|
|
109
|
+
filterId: "flt-semantic",
|
|
110
|
+
accountConfigId: "cfg-1",
|
|
111
|
+
name: "receipts",
|
|
112
|
+
scope: "Standing",
|
|
113
|
+
state: FilterState.Active,
|
|
114
|
+
hasAnchor: true,
|
|
115
|
+
ruleChangedAt: 1,
|
|
116
|
+
matchOperator: FilterMatchOperator.And,
|
|
117
|
+
literalClauses: [],
|
|
118
|
+
actionLabelId: "lbl-1",
|
|
119
|
+
actionMailboxId: NO_ACTION,
|
|
120
|
+
createdAt: 1,
|
|
121
|
+
updatedAt: 1,
|
|
122
|
+
}) as unknown as FilterItem;
|
|
123
|
+
|
|
124
|
+
const message = {
|
|
125
|
+
from: "billing@stripe.com",
|
|
126
|
+
fromName: "Stripe",
|
|
127
|
+
subject: "Your receipt",
|
|
128
|
+
text: "Thanks for your payment",
|
|
129
|
+
listId: "",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
|
|
133
|
+
const putCalls: Array<Record<string, unknown>> = [];
|
|
134
|
+
let embedCalls = 0;
|
|
135
|
+
const config: FilterConfig = {
|
|
136
|
+
filterService: {
|
|
137
|
+
listByAccountAndState: async () => [anchoredFilter()],
|
|
138
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
139
|
+
} as unknown as IFilterRepository,
|
|
140
|
+
filterAnchorService: {
|
|
141
|
+
get: async () => ({
|
|
142
|
+
accountConfigId: "cfg-1",
|
|
143
|
+
filterId: "flt-semantic",
|
|
144
|
+
// Stale-model space — orthogonal to what the current model
|
|
145
|
+
// produces, so a raw comparison against it would not match.
|
|
146
|
+
anchorEmbedding: [0, 1, 0],
|
|
147
|
+
anchorEmbeddingId: "old-model@3",
|
|
148
|
+
anchorSourceText: "your receipt is ready",
|
|
149
|
+
anchorMessageId: "msg-anchor",
|
|
150
|
+
}),
|
|
151
|
+
put: async (input: Record<string, unknown>) => {
|
|
152
|
+
putCalls.push(input);
|
|
153
|
+
return { ...input, createdAt: 1, updatedAt: 2 };
|
|
154
|
+
},
|
|
155
|
+
} as unknown as IFilterAnchorRepository,
|
|
156
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
157
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
158
|
+
embedder: {
|
|
159
|
+
embed: async () => {
|
|
160
|
+
embedCalls += 1;
|
|
161
|
+
return [1, 0, 0];
|
|
162
|
+
},
|
|
163
|
+
embeddingId: "new-model@3",
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const decision = await new FilterPipeline(config, {
|
|
168
|
+
info: () => {},
|
|
169
|
+
}).evaluate("cfg-1", "m-1", message);
|
|
170
|
+
|
|
171
|
+
assert.deepEqual(
|
|
172
|
+
decision.labels,
|
|
173
|
+
[{ labelId: "lbl-1", filterId: "flt-semantic" }],
|
|
174
|
+
"the anchor matches once re-embedded into the current model's space",
|
|
175
|
+
);
|
|
176
|
+
assert.equal(
|
|
177
|
+
putCalls.length,
|
|
178
|
+
1,
|
|
179
|
+
"the refreshed anchor is written back exactly once",
|
|
180
|
+
);
|
|
181
|
+
assert.deepEqual(putCalls[0], {
|
|
182
|
+
accountConfigId: "cfg-1",
|
|
183
|
+
filterId: "flt-semantic",
|
|
184
|
+
anchorEmbedding: [1, 0, 0],
|
|
185
|
+
anchorEmbeddingId: "new-model@3",
|
|
186
|
+
anchorSourceText: "your receipt is ready",
|
|
187
|
+
anchorMessageId: "msg-anchor",
|
|
188
|
+
});
|
|
189
|
+
assert.equal(
|
|
190
|
+
embedCalls,
|
|
191
|
+
2,
|
|
192
|
+
"one embed re-embeds the anchor's source text, one embeds the candidate message",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never re-embeds when the anchor's embeddingId is already current", async () => {
|
|
197
|
+
let putCalls = 0;
|
|
198
|
+
let embedCalls = 0;
|
|
199
|
+
const config: FilterConfig = {
|
|
200
|
+
filterService: {
|
|
201
|
+
listByAccountAndState: async () => [anchoredFilter()],
|
|
202
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
203
|
+
} as unknown as IFilterRepository,
|
|
204
|
+
filterAnchorService: {
|
|
205
|
+
get: async () => ({
|
|
206
|
+
anchorEmbedding: [1, 0, 0],
|
|
207
|
+
anchorEmbeddingId: "current-model@3",
|
|
208
|
+
anchorSourceText: "your receipt is ready",
|
|
209
|
+
anchorMessageId: "msg-anchor",
|
|
210
|
+
}),
|
|
211
|
+
put: async () => {
|
|
212
|
+
putCalls += 1;
|
|
213
|
+
throw new Error("must not be called");
|
|
214
|
+
},
|
|
215
|
+
} as unknown as IFilterAnchorRepository,
|
|
216
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
217
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
218
|
+
embedder: {
|
|
219
|
+
embed: async () => {
|
|
220
|
+
embedCalls += 1;
|
|
221
|
+
return [1, 0, 0];
|
|
222
|
+
},
|
|
223
|
+
embeddingId: "current-model@3",
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const decision = await new FilterPipeline(config, {
|
|
228
|
+
info: () => {},
|
|
229
|
+
}).evaluate("cfg-1", "m-1", message);
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(decision.labels, [
|
|
232
|
+
{ labelId: "lbl-1", filterId: "flt-semantic" },
|
|
233
|
+
]);
|
|
234
|
+
assert.equal(putCalls, 0, "a current anchor is never rewritten");
|
|
235
|
+
assert.equal(
|
|
236
|
+
embedCalls,
|
|
237
|
+
1,
|
|
238
|
+
"only the candidate message is embedded — no wasted re-embed call",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("degrades a re-embed failure to skipping just this filter, not the whole pass", async () => {
|
|
243
|
+
const literalFilter: FilterItem = {
|
|
244
|
+
filterId: "flt-literal",
|
|
245
|
+
accountConfigId: "cfg-1",
|
|
246
|
+
name: "stripe receipts",
|
|
247
|
+
scope: "Standing",
|
|
248
|
+
state: FilterState.Active,
|
|
249
|
+
hasAnchor: false,
|
|
250
|
+
ruleChangedAt: 1,
|
|
251
|
+
matchOperator: FilterMatchOperator.Or,
|
|
252
|
+
literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
|
|
253
|
+
actionLabelId: "lbl-literal",
|
|
254
|
+
actionMailboxId: NO_ACTION,
|
|
255
|
+
createdAt: 1,
|
|
256
|
+
updatedAt: 1,
|
|
257
|
+
} as unknown as FilterItem;
|
|
258
|
+
|
|
259
|
+
const config: FilterConfig = {
|
|
260
|
+
filterService: {
|
|
261
|
+
listByAccountAndState: async () => [anchoredFilter(), literalFilter],
|
|
262
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
263
|
+
} as unknown as IFilterRepository,
|
|
264
|
+
filterAnchorService: {
|
|
265
|
+
get: async () => ({
|
|
266
|
+
anchorEmbedding: [0, 1, 0],
|
|
267
|
+
anchorEmbeddingId: "old-model@3",
|
|
268
|
+
anchorSourceText: "your receipt is ready",
|
|
269
|
+
anchorMessageId: "msg-anchor",
|
|
270
|
+
}),
|
|
271
|
+
put: async () => {
|
|
272
|
+
throw new Error("SQLITE_BUSY");
|
|
273
|
+
},
|
|
274
|
+
} as unknown as IFilterAnchorRepository,
|
|
275
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
276
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
277
|
+
embedder: {
|
|
278
|
+
embed: async () => [1, 0, 0],
|
|
279
|
+
embeddingId: "new-model@3",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const decision = await new FilterPipeline(config, {
|
|
284
|
+
info: () => {},
|
|
285
|
+
error: () => {},
|
|
286
|
+
}).evaluate("cfg-1", "m-1", message);
|
|
287
|
+
|
|
288
|
+
assert.deepEqual(
|
|
289
|
+
decision.labels,
|
|
290
|
+
[{ labelId: "lbl-literal", filterId: "flt-literal" }],
|
|
291
|
+
"the anchored filter's re-embed failure is isolated — the literal filter still applies",
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/filters/pipeline.ts
CHANGED
|
@@ -26,6 +26,15 @@ import {
|
|
|
26
26
|
*/
|
|
27
27
|
export interface MessageEmbedder {
|
|
28
28
|
embed(text: string): Promise<number[]>;
|
|
29
|
+
/**
|
|
30
|
+
* `<modelId>@<dimensions>` identifier of the model currently configured —
|
|
31
|
+
* the same scheme `EmbeddingService.embeddingId`
|
|
32
|
+
* (`packages/search-service/src/embeddings.ts`) derives. Compared against
|
|
33
|
+
* `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
|
|
34
|
+
* swap would otherwise pass through silently (RFC 039 Decision 1a, reader
|
|
35
|
+
* #295).
|
|
36
|
+
*/
|
|
37
|
+
readonly embeddingId: string;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export interface FilterLogger {
|
|
@@ -215,7 +224,7 @@ export class FilterPipeline {
|
|
|
215
224
|
// One deterministic point read per semantic candidate that survived the
|
|
216
225
|
// literal pre-filter — never a scan, never the anchor message itself
|
|
217
226
|
// (RFC 034 Decision 2.3).
|
|
218
|
-
|
|
227
|
+
let anchor = await this.config.filterAnchorService.get(
|
|
219
228
|
accountConfigId,
|
|
220
229
|
filter.filterId,
|
|
221
230
|
);
|
|
@@ -227,6 +236,27 @@ export class FilterPipeline {
|
|
|
227
236
|
return false;
|
|
228
237
|
}
|
|
229
238
|
|
|
239
|
+
const embedder = this.config.embedder;
|
|
240
|
+
if (embedder && anchor.anchorEmbeddingId !== embedder.embeddingId) {
|
|
241
|
+
// The embedding model has drifted since this anchor was last written
|
|
242
|
+
// (RFC 039 Decision 1a) — re-embed the already-persisted
|
|
243
|
+
// `anchorSourceText` and write it back in place, lazily, on this read.
|
|
244
|
+
// No migration job walks these rows proactively; a re-embed failure
|
|
245
|
+
// here propagates to the per-filter catch in `match()`, which logs and
|
|
246
|
+
// skips this filter for this one evaluation exactly as an unrecoverable
|
|
247
|
+
// stale anchor does today — never a terminal state, so the next
|
|
248
|
+
// message that reaches this filter retries.
|
|
249
|
+
const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
|
|
250
|
+
anchor = await this.config.filterAnchorService.put({
|
|
251
|
+
accountConfigId,
|
|
252
|
+
filterId: filter.filterId,
|
|
253
|
+
anchorEmbedding,
|
|
254
|
+
anchorEmbeddingId: embedder.embeddingId,
|
|
255
|
+
anchorSourceText: anchor.anchorSourceText,
|
|
256
|
+
anchorMessageId: anchor.anchorMessageId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
230
260
|
const vector = await embed();
|
|
231
261
|
if (!vector) {
|
|
232
262
|
this.log.debug?.(
|