@remit/search-service 0.0.1

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.
Files changed (39) hide show
  1. package/package.json +66 -0
  2. package/src/anchor.test.ts +164 -0
  3. package/src/anchor.ts +127 -0
  4. package/src/backends/bedrock.test.ts +148 -0
  5. package/src/backends/bedrock.ts +105 -0
  6. package/src/backends/memory.test.ts +168 -0
  7. package/src/backends/memory.ts +152 -0
  8. package/src/backends/pgvector.integ.test.ts +174 -0
  9. package/src/backends/pgvector.ts +306 -0
  10. package/src/backends/runtime-import.ts +16 -0
  11. package/src/backends/s3-vectors.test.ts +929 -0
  12. package/src/backends/s3-vectors.ts +383 -0
  13. package/src/backends/sqlite-vec.integ.test.ts +144 -0
  14. package/src/backends/sqlite-vec.ts +250 -0
  15. package/src/bedrock.ts +4 -0
  16. package/src/chunking/chunker.test.ts +79 -0
  17. package/src/chunking/chunker.ts +56 -0
  18. package/src/chunking/entities.test.ts +82 -0
  19. package/src/chunking/entities.ts +74 -0
  20. package/src/chunking/entropy.test.ts +98 -0
  21. package/src/chunking/entropy.ts +161 -0
  22. package/src/chunking/keys.ts +22 -0
  23. package/src/chunking/structured.test.ts +120 -0
  24. package/src/chunking/structured.ts +79 -0
  25. package/src/content-hash.test.ts +27 -0
  26. package/src/content-hash.ts +10 -0
  27. package/src/embeddings.test.ts +28 -0
  28. package/src/embeddings.ts +149 -0
  29. package/src/from-env.test.ts +62 -0
  30. package/src/from-env.ts +130 -0
  31. package/src/index.ts +71 -0
  32. package/src/pgvector.ts +4 -0
  33. package/src/s3-vectors.ts +5 -0
  34. package/src/search.test.ts +772 -0
  35. package/src/search.ts +395 -0
  36. package/src/semantic-search.integ.test.ts +130 -0
  37. package/src/sqlite-vec.ts +4 -0
  38. package/src/types.ts +155 -0
  39. package/tsconfig.json +8 -0
