@remit/backend 0.0.28 → 0.0.30
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
|
@@ -213,7 +213,14 @@ const candidate = (
|
|
|
213
213
|
over: Partial<OrganizeCandidate["message"]> = {},
|
|
214
214
|
): OrganizeCandidate => ({
|
|
215
215
|
messageId,
|
|
216
|
-
message: {
|
|
216
|
+
message: {
|
|
217
|
+
from: "",
|
|
218
|
+
fromName: "",
|
|
219
|
+
subject: "",
|
|
220
|
+
text: "",
|
|
221
|
+
listId: "",
|
|
222
|
+
...over,
|
|
223
|
+
},
|
|
217
224
|
});
|
|
218
225
|
|
|
219
226
|
describe("matchOrganize", () => {
|
|
@@ -597,3 +604,115 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
597
604
|
);
|
|
598
605
|
});
|
|
599
606
|
});
|
|
607
|
+
|
|
608
|
+
describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
609
|
+
const senderChunk = (
|
|
610
|
+
messageId: string,
|
|
611
|
+
fromEmail: string,
|
|
612
|
+
over: Partial<ChunkMetadata> = {},
|
|
613
|
+
): VectorRecord => ({
|
|
614
|
+
chunkId: `${messageId}#sender`,
|
|
615
|
+
vector: ANCHOR_VECTOR,
|
|
616
|
+
metadata: metadata({
|
|
617
|
+
messageId,
|
|
618
|
+
chunkType: "sender",
|
|
619
|
+
textPreview: fromEmail,
|
|
620
|
+
...over,
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("matches a ListId clause exactly off the vector-free projection", async () => {
|
|
625
|
+
const deps = vectorlessDeps([
|
|
626
|
+
candidate("msg-1", { listId: "weekly.news.example.com" }),
|
|
627
|
+
candidate("msg-2", { listId: "news.example.com" }),
|
|
628
|
+
candidate("msg-3", {}),
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
632
|
+
...predicate(),
|
|
633
|
+
anchorMessageId: "None",
|
|
634
|
+
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("matches a FromDomain clause public-suffix aware off the vector-free projection", async () => {
|
|
641
|
+
const deps = vectorlessDeps([
|
|
642
|
+
candidate("msg-1", { from: "notifications@github.com" }),
|
|
643
|
+
candidate("msg-2", { from: "ci@sub.github.com" }),
|
|
644
|
+
candidate("msg-3", { from: "attacker@github.com.evil.example" }),
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
648
|
+
...predicate(),
|
|
649
|
+
anchorMessageId: "None",
|
|
650
|
+
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
assert.deepEqual(messageIds, ["msg-1", "msg-2"]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("round-trips ListId and FromDomain through the semantic-arm chunk projection", async () => {
|
|
657
|
+
const store = createMemoryVectorStore();
|
|
658
|
+
await store.upsert([
|
|
659
|
+
senderChunk("msg-1", "ci@github.com", {
|
|
660
|
+
listId: "actions.github.com",
|
|
661
|
+
}),
|
|
662
|
+
bodyChunk("msg-1", ANCHOR_VECTOR, { listId: "actions.github.com" }),
|
|
663
|
+
senderChunk("msg-2", "hi@othersender.example", {
|
|
664
|
+
listId: "other.list.example",
|
|
665
|
+
}),
|
|
666
|
+
bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
const byListId = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
|
|
670
|
+
...predicate(),
|
|
671
|
+
literalClauses: [{ field: "ListId", value: "actions.github.com" }],
|
|
672
|
+
});
|
|
673
|
+
assert.deepEqual(byListId.messageIds, ["msg-1"]);
|
|
674
|
+
|
|
675
|
+
const byFromDomain = await matchOrganize(
|
|
676
|
+
matchDeps(store),
|
|
677
|
+
ACCOUNT_CONFIG_ID,
|
|
678
|
+
{
|
|
679
|
+
...predicate(),
|
|
680
|
+
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
assert.deepEqual(byFromDomain.messageIds, ["msg-1"]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("applies a ListId predicate to exactly the previewed set (preview == apply)", async () => {
|
|
687
|
+
const deps = vectorlessDeps([
|
|
688
|
+
candidate("msg-1", { listId: "weekly.news.example.com" }),
|
|
689
|
+
candidate("msg-2", { listId: "weekly.news.example.com" }),
|
|
690
|
+
candidate("msg-3", { listId: "other.example.com" }),
|
|
691
|
+
]);
|
|
692
|
+
const p = predicate({
|
|
693
|
+
anchorMessageId: "None",
|
|
694
|
+
actionLabelId: "lbl-list",
|
|
695
|
+
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const previewed = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
|
|
699
|
+
const applied = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
|
|
700
|
+
assert.deepEqual(previewed, applied);
|
|
701
|
+
assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
|
|
702
|
+
|
|
703
|
+
const tracked = trackingClient();
|
|
704
|
+
const result = await applyOrganize(
|
|
705
|
+
{ client: tracked.client },
|
|
706
|
+
ACCOUNT_CONFIG_ID,
|
|
707
|
+
applied.messageIds,
|
|
708
|
+
p,
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
assert.equal(result.applied, 2);
|
|
712
|
+
assert.equal(result.failed, 0);
|
|
713
|
+
assert.deepEqual(tracked.labeled.map((row) => row.messageId).sort(), [
|
|
714
|
+
"msg-1",
|
|
715
|
+
"msg-2",
|
|
716
|
+
]);
|
|
717
|
+
});
|
|
718
|
+
});
|
package/src/service/organize.ts
CHANGED
|
@@ -131,11 +131,13 @@ const filterMessageFromChunks = (
|
|
|
131
131
|
let subject = "";
|
|
132
132
|
let fromName = "";
|
|
133
133
|
let from = "";
|
|
134
|
+
let listId = "";
|
|
134
135
|
const textParts: string[] = [];
|
|
135
136
|
for (const record of records) {
|
|
136
137
|
const meta = record.metadata;
|
|
137
138
|
if (meta.subject && !subject) subject = meta.subject;
|
|
138
139
|
if (meta.fromName && !fromName) fromName = meta.fromName;
|
|
140
|
+
if (meta.listId && !listId) listId = meta.listId;
|
|
139
141
|
if (meta.chunkType === "sender" && meta.textPreview && !from) {
|
|
140
142
|
from = meta.textPreview;
|
|
141
143
|
}
|
|
@@ -146,7 +148,7 @@ const filterMessageFromChunks = (
|
|
|
146
148
|
textParts.push(meta.textPreview);
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
return { from, fromName, subject, text: textParts.join("\n") };
|
|
151
|
+
return { from, fromName, subject, listId, text: textParts.join("\n") };
|
|
150
152
|
};
|
|
151
153
|
|
|
152
154
|
/**
|
|
@@ -355,8 +357,10 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
355
357
|
* deployment that ships no vector pipeline. Deduped by message id — the same
|
|
356
358
|
* mail filed in two folders is one candidate.
|
|
357
359
|
*
|
|
358
|
-
* `From`/`Subject` come from the row verbatim (full fidelity)
|
|
359
|
-
*
|
|
360
|
+
* `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
|
|
361
|
+
* `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
|
|
362
|
+
* does at index time. `text` (the body) is left empty: the thread row carries
|
|
363
|
+
* only a truncated `snippet`, and matching
|
|
360
364
|
* a body-content clause against a preview would silently diverge from the live
|
|
361
365
|
* index-time filter's full-body match. Body-content (`HasWords`) clauses are
|
|
362
366
|
* rejected before this is read (see {@link assertNoBodyContentClause}), so the
|
|
@@ -384,6 +388,7 @@ const listAccountFilterMessagesFromClient =
|
|
|
384
388
|
fromName: row.fromName ?? "",
|
|
385
389
|
subject: row.subject ?? "",
|
|
386
390
|
text: "",
|
|
391
|
+
listId: row.listId ?? "",
|
|
387
392
|
},
|
|
388
393
|
});
|
|
389
394
|
if (candidates.length >= limit) return candidates;
|
|
@@ -4,14 +4,23 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Semantic-search capability gate for the self-host compose profiles.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* embedding
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* This gates the free-text `/search/semantic` path only. Answering that query
|
|
8
|
+
* means embedding it in-process, and the backend container image ships without
|
|
9
|
+
* `@huggingface/transformers` (the model plus its dependencies would roughly
|
|
10
|
+
* quadruple the image — see npm-scripts/docker-bundle.mjs and
|
|
11
|
+
* deploy/vps/README.md). The embedder is reached lazily through `runtimeImport`,
|
|
12
|
+
* so the first semantic query — not startup — hits ERR_MODULE_NOT_FOUND. Without
|
|
13
|
+
* this gate every search the web client issues fires a `/search/semantic`
|
|
14
|
+
* request that 500s (the client fetches semantic hits alongside every literal
|
|
15
|
+
* search), retried by the client on top.
|
|
16
|
+
*
|
|
17
|
+
* The vector STORE is a separate capability and is NOT gated here. The Organize
|
|
18
|
+
* anchor widen (organize.ts matchSemantic) pools an anchor message's already
|
|
19
|
+
* stored chunk vectors and runs a KNN read over the vector store — no embedding
|
|
20
|
+
* model involved — so it needs only `sqlite-vec`, which the backend image now
|
|
21
|
+
* carries as a musl build (Dockerfile sqlite-vec-musl stage,
|
|
22
|
+
* SQLITE_VEC_EXTENSION_PATH). matchOrganize never consults the memoized flag
|
|
23
|
+
* below; a missing embedder therefore never disables the widen.
|
|
15
24
|
*
|
|
16
25
|
* The e2e-dev lane runs this backend from source rather than the container, so
|
|
17
26
|
* `@huggingface/transformers` IS present and the local embedder instead tries to
|