@remit/mailbox-service 0.0.32 → 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
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #302 (RFC 039 Decision 3): `Address.flags.unsubscribed` is
|
|
3
|
+
* documented as "auto-mark-read until sender stops" and settable from
|
|
4
|
+
* `IntelligencePane.tsx`, but nothing consumed it — every message from an
|
|
5
|
+
* unsubscribed sender still arrived unread like any other. These tests drive
|
|
6
|
+
* `BodySyncService` end-to-end (read-path body materialization →
|
|
7
|
+
* `applyPostStoreSteps`) and assert on the actual `FlagQueueService.markAsRead`
|
|
8
|
+
* call, so a regression that drops the flag read — not just a helper in
|
|
9
|
+
* isolation — shows up here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import type {
|
|
15
|
+
AddressItem,
|
|
16
|
+
IAddressRepository,
|
|
17
|
+
IEnvelopeRepository,
|
|
18
|
+
IMessageRepository,
|
|
19
|
+
IThreadMessageRepository,
|
|
20
|
+
} from "@remit/data-ports";
|
|
21
|
+
import type { StorageService } from "@remit/storage-service";
|
|
22
|
+
import { BodySyncService } from "./body-sync.js";
|
|
23
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
24
|
+
import type { IImapConnection } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const PLAIN_EML = (fromEmail: string) =>
|
|
27
|
+
Buffer.from(
|
|
28
|
+
[
|
|
29
|
+
`From: Sender <${fromEmail}>`,
|
|
30
|
+
"To: me@example.com",
|
|
31
|
+
"Subject: Hello",
|
|
32
|
+
"Content-Type: text/plain",
|
|
33
|
+
"",
|
|
34
|
+
"body",
|
|
35
|
+
].join("\r\n"),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
interface MarkReadCall {
|
|
39
|
+
accountConfigId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
accountId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Harness {
|
|
45
|
+
service: BodySyncService;
|
|
46
|
+
markReadCalls: MarkReadCall[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const buildHarness = (
|
|
50
|
+
flags: AddressItem["flags"],
|
|
51
|
+
withUnsubscribeConfig = true,
|
|
52
|
+
): Harness => {
|
|
53
|
+
const markReadCalls: MarkReadCall[] = [];
|
|
54
|
+
|
|
55
|
+
const messageService = {
|
|
56
|
+
get: async () => ({
|
|
57
|
+
messageId: "m-1",
|
|
58
|
+
mailboxId: "mb-inbox",
|
|
59
|
+
uid: 1,
|
|
60
|
+
}),
|
|
61
|
+
update: async () => {},
|
|
62
|
+
} as unknown as IMessageRepository;
|
|
63
|
+
|
|
64
|
+
const threadMessageService = {
|
|
65
|
+
findAllByMessageId: async () => [
|
|
66
|
+
{
|
|
67
|
+
threadMessageId: "tm-1",
|
|
68
|
+
sentDate: 1,
|
|
69
|
+
mailboxId: "mb-inbox",
|
|
70
|
+
isRead: false,
|
|
71
|
+
isDeleted: false,
|
|
72
|
+
hasStars: false,
|
|
73
|
+
hasAttachment: false,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
update: async () => {},
|
|
77
|
+
} as unknown as IThreadMessageRepository;
|
|
78
|
+
|
|
79
|
+
const storageService = {
|
|
80
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
81
|
+
storeParsedBody: async () => {},
|
|
82
|
+
listBodyParts: async () => [],
|
|
83
|
+
} as unknown as StorageService;
|
|
84
|
+
|
|
85
|
+
const addressService = {
|
|
86
|
+
getAddress: async () => ({ flags }) as unknown as AddressItem,
|
|
87
|
+
incrementInboundCount: async () => {},
|
|
88
|
+
} as unknown as IAddressRepository;
|
|
89
|
+
|
|
90
|
+
const envelopeService = {
|
|
91
|
+
listBodyParts: async () => [],
|
|
92
|
+
} as unknown as IEnvelopeRepository;
|
|
93
|
+
|
|
94
|
+
const flagQueueService = {
|
|
95
|
+
markAsRead: async (
|
|
96
|
+
accountConfigId: string,
|
|
97
|
+
messageId: string,
|
|
98
|
+
accountId: string,
|
|
99
|
+
) => {
|
|
100
|
+
markReadCalls.push({ accountConfigId, messageId, accountId });
|
|
101
|
+
},
|
|
102
|
+
} as unknown as FlagQueueService;
|
|
103
|
+
|
|
104
|
+
const service = new BodySyncService(
|
|
105
|
+
messageService,
|
|
106
|
+
storageService,
|
|
107
|
+
threadMessageService,
|
|
108
|
+
addressService,
|
|
109
|
+
envelopeService,
|
|
110
|
+
{ info: () => {}, error: () => {} },
|
|
111
|
+
undefined,
|
|
112
|
+
undefined,
|
|
113
|
+
undefined,
|
|
114
|
+
withUnsubscribeConfig ? { flagQueueService } : undefined,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return { service, markReadCalls };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readBody = async (
|
|
121
|
+
service: BodySyncService,
|
|
122
|
+
fromEmail = "someone@example.com",
|
|
123
|
+
) => {
|
|
124
|
+
const connection = {
|
|
125
|
+
openBox: async () => {},
|
|
126
|
+
fetchMessageBody: async () => PLAIN_EML(fromEmail),
|
|
127
|
+
} as unknown as IImapConnection;
|
|
128
|
+
return service.fetchAndGetBody(
|
|
129
|
+
"m-1",
|
|
130
|
+
"acc-1",
|
|
131
|
+
"cfg-1",
|
|
132
|
+
"INBOX",
|
|
133
|
+
async () => connection,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("Address.flags.unsubscribed drives auto-mark-read (issue #302)", () => {
|
|
138
|
+
it("marks a message from an unsubscribed sender as read at sync time", async () => {
|
|
139
|
+
const harness = buildHarness({ unsubscribed: { value: true, setAt: 1 } });
|
|
140
|
+
|
|
141
|
+
await readBody(harness.service);
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(harness.markReadCalls, [
|
|
144
|
+
{ accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("leaves read state alone for a sender without the flag", async () => {
|
|
149
|
+
const harness = buildHarness({});
|
|
150
|
+
|
|
151
|
+
await readBody(harness.service);
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("leaves read state alone once the flag is unset (no caching of the decision)", async () => {
|
|
157
|
+
const harness = buildHarness({
|
|
158
|
+
unsubscribed: { value: false, setAt: 1 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await readBody(harness.service);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("is a no-op when body sync was built without an UnsubscribeConfig", async () => {
|
|
167
|
+
const harness = buildHarness(
|
|
168
|
+
{ unsubscribed: { value: true, setAt: 1 } },
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await readBody(harness.service);
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(harness.markReadCalls, []);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type FilterDecision,
|
|
38
38
|
FilterPipeline,
|
|
39
39
|
} from "./filters/pipeline.js";
|
|
40
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
40
41
|
import {
|
|
41
42
|
classifyByHeaders,
|
|
42
43
|
extractAuthenticity,
|
|
@@ -257,6 +258,17 @@ export interface QuarantineConfig {
|
|
|
257
258
|
attempts: number;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* What body sync needs to auto-mark-read a message from an unsubscribed
|
|
263
|
+
* sender (issue #302, RFC 039 Decision 3). Reuses the same
|
|
264
|
+
* `FlagQueueService.markAsRead` a manual mark-as-read already goes through —
|
|
265
|
+
* local `\Seen` + `ThreadMessage.isRead` + a durable pending IMAP flag-push
|
|
266
|
+
* marker — so this fires the same round-trip, not a second primitive.
|
|
267
|
+
*/
|
|
268
|
+
export interface UnsubscribeConfig {
|
|
269
|
+
flagQueueService: FlagQueueService;
|
|
270
|
+
}
|
|
271
|
+
|
|
260
272
|
export class BodySyncService {
|
|
261
273
|
private log: BodySyncLogger;
|
|
262
274
|
private readonly filterPipeline?: FilterPipeline;
|
|
@@ -271,6 +283,7 @@ export class BodySyncService {
|
|
|
271
283
|
private readonly placementConfig?: PlacementConfig,
|
|
272
284
|
private readonly filterConfig?: FilterConfig,
|
|
273
285
|
private readonly quarantineConfig?: QuarantineConfig,
|
|
286
|
+
private readonly unsubscribeConfig?: UnsubscribeConfig,
|
|
274
287
|
) {
|
|
275
288
|
this.log = logger ?? noopLogger;
|
|
276
289
|
this.filterPipeline = filterConfig
|
|
@@ -813,6 +826,18 @@ export class BodySyncService {
|
|
|
813
826
|
});
|
|
814
827
|
}
|
|
815
828
|
|
|
829
|
+
// `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
|
|
830
|
+
// reusing the same FlagQueueService.markAsRead round-trip a manual
|
|
831
|
+
// mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
|
|
832
|
+
// the message is already \Seen), so a failure here safely re-fires on the
|
|
833
|
+
// next attempt rather than being lost behind the bodyStorageKey skip guard.
|
|
834
|
+
await this.applyUnsubscribedAutoRead(
|
|
835
|
+
messageId,
|
|
836
|
+
accountId,
|
|
837
|
+
accountConfigId,
|
|
838
|
+
parsed,
|
|
839
|
+
);
|
|
840
|
+
|
|
816
841
|
const moved = Boolean(resolved.move || filterMoved);
|
|
817
842
|
|
|
818
843
|
// ONE Message UpdateItem per synced message: bodyStorageKey + every
|
|
@@ -1136,6 +1161,59 @@ export class BodySyncService {
|
|
|
1136
1161
|
return unknown;
|
|
1137
1162
|
}
|
|
1138
1163
|
|
|
1164
|
+
/**
|
|
1165
|
+
* `flags.unsubscribed` (issue #302, RFC 039 Decision 3): "auto-mark-read
|
|
1166
|
+
* until sender stops" — fires on every new message from that sender for as
|
|
1167
|
+
* long as the flag stays set, per the flag's own doc comment; there is no
|
|
1168
|
+
* separate expiry mechanism, and no caching of the decision beyond this
|
|
1169
|
+
* per-message `Address` read. A no-op when body sync was built without an
|
|
1170
|
+
* {@link UnsubscribeConfig} or the message carries no `From` address.
|
|
1171
|
+
*/
|
|
1172
|
+
private async applyUnsubscribedAutoRead(
|
|
1173
|
+
messageId: string,
|
|
1174
|
+
accountId: string,
|
|
1175
|
+
accountConfigId: string,
|
|
1176
|
+
parsed: ParsedMail,
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
if (!this.unsubscribeConfig) return;
|
|
1179
|
+
|
|
1180
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1181
|
+
if (!fromEmail) return;
|
|
1182
|
+
|
|
1183
|
+
const unsubscribed = await this.deriveSenderUnsubscribed(
|
|
1184
|
+
accountConfigId,
|
|
1185
|
+
fromEmail,
|
|
1186
|
+
);
|
|
1187
|
+
if (!unsubscribed) return;
|
|
1188
|
+
|
|
1189
|
+
await this.unsubscribeConfig.flagQueueService.markAsRead(
|
|
1190
|
+
accountConfigId,
|
|
1191
|
+
messageId,
|
|
1192
|
+
accountId,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private async deriveSenderUnsubscribed(
|
|
1197
|
+
accountConfigId: string,
|
|
1198
|
+
fromEmail: string,
|
|
1199
|
+
): Promise<boolean> {
|
|
1200
|
+
try {
|
|
1201
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1202
|
+
const address = await this.addressService.getAddress(
|
|
1203
|
+
accountConfigId,
|
|
1204
|
+
addressId,
|
|
1205
|
+
);
|
|
1206
|
+
return address.flags?.unsubscribed?.value === true;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// A genuinely-absent address means "not unsubscribed". Any other
|
|
1209
|
+
// failure (AccessDenied, throttle, infra) must NOT be silently
|
|
1210
|
+
// downgraded — let it crash so the read-state decision isn't made on
|
|
1211
|
+
// bad data.
|
|
1212
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1139
1217
|
/**
|
|
1140
1218
|
* Evaluate the account's active filters against a synced message (RFC 034),
|
|
1141
1219
|
* BEFORE the single Message update — so a filter's `movedByRemit` flag joins
|
|
@@ -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?.(
|