@remit/mailbox-service 0.0.111 → 0.0.113
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/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +126 -0
- package/dist/mailbox-queue.js +214 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +284 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -73
- package/src/account-credentials.ts +0 -210
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -360
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-queue.test.ts +0 -124
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-terminal.test.ts +0 -310
- package/src/body-sync-terminal.ts +0 -176
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/body-sync.ts +0 -1970
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -615
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -205
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -369
- package/src/external-move.ts +0 -272
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/filter-config.test.ts +0 -229
- package/src/filters/filter-config.ts +0 -69
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -429
- package/src/filters/match.ts +0 -274
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/filters/pipeline.test.ts +0 -330
- package/src/filters/pipeline.ts +0 -284
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-push.test.ts +0 -408
- package/src/flag-push.ts +0 -198
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/flag-queue.ts +0 -509
- package/src/folder-placement.ts +0 -56
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -134
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/imapflow-connection.test.ts +0 -469
- package/src/imapflow-connection.ts +0 -1475
- package/src/index.ts +0 -350
- package/src/list-id-backfill.test.ts +0 -573
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -249
- package/src/mailbox-delete-intent.test.ts +0 -246
- package/src/mailbox-management.test.ts +0 -356
- package/src/mailbox-management.ts +0 -839
- package/src/mailbox-presence.test.ts +0 -72
- package/src/mailbox-queue.ts +0 -415
- package/src/mailbox-rename-intent.test.ts +0 -389
- package/src/mailbox-rename-subtree.test.ts +0 -664
- package/src/mailbox-sync.test.ts +0 -848
- package/src/mailbox-sync.ts +0 -724
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-move.ts +0 -1348
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-flags.test.ts +0 -158
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/message-sync.ts +0 -1963
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -305
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -738
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -370
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/outbox-queue.test.ts +0 -291
- package/src/outbox-queue.ts +0 -508
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-terminal.ts +0 -148
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-move.ts +0 -336
- package/src/placement-settled.test.ts +0 -147
- package/src/placement-transition-race.test.ts +0 -482
- package/src/quarantine.test.ts +0 -191
- package/src/quarantine.ts +0 -228
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -801
- package/src/spam-report.ts +0 -357
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/types/mailparser-augment.d.ts +0 -15
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
- package/tsconfig.json +0 -8
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
_resetMessageEmbedderForTest,
|
|
5
|
-
getMessageEmbedder,
|
|
6
|
-
} from "./message-embedder.js";
|
|
7
|
-
|
|
8
|
-
describe("getMessageEmbedder", () => {
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
_resetMessageEmbedderForTest();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("adapts the batch embedding service into a single-text message embedder", async () => {
|
|
14
|
-
const vector = await getMessageEmbedder().embed("invoice from stripe");
|
|
15
|
-
|
|
16
|
-
assert.ok(Array.isArray(vector));
|
|
17
|
-
assert.ok(vector.length > 0);
|
|
18
|
-
assert.ok(vector.every((value) => typeof value === "number"));
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("embeds the same text deterministically", async () => {
|
|
22
|
-
const embedder = getMessageEmbedder();
|
|
23
|
-
const a = await embedder.embed("your monthly receipt");
|
|
24
|
-
const b = await embedder.embed("your monthly receipt");
|
|
25
|
-
|
|
26
|
-
assert.deepEqual(a, b);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("memoizes the embedder across calls", () => {
|
|
30
|
-
assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("rebuilds after a reset", () => {
|
|
34
|
-
const first = getMessageEmbedder();
|
|
35
|
-
_resetMessageEmbedderForTest();
|
|
36
|
-
|
|
37
|
-
assert.notStrictEqual(getMessageEmbedder(), first);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
|
|
2
|
-
import type { MessageEmbedder } from "./pipeline.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Adapt the batch {@link EmbeddingService} the rest of the stack composes from
|
|
6
|
-
* env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
|
|
7
|
-
* incoming message becomes one message-level vector to compare against a filter's
|
|
8
|
-
* persisted anchor (RFC 034 Decision 2.1/2.3).
|
|
9
|
-
*
|
|
10
|
-
* The embedder is selected by the same `SEARCH_EMBEDDING_*` env the search-index
|
|
11
|
-
* worker reads, so every filter pass embeds under the identical model the anchors
|
|
12
|
-
* were built with. The instance is memoized across warm invocations so a local
|
|
13
|
-
* Transformers.js model loads once per container rather than per event.
|
|
14
|
-
*/
|
|
15
|
-
let cached: MessageEmbedder | undefined;
|
|
16
|
-
|
|
17
|
-
export const getMessageEmbedder = (): MessageEmbedder => {
|
|
18
|
-
if (cached) return cached;
|
|
19
|
-
const service = buildEmbeddingServiceFromEnv();
|
|
20
|
-
cached = {
|
|
21
|
-
embed: async (text: string): Promise<number[]> => {
|
|
22
|
-
const [vector] = await service.embed([text]);
|
|
23
|
-
return vector;
|
|
24
|
-
},
|
|
25
|
-
embeddingId: service.embeddingId,
|
|
26
|
-
};
|
|
27
|
-
return cached;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** Reset the singleton — test use only. */
|
|
31
|
-
export const _resetMessageEmbedderForTest = (): void => {
|
|
32
|
-
cached = undefined;
|
|
33
|
-
};
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The index-time filter pass over an anchorless literal filter — the shape the
|
|
3
|
-
* organize widen creates on a deployment without the vector pipeline: `From`
|
|
4
|
-
* clauses combined with `Or`, `hasAnchor: false`. This is what keeps a standing
|
|
5
|
-
* "move all mail from these senders" filter working on future mail with no
|
|
6
|
-
* vectors, so it is pinned directly: a matching sender resolves the move, a
|
|
7
|
-
* non-matching one resolves nothing, and the embedder is never touched.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import assert from "node:assert/strict";
|
|
11
|
-
import { describe, it } from "node:test";
|
|
12
|
-
import type {
|
|
13
|
-
FilterItem,
|
|
14
|
-
IFilterAnchorRepository,
|
|
15
|
-
IFilterRepository,
|
|
16
|
-
IMessageLabelRepository,
|
|
17
|
-
} from "@remit/data-ports";
|
|
18
|
-
import {
|
|
19
|
-
FilterClauseField,
|
|
20
|
-
FilterMatchOperator,
|
|
21
|
-
FilterState,
|
|
22
|
-
} from "@remit/domain-enums";
|
|
23
|
-
import type { PlacementMoveService } from "../placement-move.js";
|
|
24
|
-
import { NO_ACTION } from "./match.js";
|
|
25
|
-
import { type FilterConfig, FilterPipeline } from "./pipeline.js";
|
|
26
|
-
|
|
27
|
-
const senderFilter = (destinationMailboxId: string): FilterItem =>
|
|
28
|
-
({
|
|
29
|
-
filterId: "flt-senders",
|
|
30
|
-
accountConfigId: "cfg-1",
|
|
31
|
-
name: "npm barrage",
|
|
32
|
-
scope: "Standing",
|
|
33
|
-
state: FilterState.Active,
|
|
34
|
-
hasAnchor: false,
|
|
35
|
-
ruleChangedAt: 1,
|
|
36
|
-
matchOperator: FilterMatchOperator.Or,
|
|
37
|
-
literalClauses: [
|
|
38
|
-
{ field: FilterClauseField.From, value: "npm@github.com" },
|
|
39
|
-
{ field: FilterClauseField.From, value: "notifications@github.com" },
|
|
40
|
-
],
|
|
41
|
-
actionLabelId: NO_ACTION,
|
|
42
|
-
actionMailboxId: destinationMailboxId,
|
|
43
|
-
createdAt: 1,
|
|
44
|
-
updatedAt: 1,
|
|
45
|
-
}) as unknown as FilterItem;
|
|
46
|
-
|
|
47
|
-
const buildPipeline = (...filters: FilterItem[]) => {
|
|
48
|
-
const state = { embedCalls: 0 };
|
|
49
|
-
const config: FilterConfig = {
|
|
50
|
-
filterService: {
|
|
51
|
-
listByAccountAndState: async () => filters,
|
|
52
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
53
|
-
} as unknown as IFilterRepository,
|
|
54
|
-
filterAnchorService: {
|
|
55
|
-
get: async () => undefined,
|
|
56
|
-
} as unknown as IFilterAnchorRepository,
|
|
57
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
58
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
59
|
-
embedder: {
|
|
60
|
-
embed: async () => {
|
|
61
|
-
state.embedCalls += 1;
|
|
62
|
-
return [];
|
|
63
|
-
},
|
|
64
|
-
embeddingId: "test-model@0",
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
describe("FilterPipeline — anchorless From/Or filter at index time", () => {
|
|
71
|
-
it("resolves the move for a matching sender and never embeds", async () => {
|
|
72
|
-
const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
|
|
73
|
-
const decision = await pipeline.evaluate("cfg-1", "m-1", {
|
|
74
|
-
from: "npm@github.com",
|
|
75
|
-
fromName: "npm",
|
|
76
|
-
subject: "A new version of left-pad is available",
|
|
77
|
-
text: "body",
|
|
78
|
-
listId: "",
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
assert.deepEqual(decision.move, {
|
|
82
|
-
destinationMailboxId: "mbx-archive",
|
|
83
|
-
filterId: "flt-senders",
|
|
84
|
-
});
|
|
85
|
-
// A literal-only filter is decided by its clauses alone — the embedder is
|
|
86
|
-
// never constructed or called.
|
|
87
|
-
assert.equal(state.embedCalls, 0);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("resolves nothing for a sender none of the clauses match", async () => {
|
|
91
|
-
const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
|
|
92
|
-
const decision = await pipeline.evaluate("cfg-1", "m-2", {
|
|
93
|
-
from: "hello@stripe.com",
|
|
94
|
-
fromName: "Stripe",
|
|
95
|
-
subject: "Your receipt",
|
|
96
|
-
text: "body",
|
|
97
|
-
listId: "",
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
assert.equal(decision.move, undefined);
|
|
101
|
-
assert.deepEqual(decision.labels, []);
|
|
102
|
-
assert.equal(state.embedCalls, 0);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Arbitration survives exactly here: two standing filters colliding over the
|
|
108
|
-
* same message at the same trigger. A move is exclusive, so the pass resolves
|
|
109
|
-
* one destination — the more-recently-changed action wins (reader #497 leaves
|
|
110
|
-
* this untouched while removing arbitration from the user-initiated apply).
|
|
111
|
-
*/
|
|
112
|
-
describe("FilterPipeline — two standing filters colliding on one message", () => {
|
|
113
|
-
const rivalFilter = (): FilterItem =>
|
|
114
|
-
({
|
|
115
|
-
...senderFilter("mbx-projects"),
|
|
116
|
-
filterId: "flt-rival",
|
|
117
|
-
name: "github to projects",
|
|
118
|
-
actionChangedAt: 2,
|
|
119
|
-
}) as unknown as FilterItem;
|
|
120
|
-
|
|
121
|
-
it("resolves a single destination from the more-recently-changed action", async () => {
|
|
122
|
-
const older = {
|
|
123
|
-
...senderFilter("mbx-archive"),
|
|
124
|
-
actionChangedAt: 1,
|
|
125
|
-
} as unknown as FilterItem;
|
|
126
|
-
const { pipeline } = buildPipeline(older, rivalFilter());
|
|
127
|
-
const decision = await pipeline.evaluate("cfg-1", "m-3", {
|
|
128
|
-
from: "npm@github.com",
|
|
129
|
-
fromName: "npm",
|
|
130
|
-
subject: "A new version of left-pad is available",
|
|
131
|
-
text: "body",
|
|
132
|
-
listId: "",
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
assert.deepEqual(decision.move, {
|
|
136
|
-
destinationMailboxId: "mbx-projects",
|
|
137
|
-
filterId: "flt-rival",
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("FilterPipeline — anchor lazy re-embed on model drift (reader #295)", () => {
|
|
143
|
-
const anchoredFilter = (): FilterItem =>
|
|
144
|
-
({
|
|
145
|
-
filterId: "flt-semantic",
|
|
146
|
-
accountConfigId: "cfg-1",
|
|
147
|
-
name: "receipts",
|
|
148
|
-
scope: "Standing",
|
|
149
|
-
state: FilterState.Active,
|
|
150
|
-
hasAnchor: true,
|
|
151
|
-
ruleChangedAt: 1,
|
|
152
|
-
matchOperator: FilterMatchOperator.And,
|
|
153
|
-
literalClauses: [],
|
|
154
|
-
actionLabelId: "lbl-1",
|
|
155
|
-
actionMailboxId: NO_ACTION,
|
|
156
|
-
createdAt: 1,
|
|
157
|
-
updatedAt: 1,
|
|
158
|
-
}) as unknown as FilterItem;
|
|
159
|
-
|
|
160
|
-
const message = {
|
|
161
|
-
from: "billing@stripe.com",
|
|
162
|
-
fromName: "Stripe",
|
|
163
|
-
subject: "Your receipt",
|
|
164
|
-
text: "Thanks for your payment",
|
|
165
|
-
listId: "",
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
|
|
169
|
-
const putCalls: Array<Record<string, unknown>> = [];
|
|
170
|
-
let embedCalls = 0;
|
|
171
|
-
const config: FilterConfig = {
|
|
172
|
-
filterService: {
|
|
173
|
-
listByAccountAndState: async () => [anchoredFilter()],
|
|
174
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
175
|
-
} as unknown as IFilterRepository,
|
|
176
|
-
filterAnchorService: {
|
|
177
|
-
get: async () => ({
|
|
178
|
-
accountConfigId: "cfg-1",
|
|
179
|
-
filterId: "flt-semantic",
|
|
180
|
-
// Stale-model space — orthogonal to what the current model
|
|
181
|
-
// produces, so a raw comparison against it would not match.
|
|
182
|
-
anchorEmbedding: [0, 1, 0],
|
|
183
|
-
anchorEmbeddingId: "old-model@3",
|
|
184
|
-
anchorSourceText: "your receipt is ready",
|
|
185
|
-
anchorMessageId: "msg-anchor",
|
|
186
|
-
}),
|
|
187
|
-
put: async (input: Record<string, unknown>) => {
|
|
188
|
-
putCalls.push(input);
|
|
189
|
-
return { ...input, createdAt: 1, updatedAt: 2 };
|
|
190
|
-
},
|
|
191
|
-
} as unknown as IFilterAnchorRepository,
|
|
192
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
193
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
194
|
-
embedder: {
|
|
195
|
-
embed: async () => {
|
|
196
|
-
embedCalls += 1;
|
|
197
|
-
return [1, 0, 0];
|
|
198
|
-
},
|
|
199
|
-
embeddingId: "new-model@3",
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const decision = await new FilterPipeline(config, {
|
|
204
|
-
info: () => {},
|
|
205
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
206
|
-
|
|
207
|
-
assert.deepEqual(
|
|
208
|
-
decision.labels,
|
|
209
|
-
[{ labelId: "lbl-1", filterId: "flt-semantic" }],
|
|
210
|
-
"the anchor matches once re-embedded into the current model's space",
|
|
211
|
-
);
|
|
212
|
-
assert.equal(
|
|
213
|
-
putCalls.length,
|
|
214
|
-
1,
|
|
215
|
-
"the refreshed anchor is written back exactly once",
|
|
216
|
-
);
|
|
217
|
-
assert.deepEqual(putCalls[0], {
|
|
218
|
-
accountConfigId: "cfg-1",
|
|
219
|
-
filterId: "flt-semantic",
|
|
220
|
-
anchorEmbedding: [1, 0, 0],
|
|
221
|
-
anchorEmbeddingId: "new-model@3",
|
|
222
|
-
anchorSourceText: "your receipt is ready",
|
|
223
|
-
anchorMessageId: "msg-anchor",
|
|
224
|
-
});
|
|
225
|
-
assert.equal(
|
|
226
|
-
embedCalls,
|
|
227
|
-
2,
|
|
228
|
-
"one embed re-embeds the anchor's source text, one embeds the candidate message",
|
|
229
|
-
);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("never re-embeds when the anchor's embeddingId is already current", async () => {
|
|
233
|
-
let putCalls = 0;
|
|
234
|
-
let embedCalls = 0;
|
|
235
|
-
const config: FilterConfig = {
|
|
236
|
-
filterService: {
|
|
237
|
-
listByAccountAndState: async () => [anchoredFilter()],
|
|
238
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
239
|
-
} as unknown as IFilterRepository,
|
|
240
|
-
filterAnchorService: {
|
|
241
|
-
get: async () => ({
|
|
242
|
-
anchorEmbedding: [1, 0, 0],
|
|
243
|
-
anchorEmbeddingId: "current-model@3",
|
|
244
|
-
anchorSourceText: "your receipt is ready",
|
|
245
|
-
anchorMessageId: "msg-anchor",
|
|
246
|
-
}),
|
|
247
|
-
put: async () => {
|
|
248
|
-
putCalls += 1;
|
|
249
|
-
throw new Error("must not be called");
|
|
250
|
-
},
|
|
251
|
-
} as unknown as IFilterAnchorRepository,
|
|
252
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
253
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
254
|
-
embedder: {
|
|
255
|
-
embed: async () => {
|
|
256
|
-
embedCalls += 1;
|
|
257
|
-
return [1, 0, 0];
|
|
258
|
-
},
|
|
259
|
-
embeddingId: "current-model@3",
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const decision = await new FilterPipeline(config, {
|
|
264
|
-
info: () => {},
|
|
265
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
266
|
-
|
|
267
|
-
assert.deepEqual(decision.labels, [
|
|
268
|
-
{ labelId: "lbl-1", filterId: "flt-semantic" },
|
|
269
|
-
]);
|
|
270
|
-
assert.equal(putCalls, 0, "a current anchor is never rewritten");
|
|
271
|
-
assert.equal(
|
|
272
|
-
embedCalls,
|
|
273
|
-
1,
|
|
274
|
-
"only the candidate message is embedded — no wasted re-embed call",
|
|
275
|
-
);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("degrades a re-embed failure to skipping just this filter, not the whole pass", async () => {
|
|
279
|
-
const literalFilter: FilterItem = {
|
|
280
|
-
filterId: "flt-literal",
|
|
281
|
-
accountConfigId: "cfg-1",
|
|
282
|
-
name: "stripe receipts",
|
|
283
|
-
scope: "Standing",
|
|
284
|
-
state: FilterState.Active,
|
|
285
|
-
hasAnchor: false,
|
|
286
|
-
ruleChangedAt: 1,
|
|
287
|
-
matchOperator: FilterMatchOperator.Or,
|
|
288
|
-
literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
|
|
289
|
-
actionLabelId: "lbl-literal",
|
|
290
|
-
actionMailboxId: NO_ACTION,
|
|
291
|
-
createdAt: 1,
|
|
292
|
-
updatedAt: 1,
|
|
293
|
-
} as unknown as FilterItem;
|
|
294
|
-
|
|
295
|
-
const config: FilterConfig = {
|
|
296
|
-
filterService: {
|
|
297
|
-
listByAccountAndState: async () => [anchoredFilter(), literalFilter],
|
|
298
|
-
refreshExpiry: async (f: FilterItem) => f,
|
|
299
|
-
} as unknown as IFilterRepository,
|
|
300
|
-
filterAnchorService: {
|
|
301
|
-
get: async () => ({
|
|
302
|
-
anchorEmbedding: [0, 1, 0],
|
|
303
|
-
anchorEmbeddingId: "old-model@3",
|
|
304
|
-
anchorSourceText: "your receipt is ready",
|
|
305
|
-
anchorMessageId: "msg-anchor",
|
|
306
|
-
}),
|
|
307
|
-
put: async () => {
|
|
308
|
-
throw new Error("SQLITE_BUSY");
|
|
309
|
-
},
|
|
310
|
-
} as unknown as IFilterAnchorRepository,
|
|
311
|
-
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
312
|
-
placementMoveService: {} as unknown as PlacementMoveService,
|
|
313
|
-
embedder: {
|
|
314
|
-
embed: async () => [1, 0, 0],
|
|
315
|
-
embeddingId: "new-model@3",
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
const decision = await new FilterPipeline(config, {
|
|
320
|
-
info: () => {},
|
|
321
|
-
error: () => {},
|
|
322
|
-
}).evaluate("cfg-1", "m-1", message);
|
|
323
|
-
|
|
324
|
-
assert.deepEqual(
|
|
325
|
-
decision.labels,
|
|
326
|
-
[{ labelId: "lbl-literal", filterId: "flt-literal" }],
|
|
327
|
-
"the anchored filter's re-embed failure is isolated — the literal filter still applies",
|
|
328
|
-
);
|
|
329
|
-
});
|
|
330
|
-
});
|
package/src/filters/pipeline.ts
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import { inspect } from "node:util";
|
|
2
|
-
import type {
|
|
3
|
-
FilterItem,
|
|
4
|
-
IFilterAnchorRepository,
|
|
5
|
-
IFilterRepository,
|
|
6
|
-
IMessageLabelRepository,
|
|
7
|
-
} from "@remit/data-ports";
|
|
8
|
-
import { FilterState } from "@remit/domain-enums";
|
|
9
|
-
import {
|
|
10
|
-
isEmbeddingCapabilityAbsence,
|
|
11
|
-
isEmbeddingCapabilityUnavailable,
|
|
12
|
-
recordEmbeddingCapabilityAbsence,
|
|
13
|
-
} from "@remit/search-service/capability";
|
|
14
|
-
import type { PlacementMoveService } from "../placement-move.js";
|
|
15
|
-
import {
|
|
16
|
-
type AnchorEmbedder,
|
|
17
|
-
refreshAnchorForEmbedder,
|
|
18
|
-
} from "./anchor-drift.js";
|
|
19
|
-
import {
|
|
20
|
-
buildMatchText,
|
|
21
|
-
cosineSimilarity,
|
|
22
|
-
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
23
|
-
type FilterMessage,
|
|
24
|
-
literalClausesMatch,
|
|
25
|
-
NO_ACTION,
|
|
26
|
-
selectMoveWinner,
|
|
27
|
-
} from "./match.js";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Turns the candidate message's text into a single message-level vector to
|
|
31
|
-
* compare against a filter's persisted `anchorEmbedding`. The anchor side is
|
|
32
|
-
* embedded here only to repair a drifted anchor in place (see
|
|
33
|
-
* {@link refreshAnchorForEmbedder}); the match itself reads `FilterAnchor` as
|
|
34
|
-
* a fixed fact (RFC 034 Decision 2.1/2.3), and the incoming message is
|
|
35
|
-
* embedded once per message, only when a semantic filter is actually in play.
|
|
36
|
-
*/
|
|
37
|
-
export type MessageEmbedder = AnchorEmbedder;
|
|
38
|
-
|
|
39
|
-
export interface FilterLogger {
|
|
40
|
-
info(obj: Record<string, unknown>, msg: string): void;
|
|
41
|
-
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
42
|
-
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
43
|
-
error?(obj: Record<string, unknown>, msg: string): void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Dependencies for the index-time filter pass (RFC 034). Optional on
|
|
48
|
-
* {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
|
|
49
|
-
* without it simply runs no filter matching.
|
|
50
|
-
*
|
|
51
|
-
* `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
|
|
52
|
-
* without it, so they are skipped when it is absent, but literal-clause filters
|
|
53
|
-
* always run. The `FilterAnchor` write path (a separate ticket) and a wired
|
|
54
|
-
* embedder are what light the semantic half up.
|
|
55
|
-
*/
|
|
56
|
-
export interface FilterConfig {
|
|
57
|
-
filterService: IFilterRepository;
|
|
58
|
-
filterAnchorService: IFilterAnchorRepository;
|
|
59
|
-
messageLabelService: IMessageLabelRepository;
|
|
60
|
-
placementMoveService: PlacementMoveService;
|
|
61
|
-
embedder?: MessageEmbedder;
|
|
62
|
-
similarityThreshold?: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* What the matched filters resolve to for one message: every matching filter's
|
|
67
|
-
* label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
|
|
68
|
-
* most-recently-changed matching filter, Decision 3.2).
|
|
69
|
-
*/
|
|
70
|
-
export interface FilterDecision {
|
|
71
|
-
labels: Array<{ labelId: string; filterId: string }>;
|
|
72
|
-
move?: { destinationMailboxId: string; filterId: string };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const EMPTY_DECISION: FilterDecision = { labels: [] };
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Evaluates a synced message against the account's active filters and resolves
|
|
79
|
-
* the actions to apply (RFC 034). Reads only — listing filters, refreshing lazy
|
|
80
|
-
* expiry, reading anchors, and embedding the message; the caller applies the
|
|
81
|
-
* resulting {@link FilterDecision}.
|
|
82
|
-
*
|
|
83
|
-
* `evaluate` isolates every failure in this read/decision phase from body sync,
|
|
84
|
-
* matching the placement-resolution precedent (#1246): a schema-drifted query or
|
|
85
|
-
* a bad anchor must never fail the surrounding message store, since the body is
|
|
86
|
-
* already durable by the time this runs. The apply of the decision — label
|
|
87
|
-
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
88
|
-
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
89
|
-
* requeues the message.
|
|
90
|
-
*/
|
|
91
|
-
export class FilterPipeline {
|
|
92
|
-
constructor(
|
|
93
|
-
private readonly config: FilterConfig,
|
|
94
|
-
private readonly log: FilterLogger,
|
|
95
|
-
) {}
|
|
96
|
-
|
|
97
|
-
async evaluate(
|
|
98
|
-
accountConfigId: string,
|
|
99
|
-
messageId: string,
|
|
100
|
-
msg: FilterMessage,
|
|
101
|
-
): Promise<FilterDecision> {
|
|
102
|
-
return this.match(accountConfigId, msg).catch((error: unknown) => {
|
|
103
|
-
this.log.error?.(
|
|
104
|
-
{
|
|
105
|
-
alert: "body_sync_filter_match_failed",
|
|
106
|
-
messageId,
|
|
107
|
-
accountConfigId,
|
|
108
|
-
errorName: (error as { name?: string })?.name,
|
|
109
|
-
error: inspect(error),
|
|
110
|
-
},
|
|
111
|
-
"Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
|
|
112
|
-
);
|
|
113
|
-
return EMPTY_DECISION;
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private async match(
|
|
118
|
-
accountConfigId: string,
|
|
119
|
-
msg: FilterMessage,
|
|
120
|
-
): Promise<FilterDecision> {
|
|
121
|
-
const active = await this.config.filterService.listByAccountAndState(
|
|
122
|
-
accountConfigId,
|
|
123
|
-
FilterState.Active,
|
|
124
|
-
);
|
|
125
|
-
if (active.length === 0) return EMPTY_DECISION;
|
|
126
|
-
|
|
127
|
-
// The message is embedded lazily and at most once: `undefined` means not
|
|
128
|
-
// yet computed, `null` means computed-but-unavailable (no embedder). The
|
|
129
|
-
// embed only ever fires when a filter survives the literal pre-filter and
|
|
130
|
-
// carries an anchor — a purely-literal account never pays for it.
|
|
131
|
-
let messageEmbedding: number[] | null | undefined;
|
|
132
|
-
const embed = async (): Promise<number[] | null> => {
|
|
133
|
-
if (messageEmbedding !== undefined) return messageEmbedding;
|
|
134
|
-
const embedder = this.config.embedder;
|
|
135
|
-
if (!embedder) {
|
|
136
|
-
messageEmbedding = null;
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
messageEmbedding = await embedder
|
|
140
|
-
.embed(buildMatchText(msg))
|
|
141
|
-
.catch((error: unknown) => {
|
|
142
|
-
// This deployment carries no embedding pipeline at all — the
|
|
143
|
-
// container images ship without `@huggingface/transformers`. That is
|
|
144
|
-
// the same absence `SEARCH_EMBEDDING_PROVIDER=off` states, so it takes
|
|
145
|
-
// the same designed skip rather than an error-level
|
|
146
|
-
// `filter_anchor_match_failed` per semantic filter per message. The
|
|
147
|
-
// absence is remembered process-wide, so this is the only embed that
|
|
148
|
-
// pays for it: `buildFilterConfig` wires no embedder afterwards.
|
|
149
|
-
if (!isEmbeddingCapabilityAbsence(error)) throw error;
|
|
150
|
-
if (!isEmbeddingCapabilityUnavailable()) {
|
|
151
|
-
this.log.warn?.(
|
|
152
|
-
{
|
|
153
|
-
accountConfigId,
|
|
154
|
-
error: inspect(error),
|
|
155
|
-
},
|
|
156
|
-
"No embedding pipeline in this deployment; semantic filters are skipped (literal filters still run)",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
recordEmbeddingCapabilityAbsence(error);
|
|
160
|
-
return null;
|
|
161
|
-
});
|
|
162
|
-
return messageEmbedding;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const matched: FilterItem[] = [];
|
|
166
|
-
for (const filter of active) {
|
|
167
|
-
// Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
|
|
168
|
-
// its expiresAt patches it to Expired on this read and drops it from
|
|
169
|
-
// evaluation, independent of whether the TTL delete has run.
|
|
170
|
-
const usable = await this.config.filterService.refreshExpiry(filter);
|
|
171
|
-
if (usable.state !== FilterState.Active) continue;
|
|
172
|
-
// A bad or stale anchor vector (a dimension mismatch under a changed
|
|
173
|
-
// embedding model) throws from cosineSimilarity. Isolate it to this
|
|
174
|
-
// filter: it is skipped and loudly logged, and every other filter on
|
|
175
|
-
// the message still evaluates and applies. This sits inside the
|
|
176
|
-
// evaluate-level catch — a whole-phase failure still degrades to no
|
|
177
|
-
// filter actions — but stops one poisoned anchor from doing so.
|
|
178
|
-
const isMatch = await this.filterMatches(
|
|
179
|
-
accountConfigId,
|
|
180
|
-
usable,
|
|
181
|
-
msg,
|
|
182
|
-
embed,
|
|
183
|
-
).catch((error: unknown) => {
|
|
184
|
-
this.log.error?.(
|
|
185
|
-
{
|
|
186
|
-
alert: "filter_anchor_match_failed",
|
|
187
|
-
filterId: usable.filterId,
|
|
188
|
-
accountConfigId,
|
|
189
|
-
errorName: (error as { name?: string })?.name,
|
|
190
|
-
error: inspect(error),
|
|
191
|
-
},
|
|
192
|
-
"Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
|
|
193
|
-
);
|
|
194
|
-
return "skip" as const;
|
|
195
|
-
});
|
|
196
|
-
if (isMatch === "skip") continue;
|
|
197
|
-
if (isMatch) matched.push(usable);
|
|
198
|
-
}
|
|
199
|
-
if (matched.length === 0) return EMPTY_DECISION;
|
|
200
|
-
|
|
201
|
-
const labels: FilterDecision["labels"] = [];
|
|
202
|
-
const seenLabels = new Set<string>();
|
|
203
|
-
for (const filter of matched) {
|
|
204
|
-
if (filter.actionLabelId === NO_ACTION) continue;
|
|
205
|
-
if (seenLabels.has(filter.actionLabelId)) continue;
|
|
206
|
-
seenLabels.add(filter.actionLabelId);
|
|
207
|
-
labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const winner = selectMoveWinner(
|
|
211
|
-
matched.filter((filter) => filter.actionMailboxId !== NO_ACTION),
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
labels,
|
|
216
|
-
...(winner
|
|
217
|
-
? {
|
|
218
|
-
move: {
|
|
219
|
-
destinationMailboxId: winner.actionMailboxId,
|
|
220
|
-
filterId: winner.filterId,
|
|
221
|
-
},
|
|
222
|
-
}
|
|
223
|
-
: {}),
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private async filterMatches(
|
|
228
|
-
accountConfigId: string,
|
|
229
|
-
filter: FilterItem,
|
|
230
|
-
msg: FilterMessage,
|
|
231
|
-
embed: () => Promise<number[] | null>,
|
|
232
|
-
): Promise<boolean> {
|
|
233
|
-
if (
|
|
234
|
-
!literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
|
|
235
|
-
) {
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (!filter.hasAnchor) {
|
|
240
|
-
// A filter with neither clauses nor an anchor has no predicate and
|
|
241
|
-
// matches nothing; a literal-only filter matched iff it had clauses.
|
|
242
|
-
return filter.literalClauses.length > 0;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// One deterministic point read per semantic candidate that survived the
|
|
246
|
-
// literal pre-filter — never a scan, never the anchor message itself
|
|
247
|
-
// (RFC 034 Decision 2.3).
|
|
248
|
-
let anchor = await this.config.filterAnchorService.get(
|
|
249
|
-
accountConfigId,
|
|
250
|
-
filter.filterId,
|
|
251
|
-
);
|
|
252
|
-
if (!anchor) {
|
|
253
|
-
this.log.warn?.(
|
|
254
|
-
{ filterId: filter.filterId, accountConfigId },
|
|
255
|
-
"Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
|
|
256
|
-
);
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// A re-embed failure here propagates to the per-filter catch in
|
|
261
|
-
// `match()`, which logs and skips this filter for this one evaluation
|
|
262
|
-
// exactly as an unrecoverable stale anchor does today.
|
|
263
|
-
anchor = await refreshAnchorForEmbedder(
|
|
264
|
-
{
|
|
265
|
-
anchorRepository: this.config.filterAnchorService,
|
|
266
|
-
embedder: this.config.embedder,
|
|
267
|
-
},
|
|
268
|
-
anchor,
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
const vector = await embed();
|
|
272
|
-
if (!vector) {
|
|
273
|
-
this.log.debug?.(
|
|
274
|
-
{ filterId: filter.filterId },
|
|
275
|
-
"No embedder configured; skipping semantic filter",
|
|
276
|
-
);
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const threshold =
|
|
281
|
-
this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
282
|
-
return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
|
|
283
|
-
}
|
|
284
|
-
}
|