@@ -0,0 +1,250 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type SqliteDatabase from "better-sqlite3";
4
+ import type {
5
+ ChunkMetadata,
6
+ VectorMatch,
7
+ VectorQuery,
8
+ VectorQueryFilter,
9
+ VectorRecord,
10
+ } from "../types.js";
11
+ import type { VectorStoreService } from "./memory.js";
12
+ import { runtimeImport } from "./runtime-import.js";
13
+
14
+ type Database = SqliteDatabase.Database;
15
+
16
+ type BetterSqlite3Module = {
17
+ default: new (path: string) => Database;
18
+ };
19
+
20
+ type SqliteVecModule = {
21
+ load: (db: Database) => void;
22
+ };
23
+
24
+ /**
25
+ * vec0 stores each chunk's vector alongside the scalar fields the query path
26
+ * filters on, so equality / range filters are pushed into the KNN instead of
27
+ * post-filtering a fixed top-k (which would silently drop recall). The full
28
+ * metadata object rides along in an auxiliary (`+`) column for reconstruction.
29
+ *
30
+ * `mailboxId` is a single-value scalar here because the indexing pipeline always
31
+ * writes a one-element `mailboxIds`; membership therefore reduces to equality on
32
+ * the stored value.
33
+ */
34
+ const CREATE_TABLE = (dimensions: number): string => `
35
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
36
+ chunk_id TEXT PRIMARY KEY,
37
+ message_id TEXT,
38
+ account_config_id TEXT,
39
+ mailbox_id TEXT,
40
+ chunk_type TEXT,
41
+ category TEXT,
42
+ sent_date INTEGER,
43
+ is_read INTEGER,
44
+ has_attachment INTEGER,
45
+ has_stars INTEGER,
46
+ embedding FLOAT[${dimensions}] distance_metric=cosine,
47
+ +meta TEXT
48
+ );
49
+ `;
50
+
51
+ // vec0 INTEGER metadata columns are strict and better-sqlite3 binds a plain JS
52
+ // number as REAL, so every integer value bound against an INTEGER column (the
53
+ // booleans and sent_date) must be a BigInt to avoid an "Expected integer" error.
54
+ const bool = (value: boolean): bigint => (value ? 1n : 0n);
55
+
56
+ type BindValue = string | number | bigint;
57
+
58
+ interface WhereClause {
59
+ sql: string;
60
+ params: BindValue[];
61
+ }
62
+
63
+ const buildFilterClause = (
64
+ filter: VectorQueryFilter | undefined,
65
+ ): WhereClause => {
66
+ const sql: string[] = [];
67
+ const params: BindValue[] = [];
68
+ if (!filter) return { sql: "", params };
69
+
70
+ if (filter.accountConfigId !== undefined) {
71
+ sql.push("account_config_id = ?");
72
+ params.push(filter.accountConfigId);
73
+ }
74
+ if (filter.mailboxId !== undefined) {
75
+ sql.push("mailbox_id = ?");
76
+ params.push(filter.mailboxId);
77
+ }
78
+ if (filter.chunkType !== undefined) {
79
+ sql.push("chunk_type = ?");
80
+ params.push(filter.chunkType);
81
+ }
82
+ if (filter.category !== undefined) {
83
+ sql.push("category = ?");
84
+ params.push(filter.category);
85
+ }
86
+ if (filter.hasAttachment !== undefined) {
87
+ sql.push("has_attachment = ?");
88
+ params.push(bool(filter.hasAttachment));
89
+ }
90
+ if (filter.hasStars !== undefined) {
91
+ sql.push("has_stars = ?");
92
+ params.push(bool(filter.hasStars));
93
+ }
94
+ if (filter.isRead !== undefined) {
95
+ sql.push("is_read = ?");
96
+ params.push(bool(filter.isRead));
97
+ }
98
+ if (filter.sentDateRange) {
99
+ if (filter.sentDateRange.from !== undefined) {
100
+ sql.push("sent_date >= ?");
101
+ params.push(BigInt(Math.trunc(filter.sentDateRange.from)));
102
+ }
103
+ if (filter.sentDateRange.to !== undefined) {
104
+ sql.push("sent_date <= ?");
105
+ params.push(BigInt(Math.trunc(filter.sentDateRange.to)));
106
+ }
107
+ }
108
+
109
+ return { sql: sql.length > 0 ? ` AND ${sql.join(" AND ")}` : "", params };
110
+ };
111
+
112
+ export interface SqliteVectorStoreConfig {
113
+ path: string;
114
+ dimensions?: number;
115
+ }
116
+
117
+ const DEFAULT_DIMENSIONS = 384;
118
+
119
+ export const createSqliteVectorStore = (
120
+ config: SqliteVectorStoreConfig,
121
+ ): VectorStoreService => {
122
+ const dimensions = config.dimensions ?? DEFAULT_DIMENSIONS;
123
+ let dbPromise: Promise<Database> | null = null;
124
+
125
+ const getDb = async (): Promise<Database> => {
126
+ if (dbPromise) return dbPromise;
127
+ dbPromise = (async () => {
128
+ const { default: Database } =
129
+ await runtimeImport<BetterSqlite3Module>("better-sqlite3");
130
+ const sqliteVec = await runtimeImport<SqliteVecModule>("sqlite-vec");
131
+ if (config.path !== ":memory:") {
132
+ mkdirSync(dirname(config.path), { recursive: true });
133
+ }
134
+ const db = new Database(config.path);
135
+ db.pragma("journal_mode = WAL");
136
+ sqliteVec.load(db);
137
+ db.exec(CREATE_TABLE(dimensions));
138
+ return db;
139
+ })();
140
+ return dbPromise;
141
+ };
142
+
143
+ const upsert = async (vectors: VectorRecord[]): Promise<void> => {
144
+ if (vectors.length === 0) return;
145
+ const db = await getDb();
146
+ const del = db.prepare("DELETE FROM vec_chunks WHERE chunk_id = ?");
147
+ const ins = db.prepare(`
148
+ INSERT INTO vec_chunks (
149
+ chunk_id, message_id, account_config_id, mailbox_id, chunk_type,
150
+ category, sent_date, is_read, has_attachment, has_stars, embedding, meta
151
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ `);
153
+ const writeAll = db.transaction((records: VectorRecord[]) => {
154
+ for (const r of records) {
155
+ const m = r.metadata;
156
+ del.run(r.chunkId);
157
+ ins.run(
158
+ r.chunkId,
159
+ m.messageId,
160
+ m.accountConfigId,
161
+ m.mailboxIds[0] ?? "",
162
+ m.chunkType,
163
+ // vec0 filterable metadata columns are strict NOT NULL; a chunk
164
+ // with no category binds an empty-string sentinel (a `category = ?`
165
+ // filter never matches it) rather than NULL, which vec0 rejects.
166
+ // The returned metadata reads from the `meta` JSON column, so
167
+ // category stays correctly absent there.
168
+ m.category ?? "",
169
+ BigInt(Math.trunc(m.sentDate)),
170
+ bool(m.isRead),
171
+ bool(m.hasAttachment),
172
+ bool(m.hasStars),
173
+ JSON.stringify(r.vector),
174
+ JSON.stringify(m),
175
+ );
176
+ }
177
+ });
178
+ writeAll(vectors);
179
+ };
180
+
181
+ const query = async (params: VectorQuery): Promise<VectorMatch[]> => {
182
+ const db = await getDb();
183
+ const { sql: filterSql, params: filterParams } = buildFilterClause(
184
+ params.filter,
185
+ );
186
+ const stmt = db.prepare(`
187
+ SELECT chunk_id AS chunkId, distance, meta
188
+ FROM vec_chunks
189
+ WHERE embedding MATCH ? AND k = ?${filterSql}
190
+ ORDER BY distance
191
+ `);
192
+ const rows = stmt.all(
193
+ JSON.stringify(params.vector),
194
+ params.topK,
195
+ ...filterParams,
196
+ ) as { chunkId: string; distance: number; meta: string }[];
197
+ return rows.map((row) => ({
198
+ chunkId: row.chunkId,
199
+ score: 1 - row.distance,
200
+ metadata: JSON.parse(row.meta) as ChunkMetadata,
201
+ }));
202
+ };
203
+
204
+ const existingContentHashes = async (
205
+ chunkIds: string[],
206
+ ): Promise<Map<string, string>> => {
207
+ const out = new Map<string, string>();
208
+ if (chunkIds.length === 0) return out;
209
+ const db = await getDb();
210
+ const placeholders = chunkIds.map(() => "?").join(", ");
211
+ const stmt = db.prepare(
212
+ `SELECT chunk_id AS chunkId, meta FROM vec_chunks WHERE chunk_id IN (${placeholders})`,
213
+ );
214
+ const rows = stmt.all(...chunkIds) as { chunkId: string; meta: string }[];
215
+ for (const row of rows) {
216
+ const meta = JSON.parse(row.meta) as ChunkMetadata;
217
+ if (typeof meta.contentHash === "string") {
218
+ out.set(row.chunkId, meta.contentHash);
219
+ }
220
+ }
221
+ return out;
222
+ };
223
+
224
+ const getByMessage = async (messageId: string): Promise<VectorRecord[]> => {
225
+ const db = await getDb();
226
+ const stmt = db.prepare(
227
+ `SELECT chunk_id AS chunkId, vec_to_json(embedding) AS embedding, meta
228
+ FROM vec_chunks WHERE message_id = ?`,
229
+ );
230
+ const rows = stmt.all(messageId) as {
231
+ chunkId: string;
232
+ embedding: string;
233
+ meta: string;
234
+ }[];
235
+ return rows.map((row) => ({
236
+ chunkId: row.chunkId,
237
+ vector: JSON.parse(row.embedding) as number[],
238
+ metadata: JSON.parse(row.meta) as ChunkMetadata,
239
+ }));
240
+ };
241
+
242
+ const del = async (filter: { messageId: string }): Promise<void> => {
243
+ const db = await getDb();
244
+ db.prepare("DELETE FROM vec_chunks WHERE message_id = ?").run(
245
+ filter.messageId,
246
+ );
247
+ };
248
+
249
+ return { upsert, query, existingContentHashes, getByMessage, delete: del };
250
+ };
package/src/bedrock.ts ADDED
@@ -0,0 +1,4 @@
1
+ export {
2
+ type BedrockEmbeddingConfig,
3
+ BedrockEmbeddingService,
4
+ } from "./backends/bedrock.js";
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ ChunkType,
5
+ EnvelopeChunkInput,
6
+ ParsedBodyForChunking,
7
+ } from "../types.js";
8
+ import { createEmailChunker } from "./chunker.js";
9
+ import { candidateChunkKeys } from "./keys.js";
10
+
11
+ const MESSAGE_ID = "msg-parity-1";
12
+
13
+ const envelope: EnvelopeChunkInput = {
14
+ from: { name: "Alice", email: "alice@example.com" },
15
+ to: [{ name: "Bob", email: "bob@example.com" }],
16
+ cc: [{ name: "Carol", email: "carol@example.com" }],
17
+ bcc: [],
18
+ subject: "Q1 2026 invoice review and operational metrics",
19
+ attachments: [
20
+ {
21
+ filename: "invoice-q1-2026.pdf",
22
+ contentType: "application/pdf",
23
+ size: 245_000,
24
+ },
25
+ ],
26
+ };
27
+
28
+ // Long, high-entropy prose with many UNIQUE entities so the chunker is forced to
29
+ // emit multiple body-N (oversized prose split past the per-chunk cap) and
30
+ // multiple entities-N (the entity summary exceeds the embed char budget).
31
+ const richBody = (): string =>
32
+ Array.from(
33
+ { length: 200 },
34
+ (_, i) =>
35
+ `Section ${i}: the quarterly review covered revenue growth and detailed ` +
36
+ `operational metrics across every region with specific commentary. ` +
37
+ `Contact person${i}@example.com or open ` +
38
+ `https://reports.example.com/region/${i}/full-quarterly-summary before ` +
39
+ `2026-03-15 regarding the €${i},500 budget allocation under discussion.`,
40
+ ).join("\n\n");
41
+
42
+ describe("createEmailChunker producer/deleter parity", () => {
43
+ const chunker = createEmailChunker();
44
+ const parsedBody: ParsedBodyForChunking = { text: richBody(), html: null };
45
+ const chunks = chunker.chunk({ envelope, parsedBody, messageId: MESSAGE_ID });
46
+
47
+ it("exercises every chunk type with multiple body and entity chunks", () => {
48
+ const types = new Set<ChunkType>(chunks.map((c) => c.chunkType));
49
+ const expectedTypes: ChunkType[] = [
50
+ "sender",
51
+ "recipient",
52
+ "subject",
53
+ "attachment",
54
+ "body",
55
+ "entities",
56
+ ];
57
+ for (const expected of expectedTypes) {
58
+ assert.ok(types.has(expected), `missing chunk type: ${expected}`);
59
+ }
60
+
61
+ const bodyChunks = chunks.filter((c) => c.chunkId.includes("::body-"));
62
+ const entityChunks = chunks.filter((c) =>
63
+ c.chunkId.includes("::entities-"),
64
+ );
65
+ assert.ok(bodyChunks.length > 1, "expected multiple body-N chunks");
66
+ assert.ok(entityChunks.length > 1, "expected multiple entities-N chunks");
67
+ });
68
+
69
+ it("emits only chunkIds the deleter can reap via candidateChunkKeys", () => {
70
+ const reapable = new Set(candidateChunkKeys(MESSAGE_ID));
71
+ for (const chunk of chunks) {
72
+ assert.ok(
73
+ reapable.has(chunk.chunkId),
74
+ `produced chunkId ${chunk.chunkId} is not in candidateChunkKeys — ` +
75
+ `the deleter can never reap it`,
76
+ );
77
+ }
78
+ });
79
+ });
@@ -0,0 +1,56 @@
1
+ import type {
2
+ Chunk,
3
+ EnvelopeChunkInput,
4
+ ParsedBodyForChunking,
5
+ } from "../types.js";
6
+ import { buildEntityChunks } from "./entities.js";
7
+ import { buildBodyChunks } from "./entropy.js";
8
+ import { chunkKeyFor } from "./keys.js";
9
+ import { buildStructuredChunks } from "./structured.js";
10
+
11
+ export interface ChunkInput {
12
+ envelope: EnvelopeChunkInput;
13
+ parsedBody: ParsedBodyForChunking;
14
+ messageId: string;
15
+ }
16
+
17
+ export interface EmailChunker {
18
+ chunk(input: ChunkInput): Chunk[];
19
+ }
20
+
21
+ const stripHtml = (html: string): string =>
22
+ html
23
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
24
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
25
+ .replace(/<[^>]+>/g, " ")
26
+ .replace(/&nbsp;/g, " ")
27
+ .replace(/&amp;/g, "&")
28
+ .replace(/&lt;/g, "<")
29
+ .replace(/&gt;/g, ">")
30
+ .replace(/&quot;/g, '"')
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+
34
+ const bodyText = (parsed: ParsedBodyForChunking): string => {
35
+ if (parsed.text && parsed.text.trim().length > 0) return parsed.text;
36
+ if (parsed.html && parsed.html.trim().length > 0)
37
+ return stripHtml(parsed.html);
38
+ return "";
39
+ };
40
+
41
+ export const createEmailChunker = (): EmailChunker => ({
42
+ chunk: ({ envelope, parsedBody, messageId }: ChunkInput): Chunk[] => {
43
+ const chunkIdFor = (suffix: string): string =>
44
+ chunkKeyFor(messageId, suffix);
45
+ const chunks: Chunk[] = [];
46
+ chunks.push(...buildStructuredChunks(envelope, chunkIdFor));
47
+
48
+ const text = bodyText(parsedBody);
49
+ if (text.length > 0) {
50
+ chunks.push(...buildBodyChunks(text, chunkIdFor));
51
+ chunks.push(...buildEntityChunks(text, chunkIdFor));
52
+ }
53
+
54
+ return chunks;
55
+ },
56
+ });
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { buildEntityChunks, extractEntities } from "./entities.js";
4
+
5
+ const idFor = (suffix: string): string => `msg-1::${suffix}`;
6
+
7
+ describe("extractEntities", () => {
8
+ it("extracts email addresses", () => {
9
+ const text = "Contact alice@example.com or bob@example.com for details.";
10
+ const ents = extractEntities(text);
11
+ assert.deepStrictEqual(ents.emails.sort(), [
12
+ "alice@example.com",
13
+ "bob@example.com",
14
+ ]);
15
+ });
16
+
17
+ it("dedupes repeated email addresses", () => {
18
+ const text = "alice@example.com is alice@example.com everywhere";
19
+ const ents = extractEntities(text);
20
+ assert.deepStrictEqual(ents.emails, ["alice@example.com"]);
21
+ });
22
+
23
+ it("extracts URLs", () => {
24
+ const text =
25
+ "Open https://docs.example.com/report and https://example.com/x";
26
+ const ents = extractEntities(text);
27
+ assert.ok(ents.urls.includes("https://docs.example.com/report"));
28
+ assert.ok(ents.urls.includes("https://example.com/x"));
29
+ });
30
+
31
+ it("extracts ISO and long-form dates", () => {
32
+ const text =
33
+ "The deadline is 2026-03-15 or March 15, 2026 — Q1 2026 cutoff.";
34
+ const ents = extractEntities(text);
35
+ assert.ok(ents.dates.includes("2026-03-15"));
36
+ assert.ok(ents.dates.some((d) => /March 15/.test(d)));
37
+ assert.ok(ents.dates.includes("Q1 2026"));
38
+ });
39
+
40
+ it("extracts currency amounts and percentages", () => {
41
+ const text = "The deal is worth €14,500 and the team beat target by 14%.";
42
+ const ents = extractEntities(text);
43
+ assert.ok(ents.amounts.some((a) => a.includes("14,500")));
44
+ assert.ok(ents.amounts.includes("14%"));
45
+ });
46
+ });
47
+
48
+ describe("buildEntityChunks", () => {
49
+ it("emits a single entities chunk when entities are found", () => {
50
+ const text = "Email alice@example.com about Q1 2026 invoice for €14,500.";
51
+ const chunks = buildEntityChunks(text, idFor);
52
+ assert.strictEqual(chunks.length, 1);
53
+ assert.strictEqual(chunks[0].chunkType, "entities");
54
+ assert.strictEqual(chunks[0].chunkId, "msg-1::entities");
55
+ assert.match(chunks[0].text, /alice@example\.com/);
56
+ assert.match(chunks[0].text, /Q1 2026/);
57
+ });
58
+
59
+ it("emits no chunk when no entities are found", () => {
60
+ const text = "just plain prose with no specific entities at all";
61
+ const chunks = buildEntityChunks(text, idFor);
62
+ assert.strictEqual(chunks.length, 0);
63
+ });
64
+
65
+ it("splits a huge Links list into multiple capped chunks", () => {
66
+ const links = Array.from(
67
+ { length: 4000 },
68
+ (_, i) =>
69
+ `Visit https://news.example.com/articles/${i}/read-the-full-story`,
70
+ ).join(" ");
71
+ const chunks = buildEntityChunks(links, idFor);
72
+
73
+ assert.ok(chunks.length > 1, "expected the oversized list to split");
74
+ for (const chunk of chunks) {
75
+ assert.strictEqual(chunk.chunkType, "entities");
76
+ assert.ok(
77
+ chunk.text.length <= 6000,
78
+ `chunk ${chunk.chunkId} length ${chunk.text.length} exceeds budget`,
79
+ );
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,74 @@
1
+ import type { Chunk } from "../types.js";
2
+ import { EMBED_CHAR_BUDGET, splitToCharBudget } from "./entropy.js";
3
+ import { MAX_CHUNKS_PER_TYPE } from "./keys.js";
4
+
5
+ const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
6
+ const URL_RE = /\bhttps?:\/\/[^\s<>"]+/gi;
7
+ const CURRENCY_RE =
8
+ /(?:[€$£¥]\s?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})?|\b\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})?\s?(?:EUR|USD|GBP|JPY)\b)/g;
9
+ const PERCENT_RE = /\b\d{1,3}(?:[.,]\d+)?%/g;
10
+ const ISO_DATE_RE = /\b\d{4}-\d{2}-\d{2}\b/g;
11
+ const LONG_DATE_RE =
12
+ /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}(?:,\s*\d{4})?\b/gi;
13
+ const QUARTER_RE = /\bQ[1-4]\s?\d{4}\b/g;
14
+
15
+ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
16
+
17
+ export interface ExtractedEntities {
18
+ emails: string[];
19
+ urls: string[];
20
+ dates: string[];
21
+ amounts: string[];
22
+ }
23
+
24
+ export const extractEntities = (text: string): ExtractedEntities => {
25
+ const emails = dedupe(text.match(EMAIL_RE) ?? []);
26
+ const urls = dedupe(text.match(URL_RE) ?? []);
27
+ const dates = dedupe([
28
+ ...(text.match(ISO_DATE_RE) ?? []),
29
+ ...(text.match(LONG_DATE_RE) ?? []),
30
+ ...(text.match(QUARTER_RE) ?? []),
31
+ ]);
32
+ const amounts = dedupe([
33
+ ...(text.match(CURRENCY_RE) ?? []),
34
+ ...(text.match(PERCENT_RE) ?? []),
35
+ ]);
36
+ return { emails, urls, dates, amounts };
37
+ };
38
+
39
+ const formatEntities = (entities: ExtractedEntities): string => {
40
+ const parts: string[] = [];
41
+ if (entities.emails.length > 0) {
42
+ parts.push(`Emails: ${entities.emails.join(", ")}`);
43
+ }
44
+ if (entities.urls.length > 0) {
45
+ parts.push(`Links: ${entities.urls.join(", ")}`);
46
+ }
47
+ if (entities.dates.length > 0) {
48
+ parts.push(`Dates: ${entities.dates.join(", ")}`);
49
+ }
50
+ if (entities.amounts.length > 0) {
51
+ parts.push(`Amounts: ${entities.amounts.join(", ")}`);
52
+ }
53
+ return parts.join("\n");
54
+ };
55
+
56
+ export const buildEntityChunks = (
57
+ text: string,
58
+ chunkIdFor: (suffix: string) => string,
59
+ ): Chunk[] => {
60
+ const entities = extractEntities(text);
61
+ const summary = formatEntities(entities);
62
+ if (summary.length === 0) return [];
63
+ // Cap at MAX_CHUNKS_PER_TYPE so every produced entities-N has a matching key
64
+ // in candidateChunkKeys (keys.ts) and stays reapable on delete.
65
+ const parts = splitToCharBudget(summary, EMBED_CHAR_BUDGET).slice(
66
+ 0,
67
+ MAX_CHUNKS_PER_TYPE,
68
+ );
69
+ return parts.map((part, idx) => ({
70
+ chunkId: chunkIdFor(parts.length === 1 ? "entities" : `entities-${idx}`),
71
+ chunkType: "entities",
72
+ text: part,
73
+ }));
74
+ };
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildBodyChunks,
5
+ shannonEntropy,
6
+ stripBoilerplate,
7
+ } from "./entropy.js";
8
+
9
+ const idFor = (suffix: string): string => `msg-1::${suffix}`;
10
+
11
+ describe("shannonEntropy", () => {
12
+ it("returns zero for an empty string", () => {
13
+ assert.strictEqual(shannonEntropy(""), 0);
14
+ });
15
+
16
+ it("returns zero for a single repeating character", () => {
17
+ assert.strictEqual(shannonEntropy("aaaa"), 0);
18
+ });
19
+
20
+ it("returns higher entropy for varied text", () => {
21
+ const lo = shannonEntropy("aaaaaaaaa");
22
+ const hi = shannonEntropy("the quick brown fox jumps over the lazy dog");
23
+ assert.ok(hi > lo);
24
+ });
25
+ });
26
+
27
+ describe("stripBoilerplate", () => {
28
+ it("removes content after a > quoted reply", () => {
29
+ const input = "Hello bob,\nThis is the new content.\n> quoted reply line";
30
+ const out = stripBoilerplate(input);
31
+ assert.ok(!out.includes("quoted reply"));
32
+ assert.ok(out.includes("new content"));
33
+ });
34
+
35
+ it("removes a 'Best regards alice' signature", () => {
36
+ const input = "Real body text here.\n\nBest regards,\nAlice";
37
+ const out = stripBoilerplate(input);
38
+ assert.ok(!out.includes("Alice"));
39
+ assert.ok(out.includes("Real body text"));
40
+ });
41
+
42
+ it("removes 'Sent from my iPhone' boilerplate", () => {
43
+ const input = "Body content.\nSent from my iPhone";
44
+ const out = stripBoilerplate(input);
45
+ assert.ok(!out.includes("Sent from my iPhone"));
46
+ });
47
+ });
48
+
49
+ describe("buildBodyChunks", () => {
50
+ it("returns no chunks when the body is just a greeting and signature", () => {
51
+ const text = "Hi alice,\n\nBest regards,\nBob";
52
+ const chunks = buildBodyChunks(text, idFor);
53
+ assert.strictEqual(chunks.length, 0);
54
+ });
55
+
56
+ it("produces chunks from substantive prose", () => {
57
+ const body = `Hi alice,
58
+
59
+ I have reviewed the Q1 numbers and the team exceeded the target by fourteen percent.
60
+ The next milestone needs more attention from engineering and the renewal cycle is
61
+ approaching faster than the planning team anticipated. Please confirm by Friday so we
62
+ can schedule the follow-up working session for next week.
63
+
64
+ Best regards,
65
+ Bob`;
66
+ const chunks = buildBodyChunks(body, idFor);
67
+ assert.ok(chunks.length >= 1);
68
+ assert.ok(chunks[0].chunkId.startsWith("msg-1::body-"));
69
+ assert.strictEqual(chunks[0].chunkType, "body");
70
+ assert.ok(!chunks[0].text.includes("Hi alice"));
71
+ assert.ok(!chunks[0].text.includes("Best regards"));
72
+ assert.ok(chunks[0].text.includes("Q1 numbers"));
73
+ });
74
+
75
+ it("strips quoted content before chunking", () => {
76
+ const body = `Substantive answer text covering the renewal discussion in
77
+ some depth so it passes the entropy threshold and minimum length check.
78
+
79
+ > On Mon, alice wrote:
80
+ > Could you please send the report?`;
81
+ const chunks = buildBodyChunks(body, idFor);
82
+ assert.ok(chunks.length >= 1);
83
+ assert.ok(!chunks[0].text.includes("alice wrote"));
84
+ assert.ok(!chunks[0].text.includes("send the report"));
85
+ });
86
+
87
+ it("caps individual chunks below the size limit", () => {
88
+ const repeated = `This paragraph contains enough varied language to clear
89
+ the entropy threshold every time it is repeated, with several distinct words
90
+ and clauses appearing in each iteration to keep entropy high.`;
91
+ const body = Array.from({ length: 20 }, () => repeated).join("\n\n");
92
+ const chunks = buildBodyChunks(body, idFor);
93
+ assert.ok(chunks.length >= 2);
94
+ for (const chunk of chunks) {
95
+ assert.ok(chunk.text.length <= 2000);
96
+ }
97
+ });
98
+ });