@dxos/pipeline-email 0.0.0
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/LICENSE +105 -0
- package/README.md +15 -0
- package/dist/lib/neutral/index.mjs +650 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/testing/index.mjs +48 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/corpus/digest.d.ts +50 -0
- package/dist/types/src/corpus/digest.d.ts.map +1 -0
- package/dist/types/src/corpus/digest.test.d.ts +2 -0
- package/dist/types/src/corpus/digest.test.d.ts.map +1 -0
- package/dist/types/src/corpus/index.d.ts +6 -0
- package/dist/types/src/corpus/index.d.ts.map +1 -0
- package/dist/types/src/corpus/ledger.d.ts +33 -0
- package/dist/types/src/corpus/ledger.d.ts.map +1 -0
- package/dist/types/src/corpus/ledger.test.d.ts +2 -0
- package/dist/types/src/corpus/ledger.test.d.ts.map +1 -0
- package/dist/types/src/corpus/prompts.d.ts +21 -0
- package/dist/types/src/corpus/prompts.d.ts.map +1 -0
- package/dist/types/src/corpus/prompts.test.d.ts +2 -0
- package/dist/types/src/corpus/prompts.test.d.ts.map +1 -0
- package/dist/types/src/corpus/rollups.d.ts +21 -0
- package/dist/types/src/corpus/rollups.d.ts.map +1 -0
- package/dist/types/src/corpus/rollups.test.d.ts +2 -0
- package/dist/types/src/corpus/rollups.test.d.ts.map +1 -0
- package/dist/types/src/corpus/topics.d.ts +46 -0
- package/dist/types/src/corpus/topics.d.ts.map +1 -0
- package/dist/types/src/corpus/topics.test.d.ts +2 -0
- package/dist/types/src/corpus/topics.test.d.ts.map +1 -0
- package/dist/types/src/fact-pipeline.d.ts +26 -0
- package/dist/types/src/fact-pipeline.d.ts.map +1 -0
- package/dist/types/src/fact-pipeline.test.d.ts +2 -0
- package/dist/types/src/fact-pipeline.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/internal/fact-index.d.ts +8 -0
- package/dist/types/src/internal/fact-index.d.ts.map +1 -0
- package/dist/types/src/internal/fact-index.test.d.ts +2 -0
- package/dist/types/src/internal/fact-index.test.d.ts.map +1 -0
- package/dist/types/src/internal/threading.d.ts +4 -0
- package/dist/types/src/internal/threading.d.ts.map +1 -0
- package/dist/types/src/internal/threading.test.d.ts +2 -0
- package/dist/types/src/internal/threading.test.d.ts.map +1 -0
- package/dist/types/src/internal/threads.d.ts +9 -0
- package/dist/types/src/internal/threads.d.ts.map +1 -0
- package/dist/types/src/internal/threads.test.d.ts +2 -0
- package/dist/types/src/internal/threads.test.d.ts.map +1 -0
- package/dist/types/src/pipeline.d.ts +36 -0
- package/dist/types/src/pipeline.d.ts.map +1 -0
- package/dist/types/src/pipeline.test.d.ts +2 -0
- package/dist/types/src/pipeline.test.d.ts.map +1 -0
- package/dist/types/src/stages/context.d.ts +40 -0
- package/dist/types/src/stages/context.d.ts.map +1 -0
- package/dist/types/src/stages/extract-contacts.d.ts +6 -0
- package/dist/types/src/stages/extract-contacts.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts-commit.d.ts +13 -0
- package/dist/types/src/stages/extract-facts-commit.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts-commit.test.d.ts +2 -0
- package/dist/types/src/stages/extract-facts-commit.test.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts.d.ts +6 -0
- package/dist/types/src/stages/extract-facts.d.ts.map +1 -0
- package/dist/types/src/stages/extract-facts.test.d.ts +2 -0
- package/dist/types/src/stages/extract-facts.test.d.ts.map +1 -0
- package/dist/types/src/stages/facts.d.ts +7 -0
- package/dist/types/src/stages/facts.d.ts.map +1 -0
- package/dist/types/src/stages/facts.test.d.ts +2 -0
- package/dist/types/src/stages/facts.test.d.ts.map +1 -0
- package/dist/types/src/stages/index.d.ts +8 -0
- package/dist/types/src/stages/index.d.ts.map +1 -0
- package/dist/types/src/stages/stats.d.ts +6 -0
- package/dist/types/src/stages/stats.d.ts.map +1 -0
- package/dist/types/src/stages/stats.test.d.ts +2 -0
- package/dist/types/src/stages/stats.test.d.ts.map +1 -0
- package/dist/types/src/stages/summarize.d.ts +7 -0
- package/dist/types/src/stages/summarize.d.ts.map +1 -0
- package/dist/types/src/testing/email-extraction.bench.test.d.ts +2 -0
- package/dist/types/src/testing/email-extraction.bench.test.d.ts.map +1 -0
- package/dist/types/src/testing/email-fixtures.d.ts +25 -0
- package/dist/types/src/testing/email-fixtures.d.ts.map +1 -0
- package/dist/types/src/testing/email-pipeline.test.d.ts +2 -0
- package/dist/types/src/testing/email-pipeline.test.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/parquet-email.test.d.ts +2 -0
- package/dist/types/src/testing/parquet-email.test.d.ts.map +1 -0
- package/dist/types/src/testing/parquet.d.ts +20 -0
- package/dist/types/src/testing/parquet.d.ts.map +1 -0
- package/dist/types/src/testing/parquet.test.d.ts +2 -0
- package/dist/types/src/testing/parquet.test.d.ts.map +1 -0
- package/dist/types/src/types/Thread.d.ts +18 -0
- package/dist/types/src/types/Thread.d.ts.map +1 -0
- package/dist/types/src/types/Thread.test.d.ts +2 -0
- package/dist/types/src/types/Thread.test.d.ts.map +1 -0
- package/dist/types/src/types/Topic.d.ts +12 -0
- package/dist/types/src/types/Topic.d.ts.map +1 -0
- package/dist/types/src/types/Topic.test.d.ts +2 -0
- package/dist/types/src/types/Topic.test.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/corpus/digest.test.ts +78 -0
- package/src/corpus/digest.ts +92 -0
- package/src/corpus/index.ts +9 -0
- package/src/corpus/ledger.test.ts +85 -0
- package/src/corpus/ledger.ts +73 -0
- package/src/corpus/prompts.test.ts +24 -0
- package/src/corpus/prompts.ts +43 -0
- package/src/corpus/rollups.test.ts +50 -0
- package/src/corpus/rollups.ts +65 -0
- package/src/corpus/topics.test.ts +90 -0
- package/src/corpus/topics.ts +211 -0
- package/src/fact-pipeline.test.ts +63 -0
- package/src/fact-pipeline.ts +51 -0
- package/src/index.ts +9 -0
- package/src/internal/fact-index.test.ts +54 -0
- package/src/internal/fact-index.ts +43 -0
- package/src/internal/threading.test.ts +28 -0
- package/src/internal/threading.ts +27 -0
- package/src/internal/threads.test.ts +76 -0
- package/src/internal/threads.ts +73 -0
- package/src/pipeline.test.ts +74 -0
- package/src/pipeline.ts +76 -0
- package/src/stages/context.ts +32 -0
- package/src/stages/extract-contacts.ts +25 -0
- package/src/stages/extract-facts-commit.test.ts +75 -0
- package/src/stages/extract-facts-commit.ts +44 -0
- package/src/stages/extract-facts.test.ts +74 -0
- package/src/stages/extract-facts.ts +26 -0
- package/src/stages/facts.test.ts +40 -0
- package/src/stages/facts.ts +37 -0
- package/src/stages/index.ts +11 -0
- package/src/stages/stats.test.ts +38 -0
- package/src/stages/stats.ts +35 -0
- package/src/stages/summarize.ts +76 -0
- package/src/testing/email-extraction.bench.test.ts +117 -0
- package/src/testing/email-fixtures.ts +62 -0
- package/src/testing/email-pipeline.test.ts +483 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/parquet-email.test.ts +114 -0
- package/src/testing/parquet.test.ts +73 -0
- package/src/testing/parquet.ts +82 -0
- package/src/types/Thread.test.ts +29 -0
- package/src/types/Thread.ts +26 -0
- package/src/types/Topic.test.ts +25 -0
- package/src/types/Topic.ts +20 -0
- package/src/types/index.ts +6 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Stream from 'effect/Stream';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { describe, test } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { Provider } from '@dxos/ai';
|
|
14
|
+
import { OllamaAiServiceLayer } from '@dxos/ai/testing';
|
|
15
|
+
import { EffectEx } from '@dxos/effect';
|
|
16
|
+
import { log } from '@dxos/log';
|
|
17
|
+
import { Pipeline } from '@dxos/pipeline';
|
|
18
|
+
import { type DocumentFacts, extractFactsStage } from '@dxos/pipeline-rdf';
|
|
19
|
+
import { captureSink, instrument, renderBenchmark, runBenchmark } from '@dxos/pipeline/testing';
|
|
20
|
+
|
|
21
|
+
import { EMAIL_EXTRACT_OPTIONS, messageToDocument } from '../stages/facts';
|
|
22
|
+
import { emailToMessage } from './email-fixtures';
|
|
23
|
+
import { parquetSource } from './parquet';
|
|
24
|
+
|
|
25
|
+
// Compares fact-extraction quality across local models over the head of the Enron dataset. Same
|
|
26
|
+
// gating as email-pipeline.test.ts: needs the dataset under ROOT_DIR and a running Ollama, so CI and
|
|
27
|
+
// un-provisioned checkouts skip it. This is the first concrete use of the `@dxos/pipeline/testing`
|
|
28
|
+
// benchmark framework: an instrumented extraction stage feeds per-stage counters, and an effectful
|
|
29
|
+
// evaluator derives the domain metrics (facts per message, extraction success rate).
|
|
30
|
+
const DEFAULT_ROOT_DIR = fileURLToPath(new URL('../../data/enron-emails', import.meta.url));
|
|
31
|
+
const ROOT_DIR = process.env.ROOT_DIR ?? DEFAULT_ROOT_DIR;
|
|
32
|
+
const HAS_DATASET = existsSync(join(ROOT_DIR, 'data'));
|
|
33
|
+
|
|
34
|
+
// Where the benchmark's serialized comparison is written (git-ignored, under the package's ./data).
|
|
35
|
+
const BENCH_FILE = fileURLToPath(new URL('../../data/benchmark.json', import.meta.url));
|
|
36
|
+
|
|
37
|
+
// Models to compare (Ollama DXNs).
|
|
38
|
+
const BENCH_MODELS = [
|
|
39
|
+
'com.meta.model.llama-3-2-3b.instruct',
|
|
40
|
+
'com.alibaba.model.qwen-2-5-7b.instruct',
|
|
41
|
+
'com.google.model.gemma-4-12b.default',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Emails drawn from the head of the dataset per model. Kept modest by default (each is one LLM call
|
|
45
|
+
// per model); override via BENCH_COUNT for a heavier comparison.
|
|
46
|
+
const BENCH_COUNT = Number(process.env.BENCH_COUNT ?? 10);
|
|
47
|
+
|
|
48
|
+
describe.skipIf(!HAS_DATASET)('email extraction benchmark (ROOT_DIR + Ollama gated)', () => {
|
|
49
|
+
test(
|
|
50
|
+
'compares fact extraction across models',
|
|
51
|
+
async ({ expect }) => {
|
|
52
|
+
const dataDir = join(ROOT_DIR, 'data');
|
|
53
|
+
const files = (await readdir(dataDir))
|
|
54
|
+
.filter((name) => /^train-.*\.parquet$/.test(name))
|
|
55
|
+
.sort()
|
|
56
|
+
.map((name) => join(dataDir, name));
|
|
57
|
+
expect(files.length).toBeGreaterThan(0);
|
|
58
|
+
|
|
59
|
+
const variants = BENCH_MODELS.map((model) => ({ name: modelLabel(model), config: { model } }));
|
|
60
|
+
|
|
61
|
+
const result = await EffectEx.runPromise(
|
|
62
|
+
runBenchmark({
|
|
63
|
+
variants,
|
|
64
|
+
// Stream the first BENCH_COUNT emails through the instrumented extraction stage under the
|
|
65
|
+
// variant's model; the fallback in pipeline-rdf degrades a failed extraction to no facts, so
|
|
66
|
+
// a weak model yields low facts-per-message rather than aborting the run.
|
|
67
|
+
program: ({ model }) => {
|
|
68
|
+
const { sink, items } = captureSink<DocumentFacts>();
|
|
69
|
+
return parquetSource(files)
|
|
70
|
+
.pipe(
|
|
71
|
+
Stream.take(BENCH_COUNT),
|
|
72
|
+
Stream.map(emailToMessage),
|
|
73
|
+
Stream.map(messageToDocument),
|
|
74
|
+
instrument(
|
|
75
|
+
'extract',
|
|
76
|
+
extractFactsStage({ ...EMAIL_EXTRACT_OPTIONS, model, provider: Provider.ollama.id, strict: false }),
|
|
77
|
+
),
|
|
78
|
+
Pipeline.run({ sink }),
|
|
79
|
+
)
|
|
80
|
+
.pipe(Effect.provide(OllamaAiServiceLayer), Effect.as(items));
|
|
81
|
+
},
|
|
82
|
+
// Domain metrics computed from the captured per-document facts.
|
|
83
|
+
evaluate: (_config, docs) => {
|
|
84
|
+
const facts = docs.reduce((total, entry) => total + entry.facts.length, 0);
|
|
85
|
+
const withFacts = docs.filter((entry) => entry.facts.length > 0).length;
|
|
86
|
+
return Effect.succeed({
|
|
87
|
+
'messages': docs.length,
|
|
88
|
+
facts,
|
|
89
|
+
'facts.per.msg': docs.length ? round(facts / docs.length) : 0,
|
|
90
|
+
'success.rate': docs.length ? round(withFacts / docs.length) : 0,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const table = renderBenchmark(result);
|
|
97
|
+
log.info(`email extraction benchmark (${BENCH_COUNT} emails)\n${table}`);
|
|
98
|
+
await writeFile(BENCH_FILE, JSON.stringify(result, null, 2));
|
|
99
|
+
|
|
100
|
+
expect(result.variants.length).toBe(variants.length);
|
|
101
|
+
// Every variant ran the extraction stage over the whole sample (fewer only if the dataset is
|
|
102
|
+
// smaller). A model that produced zero facts still counts as a completed, comparable run.
|
|
103
|
+
for (const variant of result.variants) {
|
|
104
|
+
expect(variant.metrics['extract.in']).toBeGreaterThan(0);
|
|
105
|
+
expect(variant.metrics.messages).toBe(variant.metrics['extract.out']);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
// Two LLM calls at most per message per model; budget generously so larger BENCH_COUNT runs and
|
|
109
|
+
// slower models don't trip the per-test timeout.
|
|
110
|
+
Math.max(5 * 60_000, BENCH_MODELS.length * BENCH_COUNT * 45_000),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Short display label from a model DXN, e.g. `com.openai.model.gpt-oss-20b.default` -> `gpt-oss-20b`.
|
|
115
|
+
const modelLabel = (model: string): string => model.split('.').at(-2) ?? model;
|
|
116
|
+
|
|
117
|
+
const round = (value: number): number => Math.round(value * 100) / 100;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { asyncBufferFromUrl, parquetMetadataAsync, parquetReadObjects } from 'hyparquet';
|
|
6
|
+
|
|
7
|
+
import { type ContentBlock, Message } from '@dxos/types';
|
|
8
|
+
|
|
9
|
+
import { type ParquetRow } from './parquet';
|
|
10
|
+
|
|
11
|
+
// Maps rows from the Enron email dataset (see the dataset's `dataset_info` schema) onto ECHO Messages.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* First shard of the Enron email dataset (https://huggingface.co/datasets/corbt/enron-emails). The
|
|
15
|
+
* HuggingFace CDN serves it with range requests + permissive CORS, so a browser reads only the first
|
|
16
|
+
* N rows (a few row-group byte ranges) rather than the whole ~185 MB file.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_ENRON_URL =
|
|
19
|
+
'https://huggingface.co/datasets/corbt/enron-emails/resolve/main/data/train-00000-of-00003.parquet';
|
|
20
|
+
|
|
21
|
+
export type LoadEnronOptions = {
|
|
22
|
+
/** Number of messages to read from the start of the dataset. */
|
|
23
|
+
readonly count?: number;
|
|
24
|
+
/** Parquet URL to read from; defaults to {@link DEFAULT_ENRON_URL}. */
|
|
25
|
+
readonly url?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the first `count` (default 100) Enron messages directly from the dataset parquet over HTTP.
|
|
30
|
+
* Browser-safe: uses hyparquet's URL async buffer (range requests), so only the byte ranges the first
|
|
31
|
+
* rows need are fetched. Rows are mapped to ECHO Messages via {@link emailToMessage}.
|
|
32
|
+
*/
|
|
33
|
+
export const loadEnronMessages = async ({ count = 100, url = DEFAULT_ENRON_URL }: LoadEnronOptions = {}): Promise<
|
|
34
|
+
Message.Message[]
|
|
35
|
+
> => {
|
|
36
|
+
const file = await asyncBufferFromUrl({ url });
|
|
37
|
+
const metadata = await parquetMetadataAsync(file);
|
|
38
|
+
const rows = await parquetReadObjects({ file, metadata, rowStart: 0, rowEnd: count });
|
|
39
|
+
return rows.map((row) => emailToMessage(row as ParquetRow));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Coerce a parquet timestamp cell (a `Date`, or a string/number) to an ISO string. */
|
|
43
|
+
export const asIso = (value: unknown): string =>
|
|
44
|
+
(value instanceof Date ? value : new Date(String(value))).toISOString();
|
|
45
|
+
|
|
46
|
+
/** Map one email row (see the dataset's `dataset_info` schema) to a Message carrying the body as a text block. */
|
|
47
|
+
export const emailToMessage = (row: ParquetRow): Message.Message => {
|
|
48
|
+
const block: ContentBlock.Text = { _tag: 'text', text: String(row.body ?? '') };
|
|
49
|
+
return Message.make({
|
|
50
|
+
created: asIso(row.date),
|
|
51
|
+
sender: { email: String(row.from ?? '') },
|
|
52
|
+
blocks: [block],
|
|
53
|
+
properties: {
|
|
54
|
+
messageId: row.message_id,
|
|
55
|
+
subject: row.subject,
|
|
56
|
+
to: row.to,
|
|
57
|
+
cc: row.cc,
|
|
58
|
+
bcc: row.bcc,
|
|
59
|
+
fileName: row.file_name,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
};
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as LanguageModel from '@effect/ai/LanguageModel';
|
|
6
|
+
import * as Cause from 'effect/Cause';
|
|
7
|
+
import * as Context from 'effect/Context';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as Layer from 'effect/Layer';
|
|
10
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
11
|
+
import * as Schema from 'effect/Schema';
|
|
12
|
+
import * as Stream from 'effect/Stream';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { readdir, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { afterAll, beforeAll, describe, test } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { AiService, Provider } from '@dxos/ai';
|
|
20
|
+
import { OllamaAiServiceLayer } from '@dxos/ai/testing';
|
|
21
|
+
import { type Database, Filter, Obj } from '@dxos/echo';
|
|
22
|
+
import { EchoTestBuilder } from '@dxos/echo-client/testing';
|
|
23
|
+
import { EffectEx } from '@dxos/effect';
|
|
24
|
+
import { extractContact } from '@dxos/extractor-lib';
|
|
25
|
+
import { log } from '@dxos/log';
|
|
26
|
+
import { Pipeline, Stage } from '@dxos/pipeline';
|
|
27
|
+
import { FactPipeline, FactStore } from '@dxos/pipeline-rdf';
|
|
28
|
+
import { Metrics, captureSink, instrument, makeMetrics } from '@dxos/pipeline/testing';
|
|
29
|
+
import { type ContentBlock, Message, Organization, Person } from '@dxos/types';
|
|
30
|
+
import { trim } from '@dxos/util';
|
|
31
|
+
|
|
32
|
+
import { buildDigest, narrateDigest, renderDigest } from '../corpus/digest';
|
|
33
|
+
import { commitmentLedger } from '../corpus/ledger';
|
|
34
|
+
import { type Summarizer } from '../corpus/prompts';
|
|
35
|
+
import { buildRollups } from '../corpus/rollups';
|
|
36
|
+
import { clusterThreads, materializeTopics, summarizeTopics } from '../corpus/topics';
|
|
37
|
+
import { buildEntityIndex, reconcileFactEntities } from '../internal/fact-index';
|
|
38
|
+
import { buildThreads } from '../internal/threads';
|
|
39
|
+
import { type FactIndexer, extractFactsStage } from '../stages/extract-facts';
|
|
40
|
+
import { EMAIL_EXTRACT_OPTIONS, messageToDocument } from '../stages/facts';
|
|
41
|
+
import { Thread, Topic } from '../types';
|
|
42
|
+
import { emailToMessage } from './email-fixtures';
|
|
43
|
+
import { parquetSource } from './parquet';
|
|
44
|
+
|
|
45
|
+
// The email dataset (https://huggingface.co/datasets/corbt/enron-emails) lives under ROOT_DIR with
|
|
46
|
+
// layout `${ROOT_DIR}/data/train-*.parquet`. ROOT_DIR defaults to the local checkout produced by
|
|
47
|
+
// `moon run pipeline-email:setup` (packages/core/compute/pipeline-email/data/enron-emails); override
|
|
48
|
+
// it via the ROOT_DIR env var. The suite is skipped unless that dataset is actually present — it also
|
|
49
|
+
// needs a running Ollama (localhost:11434) and the native better-sqlite3 addon — so CI and
|
|
50
|
+
// un-provisioned checkouts skip it.
|
|
51
|
+
const DEFAULT_ROOT_DIR = fileURLToPath(new URL('../../data/enron-emails', import.meta.url));
|
|
52
|
+
const ROOT_DIR = process.env.ROOT_DIR ?? DEFAULT_ROOT_DIR;
|
|
53
|
+
|
|
54
|
+
// Where the run's serialized outputs are written (git-ignored, under the package's ./data).
|
|
55
|
+
const RESULTS_FILE = fileURLToPath(new URL('../../data/results.json', import.meta.url));
|
|
56
|
+
|
|
57
|
+
const TEST_ORGS = [
|
|
58
|
+
{
|
|
59
|
+
name: 'DXOS',
|
|
60
|
+
website: 'https://dxos.org',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'Enron',
|
|
64
|
+
website: 'https://enron.com',
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Gate on the dataset shard directory existing locally.
|
|
69
|
+
const HAS_DATASET = existsSync(join(ROOT_DIR, 'data'));
|
|
70
|
+
|
|
71
|
+
// Local model served by Ollama (model DXN; its final NSID segment must be camelCase).
|
|
72
|
+
const MODEL = process.env.OLLAMA_MODEL ?? 'com.openai.model.gpt-oss-20b.default';
|
|
73
|
+
|
|
74
|
+
// Number of emails drawn from the head of the dataset for one run. Defaults to 50 so the gated run
|
|
75
|
+
// spans multiple threads/senders and actually exercises the corpus-layer assertions (clustering,
|
|
76
|
+
// rollups, digest); override via EMAIL_COUNT (e.g. EMAIL_COUNT=1) for fast local iteration.
|
|
77
|
+
const EMAIL_COUNT = Number(process.env.EMAIL_COUNT ?? 50);
|
|
78
|
+
|
|
79
|
+
const SUMMARIZE_PROMPT = trim`
|
|
80
|
+
Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.
|
|
81
|
+
Respond with ONLY a JSON object of the form {"summary": string, "isSpam": boolean, "keywords": string[]}.
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
// Structured output the summarize stage asks the model for.
|
|
85
|
+
const SummarySchema = Schema.Struct({
|
|
86
|
+
summary: Schema.String,
|
|
87
|
+
isSpam: Schema.Boolean,
|
|
88
|
+
keywords: Schema.Array(Schema.String),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
type Summary = Schema.Schema.Type<typeof SummarySchema>;
|
|
92
|
+
|
|
93
|
+
// Model output is untyped JSON, often wrapped in prose/reasoning by local models; extract the first
|
|
94
|
+
// object and coerce leniently (field names and types vary between models) rather than strict schema
|
|
95
|
+
// validation, which gpt-oss/Ollama frequently fails (`generateObject` → MalformedOutput).
|
|
96
|
+
const parseSummary = (raw: string): Summary => {
|
|
97
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
98
|
+
if (!match) {
|
|
99
|
+
return { summary: '', isSpam: false, keywords: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(match[0]);
|
|
104
|
+
const keywords = parsed.keywords;
|
|
105
|
+
return {
|
|
106
|
+
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
|
|
107
|
+
isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,
|
|
108
|
+
keywords: Array.isArray(keywords)
|
|
109
|
+
? keywords.map((keyword: unknown) => String(keyword))
|
|
110
|
+
: typeof keywords === 'string'
|
|
111
|
+
? keywords
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((keyword) => keyword.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
: [],
|
|
116
|
+
};
|
|
117
|
+
} catch {
|
|
118
|
+
return {
|
|
119
|
+
summary: '',
|
|
120
|
+
isSpam: false,
|
|
121
|
+
keywords: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Running tallies produced by the stats stage; readable after the run for assertions.
|
|
127
|
+
type Stats = {
|
|
128
|
+
readonly from: Map<string, number>;
|
|
129
|
+
readonly to: Map<string, number>;
|
|
130
|
+
total: number;
|
|
131
|
+
spam: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Shared context threaded through every stage via Effect's Requirements channel. The service
|
|
135
|
+
// closure and db are built once up-front and provided at the edge (`Effect.provide` below), so the
|
|
136
|
+
// individual stage Effects carry `R = Ctx` but the fully-composed program is reduced back to
|
|
137
|
+
// `R = never` before `EffectEx.runPromise` — the same "provide at the edge" idiom used elsewhere in
|
|
138
|
+
// this repo (e.g. `Database.layer`). The heavy bits (Ollama runtime, better-sqlite3-backed db) live
|
|
139
|
+
// here in the test harness, which is why the whole suite is env-gated.
|
|
140
|
+
class Ctx extends Context.Tag('EmailPipelineCtx')<
|
|
141
|
+
Ctx,
|
|
142
|
+
{
|
|
143
|
+
readonly summarize: (text: string) => Promise<Summary>;
|
|
144
|
+
readonly db: Database.Database;
|
|
145
|
+
readonly stats: Stats;
|
|
146
|
+
}
|
|
147
|
+
>() {}
|
|
148
|
+
|
|
149
|
+
// Stage 1: LLM summarization. Appends a second text block carrying the summary and records spam /
|
|
150
|
+
// keyword metadata on `Message.properties` (ContentBlock.Text has no metadata field). Produces a new
|
|
151
|
+
// Message rather than mutating in place. The model call degrades gracefully to an empty summary when
|
|
152
|
+
// Ollama is unreachable or a row fails (`tryPromise` captures the rejection so `orElse` can recover),
|
|
153
|
+
// so the suite stays green whenever the dataset is checked out — real summaries when Ollama is up, an
|
|
154
|
+
// empty-summary no-op otherwise.
|
|
155
|
+
const summarizeStage: Stage.Stage<Message.Message, Message.Message, never, Ctx> = Stage.map('summarize', (message) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const { summarize } = yield* Ctx;
|
|
158
|
+
const text = Message.extractText(message);
|
|
159
|
+
const result = yield* Effect.tryPromise(() => summarize(text)).pipe(
|
|
160
|
+
Effect.orElse(() => Effect.succeed<Summary>({ summary: '', isSpam: false, keywords: [] })),
|
|
161
|
+
);
|
|
162
|
+
const summaryBlock: ContentBlock.Text = { _tag: 'text', text: result.summary };
|
|
163
|
+
return Message.make({
|
|
164
|
+
created: message.created,
|
|
165
|
+
sender: message.sender,
|
|
166
|
+
blocks: [...message.blocks, summaryBlock],
|
|
167
|
+
properties: {
|
|
168
|
+
...message.properties,
|
|
169
|
+
spam: result.isSpam,
|
|
170
|
+
keywords: result.keywords,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Stage 2: run the shared `contactExtractor` (the `@dxos/extractor` abstraction) to build a Person
|
|
177
|
+
// (+ Organization link by domain) from the sender, then persist the extractor's uncommitted output
|
|
178
|
+
// (normally the dispatcher's role). The extractor's `extract` has R = never, so it composes into
|
|
179
|
+
// the Requirements channel the same way any other stage dependency does. Passes the Message through
|
|
180
|
+
// unchanged.
|
|
181
|
+
const extractStage: Stage.Stage<Message.Message, Message.Message, never, Ctx> = Stage.map(
|
|
182
|
+
'extract-contact',
|
|
183
|
+
(message) =>
|
|
184
|
+
Effect.gen(function* () {
|
|
185
|
+
const { db } = yield* Ctx;
|
|
186
|
+
const result = yield* extractContact({ db, source: message });
|
|
187
|
+
for (const object of result.created) {
|
|
188
|
+
db.add(object);
|
|
189
|
+
}
|
|
190
|
+
return message;
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Stage 3: pure-JS running tallies (no analysis libs — a Cloudflare-safe option would be a small
|
|
195
|
+
// pure-JS stats package, but none is added here). Mutates the context-carried accumulator and passes
|
|
196
|
+
// the Message through so it reaches the sink for collection.
|
|
197
|
+
const statsStage: Stage.Stage<Message.Message, Message.Message, never, Ctx> = Stage.map('stats', (message) =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const { stats } = yield* Ctx;
|
|
200
|
+
stats.total += 1;
|
|
201
|
+
const sender = message.sender.email;
|
|
202
|
+
if (sender) {
|
|
203
|
+
stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
const recipients = message.properties?.to;
|
|
206
|
+
if (Array.isArray(recipients)) {
|
|
207
|
+
for (const recipient of recipients) {
|
|
208
|
+
const address = String(recipient);
|
|
209
|
+
stats.to.set(address, (stats.to.get(address) ?? 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (message.properties?.spam) {
|
|
213
|
+
stats.spam += 1;
|
|
214
|
+
}
|
|
215
|
+
return message;
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Bookend logging stage: emits one line describing the message as it enters/leaves the pipeline and
|
|
220
|
+
// passes it through unchanged. (Visibility is controlled by LOG_FILTER, e.g. `LOG_FILTER=info`.)
|
|
221
|
+
// Logs only the sender domain, not the full address, to avoid recording PII. Needs no shared context,
|
|
222
|
+
// so it stays `R = never` even inside a pipeline that otherwise requires `Ctx`.
|
|
223
|
+
const logStage = (label: string): Stage.Stage<Message.Message, Message.Message> =>
|
|
224
|
+
Stage.map(`log:${label}`, (message) =>
|
|
225
|
+
Effect.sync(() => {
|
|
226
|
+
const senderDomain = message.sender.email?.split('@')[1];
|
|
227
|
+
log.info(label, {
|
|
228
|
+
senderDomain,
|
|
229
|
+
subject: message.properties?.subject,
|
|
230
|
+
blocks: message.blocks.length,
|
|
231
|
+
spam: message.properties?.spam,
|
|
232
|
+
});
|
|
233
|
+
return message;
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
describe.skipIf(!HAS_DATASET)('Enron email pipeline (ROOT_DIR + Ollama gated)', () => {
|
|
238
|
+
// Model layer built ONCE so it is not rebuilt per message.
|
|
239
|
+
// `AiService.model` provides the `LanguageModel`, resolved through the local Ollama provider;
|
|
240
|
+
// `OllamaAiServiceLayer` provides the `AiService` it requires.
|
|
241
|
+
const modelLayer = AiService.model(MODEL, { provider: Provider.ollama.id }).pipe(Layer.provide(OllamaAiServiceLayer));
|
|
242
|
+
const runtime = ManagedRuntime.make(modelLayer.pipe(Layer.orDie));
|
|
243
|
+
|
|
244
|
+
// In-memory fact substrate for this run; shares the Ollama-backed AiService the extraction resolves
|
|
245
|
+
// its model through (pipeline-rdf's ExtractOptions carries the model + provider).
|
|
246
|
+
const factRuntime = ManagedRuntime.make(FactStore.layerMemory.pipe(Layer.provideMerge(OllamaAiServiceLayer)));
|
|
247
|
+
|
|
248
|
+
let builder: EchoTestBuilder;
|
|
249
|
+
let db: Database.Database;
|
|
250
|
+
|
|
251
|
+
beforeAll(async () => {
|
|
252
|
+
builder = await new EchoTestBuilder().open();
|
|
253
|
+
({ db } = await builder.createDatabase({ types: [Organization.Organization, Person.Person, Thread, Topic] }));
|
|
254
|
+
// Seed a known Organization so domain-matching can link a sender's Person to it.
|
|
255
|
+
for (const org of TEST_ORGS) {
|
|
256
|
+
db.add(Obj.make(Organization.Organization, org));
|
|
257
|
+
}
|
|
258
|
+
await db.flush({ indexes: true });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterAll(async () => {
|
|
262
|
+
await runtime.dispose();
|
|
263
|
+
await factRuntime.dispose();
|
|
264
|
+
await builder.close();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test(
|
|
268
|
+
'summarizes, extracts contacts, and tallies stats over the first emails',
|
|
269
|
+
async ({ expect }) => {
|
|
270
|
+
const dataDir = join(ROOT_DIR, 'data');
|
|
271
|
+
const files = (await readdir(dataDir))
|
|
272
|
+
.filter((name) => /^train-.*\.parquet$/.test(name))
|
|
273
|
+
.sort()
|
|
274
|
+
.map((name) => join(dataDir, name));
|
|
275
|
+
expect(files.length).toBeGreaterThan(0);
|
|
276
|
+
|
|
277
|
+
const stats: Stats = { from: new Map(), to: new Map(), total: 0, spam: 0 };
|
|
278
|
+
// `generateText` + lenient parse rather than `generateObject`: local reasoning models (gpt-oss
|
|
279
|
+
// via Ollama) return valid JSON that fails strict schema conformance (→ MalformedOutput). The
|
|
280
|
+
// cause is logged and degraded to an empty summary on failure, which also keeps the inner fiber
|
|
281
|
+
// from raising a FiberFailure.
|
|
282
|
+
const summarize = (text: string): Promise<Summary> =>
|
|
283
|
+
runtime.runPromise(
|
|
284
|
+
Effect.scoped(LanguageModel.generateText({ prompt: [SUMMARIZE_PROMPT, text].join('\n\n') })).pipe(
|
|
285
|
+
Effect.map((response) => parseSummary(response.text)),
|
|
286
|
+
Effect.catchAllCause((cause) =>
|
|
287
|
+
Effect.sync(() => {
|
|
288
|
+
log.warn('summarize failed; using empty summary', { model: MODEL, cause: Cause.pretty(cause) });
|
|
289
|
+
return { summary: '', isSpam: false, keywords: [] } satisfies Summary;
|
|
290
|
+
}),
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const context: Context.Tag.Service<typeof Ctx> = { summarize, db, stats };
|
|
296
|
+
|
|
297
|
+
// Index each message into the fact substrate. The model + provider ride on ExtractOptions so
|
|
298
|
+
// pipeline-rdf resolves the Ollama model (its default is Anthropic); a failed extraction
|
|
299
|
+
// degrades to no facts in `extractFactsStage`, so the run stays green. The counter proves the
|
|
300
|
+
// stage actually invoked indexing (facts themselves are advisory — see the assertions below).
|
|
301
|
+
let indexedMessageCount = 0;
|
|
302
|
+
const indexFacts: FactIndexer = (message) => {
|
|
303
|
+
indexedMessageCount += 1;
|
|
304
|
+
return factRuntime.runPromise(
|
|
305
|
+
FactPipeline.run([messageToDocument(message)], {
|
|
306
|
+
...EMAIL_EXTRACT_OPTIONS,
|
|
307
|
+
model: MODEL,
|
|
308
|
+
provider: Provider.ollama.id,
|
|
309
|
+
// Ollama reliably fails strict generateObject; skip it and salvage from generateText.
|
|
310
|
+
strict: false,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Per-stage timing: each LLM-bearing stage is instrumented so the run reports where the
|
|
316
|
+
// per-message latency goes (`extract-facts.ms` dominates — it makes up to two model calls per
|
|
317
|
+
// message, see extractChunk). One Metrics instance for the run, read back after it completes.
|
|
318
|
+
const metrics = makeMetrics();
|
|
319
|
+
const { sink, items } = captureSink<Message.Message>();
|
|
320
|
+
await EffectEx.runPromise(
|
|
321
|
+
Effect.provide(
|
|
322
|
+
parquetSource(files).pipe(
|
|
323
|
+
Stream.take(EMAIL_COUNT),
|
|
324
|
+
Stream.map(emailToMessage),
|
|
325
|
+
logStage('email.in'),
|
|
326
|
+
instrument('summarize', summarizeStage),
|
|
327
|
+
instrument('extract-facts', extractFactsStage(indexFacts)),
|
|
328
|
+
instrument('extract-contact', extractStage),
|
|
329
|
+
instrument('stats', statsStage),
|
|
330
|
+
logStage('email.out'),
|
|
331
|
+
Pipeline.run({ sink }),
|
|
332
|
+
),
|
|
333
|
+
Layer.merge(Layer.succeed(Ctx, context), Layer.succeed(Metrics, metrics)),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// Timing breakdown (ms is the summed per-stage latency; divide by the stage's `.out` count for
|
|
338
|
+
// the per-message average). Surfaced in the log and serialized with the run's results.
|
|
339
|
+
const timing = await EffectEx.runPromise(metrics.snapshot);
|
|
340
|
+
log.info('pipeline timing', timing);
|
|
341
|
+
|
|
342
|
+
// The pipeline processes up to EMAIL_COUNT messages (fewer only if the dataset is smaller).
|
|
343
|
+
expect(items.length).toBeGreaterThan(0);
|
|
344
|
+
expect(items.length).toBeLessThanOrEqual(EMAIL_COUNT);
|
|
345
|
+
|
|
346
|
+
for (const message of items) {
|
|
347
|
+
const textBlocks = message.blocks.filter((block) => block._tag === 'text');
|
|
348
|
+
// Original body block plus the appended summary block.
|
|
349
|
+
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
|
|
350
|
+
expect(typeof message.properties?.spam).toBe('boolean');
|
|
351
|
+
expect(Array.isArray(message.properties?.keywords)).toBe(true);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await db.flush({ indexes: true });
|
|
355
|
+
const persons = await db.query(Filter.type(Person.Person)).run();
|
|
356
|
+
expect(persons.length).toBeGreaterThan(0);
|
|
357
|
+
|
|
358
|
+
expect(stats.total).toBe(items.length);
|
|
359
|
+
expect(stats.from.size).toBeGreaterThan(0);
|
|
360
|
+
expect(stats.to.size).toBeGreaterThan(0);
|
|
361
|
+
|
|
362
|
+
// Facts are advisory: gpt-oss's structured `generateObject` output is only intermittently
|
|
363
|
+
// schema-conforming (the summarize stage sidesteps this with lenient `generateText`), so the
|
|
364
|
+
// fact count is best-effort here — the deterministic proof of extraction lives in
|
|
365
|
+
// extract-stage.test.ts (mockAiService). Assert only that the substrate is queryable.
|
|
366
|
+
// The extract-facts stage ran for every captured message (proves the wiring executed, even
|
|
367
|
+
// though the facts it produced are advisory and may be empty under a flaky model).
|
|
368
|
+
expect(indexedMessageCount).toBe(items.length);
|
|
369
|
+
const facts = await factRuntime.runPromise(
|
|
370
|
+
Effect.gen(function* () {
|
|
371
|
+
const store = yield* FactStore;
|
|
372
|
+
return yield* store.query({});
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
expect(Array.isArray(facts)).toBe(true);
|
|
376
|
+
|
|
377
|
+
// Reconcile advisory fact entities against the canonical ECHO Person/Organization objects the
|
|
378
|
+
// pipeline created (slice-1 §9.1). Facts are sparse/flaky under gpt-oss, so the resolved count
|
|
379
|
+
// is best-effort here — the reconciliation logic itself is proven deterministically in
|
|
380
|
+
// fact-index.test.ts.
|
|
381
|
+
const orgsForIndex = await db.query(Filter.type(Organization.Organization)).run();
|
|
382
|
+
const entityIndex = buildEntityIndex([...persons, ...orgsForIndex]);
|
|
383
|
+
const resolved = facts.flatMap((fact) => {
|
|
384
|
+
const refs = reconcileFactEntities(fact, entityIndex);
|
|
385
|
+
return refs.subject || refs.object ? [refs] : [];
|
|
386
|
+
});
|
|
387
|
+
expect(Array.isArray(resolved)).toBe(true);
|
|
388
|
+
|
|
389
|
+
// Threads are deterministic (grouped from the captured messages), so assert them strictly: at
|
|
390
|
+
// least one thread is materialized and every stored thread references its messages.
|
|
391
|
+
const threads = buildThreads(items, { ownerEmail: 'owner@dxos.org', now: new Date().toISOString() });
|
|
392
|
+
expect(threads.length).toBeGreaterThan(0);
|
|
393
|
+
for (const thread of threads) {
|
|
394
|
+
db.add(thread);
|
|
395
|
+
}
|
|
396
|
+
await db.flush({ indexes: true });
|
|
397
|
+
const storedThreads = await db.query(Filter.type(Thread)).run();
|
|
398
|
+
expect(storedThreads.length).toBe(threads.length);
|
|
399
|
+
expect(storedThreads.every((thread) => thread.messageIds.length > 0)).toBe(true);
|
|
400
|
+
|
|
401
|
+
// Corpus layer (phase 3). Clustering is deterministic, so assert it strictly: every thread
|
|
402
|
+
// lands in exactly one topic. LLM enrichment (topic summaries, digest narrative) is advisory
|
|
403
|
+
// and degradable — proven deterministically in topics/digest unit tests.
|
|
404
|
+
const narrate: Summarizer = (prompt) =>
|
|
405
|
+
runtime.runPromise(
|
|
406
|
+
Effect.scoped(LanguageModel.generateText({ prompt })).pipe(
|
|
407
|
+
Effect.map((response) => response.text),
|
|
408
|
+
Effect.catchAllCause(() => Effect.succeed('')),
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
const drafts = await summarizeTopics(clusterThreads(threads), narrate);
|
|
412
|
+
expect(drafts.length).toBeGreaterThan(0);
|
|
413
|
+
expect(drafts.flatMap((draft) => [...draft.threadIds]).sort()).toEqual(
|
|
414
|
+
threads.map((thread) => thread.threadId).sort(),
|
|
415
|
+
);
|
|
416
|
+
const topics = materializeTopics(drafts);
|
|
417
|
+
for (const topic of topics) {
|
|
418
|
+
db.add(topic);
|
|
419
|
+
}
|
|
420
|
+
await db.flush({ indexes: true });
|
|
421
|
+
const storedTopics = await db.query(Filter.type(Topic)).run();
|
|
422
|
+
expect(storedTopics.length).toBe(topics.length);
|
|
423
|
+
|
|
424
|
+
// Commitment ledger over the advisory fact store: rows (if any) must be grounded in a fact.
|
|
425
|
+
const commitments = await factRuntime.runPromise(
|
|
426
|
+
Effect.gen(function* () {
|
|
427
|
+
const store = yield* FactStore;
|
|
428
|
+
return yield* commitmentLedger(store);
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
expect(commitments.every((commitment) => commitment.factId.length > 0 && commitment.source.length > 0)).toBe(
|
|
432
|
+
true,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Rollups are deterministic: one per distinct sender among the processed messages.
|
|
436
|
+
const rollups = buildRollups(items);
|
|
437
|
+
const senders = new Set(
|
|
438
|
+
items.flatMap((message) => (message.sender.email ? [message.sender.email.toLowerCase()] : [])),
|
|
439
|
+
);
|
|
440
|
+
expect(rollups.length).toBe(senders.size);
|
|
441
|
+
|
|
442
|
+
// Digest skeleton is deterministic; the narrative is advisory (may be empty when degraded).
|
|
443
|
+
const digest = await narrateDigest(
|
|
444
|
+
buildDigest({ threads, topics: drafts, commitments, rollups }, { now: new Date().toISOString() }),
|
|
445
|
+
narrate,
|
|
446
|
+
);
|
|
447
|
+
expect(digest.threadCount).toBe(threads.length);
|
|
448
|
+
expect(digest.topicCount).toBe(drafts.length);
|
|
449
|
+
expect(typeof digest.narrative).toBe('string');
|
|
450
|
+
|
|
451
|
+
// Serialize the run's outputs for inspection (git-ignored under ./data). `Obj.toJSON` is the
|
|
452
|
+
// canonical ECHO serialization (encodes refs as DXNs).
|
|
453
|
+
const organizations = await db.query(Filter.type(Organization.Organization)).run();
|
|
454
|
+
await writeFile(
|
|
455
|
+
RESULTS_FILE,
|
|
456
|
+
JSON.stringify(
|
|
457
|
+
{
|
|
458
|
+
messages: items.map((message) => Obj.toJSON(message)),
|
|
459
|
+
organizations: organizations.map((organization) => Obj.toJSON(organization)),
|
|
460
|
+
persons: persons.map((person) => Obj.toJSON(person)),
|
|
461
|
+
topics: storedTopics.map((topic) => Obj.toJSON(topic)),
|
|
462
|
+
commitments,
|
|
463
|
+
rollups,
|
|
464
|
+
digest: renderDigest(digest),
|
|
465
|
+
timing,
|
|
466
|
+
stats: {
|
|
467
|
+
total: stats.total,
|
|
468
|
+
spam: stats.spam,
|
|
469
|
+
from: Object.fromEntries(stats.from),
|
|
470
|
+
to: Object.fromEntries(stats.to),
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
null,
|
|
474
|
+
2,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
},
|
|
478
|
+
// LLM calls dominate and scale with EMAIL_COUNT: per message a summarize + an extraction (which
|
|
479
|
+
// may make a second lenient call), plus per-topic summaries and the digest narration. Budget
|
|
480
|
+
// ~45s/message over a 5-minute floor so larger demo runs (e.g. EMAIL_COUNT=50) don't time out.
|
|
481
|
+
Math.max(5 * 60_000, EMAIL_COUNT * 45_000),
|
|
482
|
+
);
|
|
483
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export * from './email-fixtures';
|
|
6
|
+
// NOTE: parquet.ts (node:fs-backed `parquetSource`) is intentionally NOT re-exported here — the
|
|
7
|
+
// testing entry must stay browser-safe (the Pipeline story imports `loadEnronMessages`). Node tests
|
|
8
|
+
// import `./parquet` directly.
|