@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,74 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import { afterEach, beforeEach, describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { EchoTestBuilder } from '@dxos/echo-client/testing';
|
|
9
|
+
import { EffectEx } from '@dxos/effect';
|
|
10
|
+
import { type RDF } from '@dxos/pipeline-rdf';
|
|
11
|
+
import { mockAiService } from '@dxos/pipeline-rdf/testing';
|
|
12
|
+
import { Message, Organization, Person } from '@dxos/types';
|
|
13
|
+
|
|
14
|
+
import { EmailPipeline } from './pipeline';
|
|
15
|
+
import { type FactIndexer } from './stages';
|
|
16
|
+
import { Thread } from './types';
|
|
17
|
+
|
|
18
|
+
// `mockAiService`'s `generateText` always answers `''` (only `generateObject` echoes the payload), so
|
|
19
|
+
// summarize degrades to its empty-summary default and extract-contacts derives no LLM contacts here;
|
|
20
|
+
// this asserts the assembly WIRING (messages flow, stats tally, threads group) over a real ECHO db.
|
|
21
|
+
// Summary/contact/fact CONTENT is covered by the stage-specific suites.
|
|
22
|
+
|
|
23
|
+
describe('email pipeline (canonical assembly)', () => {
|
|
24
|
+
let builder: EchoTestBuilder;
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
builder = await new EchoTestBuilder().open();
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await builder.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('EmailPipeline.run: summarize → extract-contacts → stats → extract-facts → threads', async ({ expect }) => {
|
|
33
|
+
const { db } = await builder.createDatabase({
|
|
34
|
+
types: [Person.Person, Organization.Organization, Thread],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const messages = [
|
|
38
|
+
makeMessage('<m-1@enron.com>', 'alice@enron.com', 'Q2 report', 'I will send the Q2 report by Friday.'),
|
|
39
|
+
makeMessage('<m-2@enron.com>', 'bob@enron.com', 'Q2 report', 'Thanks, looking forward to it.'),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Fact persistence is proven by stages/extract-facts.test.ts; here the indexer is a no-op.
|
|
43
|
+
const indexFacts: FactIndexer = async () => [] as RDF.Fact[];
|
|
44
|
+
|
|
45
|
+
const result = await EffectEx.runPromise(
|
|
46
|
+
EmailPipeline.run(messages, {
|
|
47
|
+
db,
|
|
48
|
+
indexFacts,
|
|
49
|
+
ownerEmail: 'alice@enron.com',
|
|
50
|
+
now: '2001-05-20T10:00:00.000Z',
|
|
51
|
+
}).pipe(Effect.provide(mockAiService({}))),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// All messages flowed through the stream and were collected in order.
|
|
55
|
+
expect(result.messages).toHaveLength(2);
|
|
56
|
+
// stats tallied each message that flowed through.
|
|
57
|
+
expect(result.stats.total).toBe(2);
|
|
58
|
+
expect(result.stats.from.get('alice@enron.com')).toBe(1);
|
|
59
|
+
expect(result.stats.from.get('bob@enron.com')).toBe(1);
|
|
60
|
+
// summarize recorded a result per message (content empty: see note above).
|
|
61
|
+
expect(result.summaries).toHaveLength(2);
|
|
62
|
+
// Both messages share a subject/thread, so they group into a single thread persisted to the db.
|
|
63
|
+
expect(result.threads).toHaveLength(1);
|
|
64
|
+
expect(result.threads[0].messageIds).toHaveLength(2);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const makeMessage = (messageId: string, email: string, subject: string, text: string): Message.Message =>
|
|
69
|
+
Message.make({
|
|
70
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
71
|
+
sender: { email },
|
|
72
|
+
blocks: [{ _tag: 'text', text }],
|
|
73
|
+
properties: { messageId, subject },
|
|
74
|
+
});
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Stream from 'effect/Stream';
|
|
7
|
+
|
|
8
|
+
import { type AiService } from '@dxos/ai';
|
|
9
|
+
import { type Database } from '@dxos/echo';
|
|
10
|
+
import { Pipeline } from '@dxos/pipeline';
|
|
11
|
+
import { Message } from '@dxos/types';
|
|
12
|
+
|
|
13
|
+
import { buildThreads } from './internal/threads';
|
|
14
|
+
import {
|
|
15
|
+
EmailPipelineCtx,
|
|
16
|
+
type FactIndexer,
|
|
17
|
+
type Stats,
|
|
18
|
+
type Summary,
|
|
19
|
+
emptyStats,
|
|
20
|
+
extractContactsStage,
|
|
21
|
+
extractFactsStage,
|
|
22
|
+
statsStage,
|
|
23
|
+
summarizeStage,
|
|
24
|
+
} from './stages';
|
|
25
|
+
import { type Thread } from './types';
|
|
26
|
+
|
|
27
|
+
export type EmailPipelineOptions = {
|
|
28
|
+
/** ECHO space database: extract-contacts persists Person/Organization, threads are added here. */
|
|
29
|
+
readonly db: Database.Database;
|
|
30
|
+
/** Persists one message's facts to the fact substrate (a closure over a `FactStore`). */
|
|
31
|
+
readonly indexFacts: FactIndexer;
|
|
32
|
+
/** The space owner's email, used to orient thread state (awaiting-mine / awaiting-theirs). */
|
|
33
|
+
readonly ownerEmail: string;
|
|
34
|
+
/** Wall clock used to derive thread staleness; injected so the assembly stays deterministic. */
|
|
35
|
+
readonly now: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type EmailPipelineResult = {
|
|
39
|
+
readonly messages: Message.Message[];
|
|
40
|
+
readonly stats: Stats;
|
|
41
|
+
readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }>;
|
|
42
|
+
readonly threads: Thread[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const EmailPipeline = {
|
|
46
|
+
/**
|
|
47
|
+
* Default assembly of the email stages over a batch of messages: summarize → extract-contacts →
|
|
48
|
+
* stats → extract-facts stream through with back pressure, then {@link buildThreads} groups the
|
|
49
|
+
* collected messages into canonical threads. Contacts and threads are persisted to `db`; the fact
|
|
50
|
+
* substrate is populated via `indexFacts`. Requires an `AiService` (summarize); the shared
|
|
51
|
+
* {@link EmailPipelineCtx} is constructed and provided internally.
|
|
52
|
+
*/
|
|
53
|
+
run: (
|
|
54
|
+
messages: readonly Message.Message[],
|
|
55
|
+
options: EmailPipelineOptions,
|
|
56
|
+
): Effect.Effect<EmailPipelineResult, never, AiService.AiService> =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const stats = emptyStats();
|
|
59
|
+
const summaries: Array<{ messageId: string; summary: Summary }> = [];
|
|
60
|
+
const collected: Message.Message[] = [];
|
|
61
|
+
yield* Stream.fromIterable(messages).pipe(
|
|
62
|
+
summarizeStage,
|
|
63
|
+
extractContactsStage,
|
|
64
|
+
statsStage,
|
|
65
|
+
extractFactsStage(options.indexFacts),
|
|
66
|
+
Pipeline.run({ sink: (message) => Effect.sync(() => collected.push(message)) }),
|
|
67
|
+
Effect.provideService(EmailPipelineCtx, { db: options.db, stats, summaries }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const threads = buildThreads(collected, { ownerEmail: options.ownerEmail, now: options.now });
|
|
71
|
+
for (const thread of threads) {
|
|
72
|
+
options.db.add(thread);
|
|
73
|
+
}
|
|
74
|
+
return { messages: collected, stats, summaries, threads };
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Context from 'effect/Context';
|
|
6
|
+
|
|
7
|
+
import { type Database } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
/** Summary produced by the summarize stage. */
|
|
10
|
+
export type Summary = { readonly summary: string; readonly isSpam: boolean; readonly keywords: readonly string[] };
|
|
11
|
+
|
|
12
|
+
/** Running tallies produced by the stats stage (mutable accumulator). */
|
|
13
|
+
export type Stats = { from: Map<string, number>; to: Map<string, number>; total: number; spam: number };
|
|
14
|
+
|
|
15
|
+
/** Per-message summary result keyed by messageId (for a summary view). */
|
|
16
|
+
export type SummaryResult = { readonly summaries: ReadonlyArray<{ messageId: string; summary: Summary }> };
|
|
17
|
+
|
|
18
|
+
export const emptyStats = (): Stats => ({ from: new Map(), to: new Map(), total: 0, spam: 0 });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared context threaded through the email stages via Effect's Requirements channel. `db` is the
|
|
22
|
+
* ECHO space database (browser-safe); `stats` and `summaries` are mutable accumulators read after
|
|
23
|
+
* the run. Callers provide it once at the pipeline edge (`Effect.provide`).
|
|
24
|
+
*/
|
|
25
|
+
export class EmailPipelineCtx extends Context.Tag('@dxos/pipeline-email/EmailPipelineCtx')<
|
|
26
|
+
EmailPipelineCtx,
|
|
27
|
+
{
|
|
28
|
+
readonly db: Database.Database;
|
|
29
|
+
readonly stats: Stats;
|
|
30
|
+
readonly summaries: Array<{ messageId: string; summary: Summary }>;
|
|
31
|
+
}
|
|
32
|
+
>() {}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { extractContact } from '@dxos/extractor-lib';
|
|
8
|
+
import { Stage } from '@dxos/pipeline';
|
|
9
|
+
import { Message } from '@dxos/types';
|
|
10
|
+
|
|
11
|
+
import { EmailPipelineCtx } from './context';
|
|
12
|
+
|
|
13
|
+
/** Extract a Person (+ Organization) from the sender and persist to the ECHO space; pass message through. */
|
|
14
|
+
export const extractContactsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(
|
|
15
|
+
'extract-contacts',
|
|
16
|
+
(message) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const { db } = yield* EmailPipelineCtx;
|
|
19
|
+
const result = yield* extractContact({ db, source: message });
|
|
20
|
+
for (const object of result.created) {
|
|
21
|
+
db.add(object);
|
|
22
|
+
}
|
|
23
|
+
return message;
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Stream from 'effect/Stream';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { EffectEx } from '@dxos/effect';
|
|
9
|
+
import { Pipeline } from '@dxos/pipeline';
|
|
10
|
+
import { type RDF } from '@dxos/pipeline-rdf';
|
|
11
|
+
import { captureSink } from '@dxos/pipeline/testing';
|
|
12
|
+
import { Message } from '@dxos/types';
|
|
13
|
+
|
|
14
|
+
import { type FactExtractor, type FactUnit, extractFactsUnitStage } from './extract-facts-commit';
|
|
15
|
+
|
|
16
|
+
const ALICE_FACT: RDF.Fact = {
|
|
17
|
+
id: 'fact-1',
|
|
18
|
+
assertion: {
|
|
19
|
+
subject: { entity: 'alice' },
|
|
20
|
+
predicate: 'travelsTo',
|
|
21
|
+
object: { entity: 'paris' },
|
|
22
|
+
validFrom: '2026-06-12',
|
|
23
|
+
quote: "I think I'm probably going to Paris next week",
|
|
24
|
+
},
|
|
25
|
+
factuality: { value: 'PR+', polarity: '+', confidence: 0.6, nature: 'epistemic' },
|
|
26
|
+
attribution: {
|
|
27
|
+
agent: 'alice',
|
|
28
|
+
source: 'dxn:queue:...:msg-1',
|
|
29
|
+
generatedAtTime: '2026-06-06T00:00:00.000Z',
|
|
30
|
+
},
|
|
31
|
+
recordedAt: '2026-06-06T12:00:00.000Z',
|
|
32
|
+
extractor: { id: 'default', model: 'ai.claude.model.claude-haiku-4-5', version: '1' },
|
|
33
|
+
sourceHash: 'abc123',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const makeMessage = () =>
|
|
37
|
+
Message.make({
|
|
38
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
39
|
+
sender: { email: 'alice@enron.com' },
|
|
40
|
+
blocks: [{ _tag: 'text', text: 'I will send the Q2 report by Friday.' }],
|
|
41
|
+
properties: { messageId: '<m-1@enron.com>', subject: 'Q2 report' },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('extractFactsUnitStage', () => {
|
|
45
|
+
test('emits one FactUnit with the extracted facts and no persistence side effect', async ({ expect }) => {
|
|
46
|
+
const message = makeMessage();
|
|
47
|
+
const extract: FactExtractor = () => Promise.resolve([ALICE_FACT]);
|
|
48
|
+
|
|
49
|
+
const { sink, items } = captureSink<FactUnit>();
|
|
50
|
+
await EffectEx.runPromise(
|
|
51
|
+
Stream.fromIterable([message]).pipe(extractFactsUnitStage(extract), Pipeline.run({ sink })),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(items).toHaveLength(1);
|
|
55
|
+
expect(items[0].facts).toEqual([ALICE_FACT]);
|
|
56
|
+
expect(typeof items[0].key).toBe('number');
|
|
57
|
+
expect(typeof items[0].foreignId).toBe('string');
|
|
58
|
+
expect(items[0].foreignId).toBe('<m-1@enron.com>');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('degrades to an empty fact list when extraction rejects', async ({ expect }) => {
|
|
62
|
+
const message = makeMessage();
|
|
63
|
+
const extract: FactExtractor = () => Promise.reject(new Error('extraction failed'));
|
|
64
|
+
|
|
65
|
+
const { sink, items } = captureSink<FactUnit>();
|
|
66
|
+
await EffectEx.runPromise(
|
|
67
|
+
Stream.fromIterable([message]).pipe(extractFactsUnitStage(extract), Pipeline.run({ sink })),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(items).toHaveLength(1);
|
|
71
|
+
expect(items[0].facts).toEqual([]);
|
|
72
|
+
expect(typeof items[0].key).toBe('number');
|
|
73
|
+
expect(typeof items[0].foreignId).toBe('string');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { Stage } from '@dxos/pipeline';
|
|
8
|
+
import { type RDF } from '@dxos/pipeline-rdf';
|
|
9
|
+
import { Message } from '@dxos/types';
|
|
10
|
+
|
|
11
|
+
import { messageSource } from './facts';
|
|
12
|
+
|
|
13
|
+
// Extract one message's facts WITHOUT persisting them. Distinct from `FactIndexer`, whose closure
|
|
14
|
+
// also writes to the store: here persistence is the sink's responsibility, so a page of facts can
|
|
15
|
+
// commit atomically with the cursor advance. Same signature, different contract.
|
|
16
|
+
export type FactExtractor = (message: Message.Message) => Promise<RDF.Fact[]>;
|
|
17
|
+
|
|
18
|
+
// Terminal unit for the cursored fact pipeline: extracted facts plus the keys the sink needs to
|
|
19
|
+
// dedup and advance the cursor.
|
|
20
|
+
export type FactUnit = {
|
|
21
|
+
readonly facts: RDF.Fact[];
|
|
22
|
+
/** Stable per-message id (`messageSource`); the dedup foreign id. */
|
|
23
|
+
readonly foreignId: string;
|
|
24
|
+
/** Monotonic cursor key. */
|
|
25
|
+
readonly key: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Facts-returning variant of `extractFactsStage`: returns the extracted facts as a `FactUnit`
|
|
29
|
+
// instead of persisting them in-stage, so the sink can write a page atomically with the cursor
|
|
30
|
+
// advance. Extraction degrades to no facts on failure (advisory layer) but logs first, matching
|
|
31
|
+
// `extractFactsStage`'s degradation, so the loss stays observable.
|
|
32
|
+
export const extractFactsUnitStage = (extract: FactExtractor): Stage.Stage<Message.Message, FactUnit> =>
|
|
33
|
+
Stage.map('extract-facts-unit', (message) =>
|
|
34
|
+
Effect.tryPromise(() => extract(message)).pipe(
|
|
35
|
+
Effect.tapError((error) => Effect.logWarning('extract-facts-unit failed; degrading to no facts', error)),
|
|
36
|
+
Effect.orElse(() => Effect.succeed<RDF.Fact[]>([])),
|
|
37
|
+
Effect.map((facts): FactUnit => ({ facts, foreignId: messageSource(message), key: keyOf(message) })),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// NOTE(workaround): `message.created` is the incremental cursor key because ECHO's native feed
|
|
42
|
+
// cursor is unimplemented (`Feed.cursor` is stubbed). Replace with the native queue sequence when
|
|
43
|
+
// available.
|
|
44
|
+
const keyOf = (message: Message.Message): number => Date.parse(message.created);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Layer from 'effect/Layer';
|
|
7
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
8
|
+
import * as Stream from 'effect/Stream';
|
|
9
|
+
import { describe, test } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { EffectEx } from '@dxos/effect';
|
|
12
|
+
import { Pipeline } from '@dxos/pipeline';
|
|
13
|
+
import { FactPipeline, FactStore, type RDF } from '@dxos/pipeline-rdf';
|
|
14
|
+
import { mockAiService } from '@dxos/pipeline-rdf/testing';
|
|
15
|
+
import { captureSink } from '@dxos/pipeline/testing';
|
|
16
|
+
import { Message } from '@dxos/types';
|
|
17
|
+
|
|
18
|
+
import { type FactIndexer, extractFactsStage } from './extract-facts';
|
|
19
|
+
import { EMAIL_EXTRACT_OPTIONS, messageToDocument } from './facts';
|
|
20
|
+
|
|
21
|
+
// One fact per message from the mock LLM; proves the stage persists into the store.
|
|
22
|
+
const LLM_OUTPUT = {
|
|
23
|
+
facts: [
|
|
24
|
+
{
|
|
25
|
+
subject: 'alice@enron.com',
|
|
26
|
+
predicate: 'will send',
|
|
27
|
+
object: 'Q2 report',
|
|
28
|
+
validTo: '2001-05-18',
|
|
29
|
+
factuality: 'CT+',
|
|
30
|
+
polarity: '+',
|
|
31
|
+
quote: 'I will send the Q2 report by Friday.',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('extractFactsStage', () => {
|
|
37
|
+
test('extracts and persists a fact per message into the store', async ({ expect }) => {
|
|
38
|
+
const runtime = ManagedRuntime.make(FactStore.layerMemory.pipe(Layer.provideMerge(mockAiService(LLM_OUTPUT))));
|
|
39
|
+
const indexFacts: FactIndexer = (message) =>
|
|
40
|
+
runtime.runPromise(FactPipeline.run([messageToDocument(message)], EMAIL_EXTRACT_OPTIONS));
|
|
41
|
+
|
|
42
|
+
const message = Message.make({
|
|
43
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
44
|
+
sender: { email: 'alice@enron.com' },
|
|
45
|
+
blocks: [{ _tag: 'text', text: 'I will send the Q2 report by Friday.' }],
|
|
46
|
+
properties: { messageId: '<m-1@enron.com>', subject: 'Q2 report' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const { sink, items } = captureSink<Message.Message>();
|
|
50
|
+
await EffectEx.runPromise(
|
|
51
|
+
Stream.fromIterable([message]).pipe(extractFactsStage(indexFacts), Pipeline.run({ sink })),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// The Message passes through unchanged.
|
|
55
|
+
expect(items).toHaveLength(1);
|
|
56
|
+
|
|
57
|
+
// The fact was persisted; read it back from the same store.
|
|
58
|
+
const facts: RDF.Fact[] = await runtime.runPromise(
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const store = yield* FactStore;
|
|
61
|
+
return yield* store.query({});
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
await runtime.dispose();
|
|
65
|
+
|
|
66
|
+
expect(facts.length).toBeGreaterThan(0);
|
|
67
|
+
const { object } = facts[0].assertion;
|
|
68
|
+
// Extraction always produces an entity-ref Term (never a literal), so `label` is present here.
|
|
69
|
+
expect('entity' in object).toBe(true);
|
|
70
|
+
expect('entity' in object && object.label).toBe('Q2 report');
|
|
71
|
+
expect(facts[0].factuality.value).toBe('CT+');
|
|
72
|
+
expect(facts[0].attribution.source).toBe('<m-1@enron.com>');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { Stage } from '@dxos/pipeline';
|
|
8
|
+
import { type RDF } from '@dxos/pipeline-rdf';
|
|
9
|
+
import { Message } from '@dxos/types';
|
|
10
|
+
|
|
11
|
+
// Extract + persist one message's facts, returning them. The store/AI-bound work is a Promise closure
|
|
12
|
+
// so the stage's Effect stays R = never (a failed extraction must not fail the pipeline run).
|
|
13
|
+
export type FactIndexer = (message: Message.Message) => Promise<RDF.Fact[]>;
|
|
14
|
+
|
|
15
|
+
// Message-layer stage: index each message into the fact substrate, passing the Message through
|
|
16
|
+
// unchanged. Extraction degrades to no facts on failure (advisory layer) but logs first, so the
|
|
17
|
+
// degradation stays observable. A factory over `indexFacts` (like the pipeline's `logStage(label)`)
|
|
18
|
+
// keeps the module decoupled from any test-level Context.
|
|
19
|
+
export const extractFactsStage = (indexFacts: FactIndexer): Stage.Stage<Message.Message, Message.Message> =>
|
|
20
|
+
Stage.map('extract-facts', (message) =>
|
|
21
|
+
Effect.tryPromise(() => indexFacts(message)).pipe(
|
|
22
|
+
Effect.tapError((error) => Effect.logWarning('extract-facts failed; degrading to no facts', error)),
|
|
23
|
+
Effect.orElse(() => Effect.succeed<RDF.Fact[]>([])),
|
|
24
|
+
Effect.as(message),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { Message } from '@dxos/types';
|
|
8
|
+
|
|
9
|
+
import { EMAIL_EXTRACTION_RULES, messageSource, messageToDocument } from './facts';
|
|
10
|
+
|
|
11
|
+
describe('messageToDocument', () => {
|
|
12
|
+
test('maps a Message onto an ExtractDocument', ({ expect }) => {
|
|
13
|
+
const message = Message.make({
|
|
14
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
15
|
+
sender: { email: 'alice@enron.com' },
|
|
16
|
+
blocks: [{ _tag: 'text', text: 'Please send the Q2 report by Friday.' }],
|
|
17
|
+
properties: { messageId: '<m-1@enron.com>', subject: 'Q2 report' },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const doc = messageToDocument(message);
|
|
21
|
+
expect(doc.text).toBe('Please send the Q2 report by Friday.');
|
|
22
|
+
expect(doc.source).toBe('<m-1@enron.com>');
|
|
23
|
+
expect(doc.author).toBe('alice@enron.com');
|
|
24
|
+
expect(doc.date).toBe('2001-05-14T10:00:00.000Z');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('messageSource falls back to sender+created when no messageId', ({ expect }) => {
|
|
28
|
+
const message = Message.make({
|
|
29
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
30
|
+
sender: { email: 'bob@enron.com' },
|
|
31
|
+
blocks: [{ _tag: 'text', text: 'hi' }],
|
|
32
|
+
});
|
|
33
|
+
expect(messageSource(message)).toBe('bob@enron.com:2001-05-14T10:00:00.000Z');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('email rules extend the base rules with email-specific guidance', ({ expect }) => {
|
|
37
|
+
expect(EMAIL_EXTRACTION_RULES.length).toBeGreaterThan(0);
|
|
38
|
+
expect(EMAIL_EXTRACTION_RULES.some((rule) => /commitment|deadline|action/i.test(rule))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type RDF } from '@dxos/pipeline-rdf';
|
|
6
|
+
import { Message } from '@dxos/types';
|
|
7
|
+
|
|
8
|
+
// Email-specific extraction guidance appended after the pipeline-rdf DEFAULT_EXTRACTION_RULES.
|
|
9
|
+
// Kept atomic so the two rule sets compose without forking the base prompt.
|
|
10
|
+
export const EMAIL_EXTRACTION_RULES: readonly string[] = [
|
|
11
|
+
'Treat a request or promise as a commitment: "please send X by Friday" => subject (the owner), predicate "owes"/"will send", object X, validTo the deadline.',
|
|
12
|
+
'A deadline or due date becomes validTo on the relevant assertion (ISO date when known).',
|
|
13
|
+
'Attribute first-person statements ("I will…", "we can…") to the message sender as subject.',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const EMAIL_EXTRACT_OPTIONS: RDF.ExtractOptions = {
|
|
17
|
+
rules: EMAIL_EXTRACTION_RULES,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Stable per-message identifier used as the fact `source` and the incremental cursor key. Prefer the
|
|
21
|
+
// RFC message-id header (present in the Enron dataset); fall back to sender+timestamp so every message
|
|
22
|
+
// has a deterministic source even when the header is absent.
|
|
23
|
+
export const messageSource = (message: Message.Message): string => {
|
|
24
|
+
const messageId = message.properties?.messageId;
|
|
25
|
+
if (typeof messageId === 'string' && messageId.length > 0) {
|
|
26
|
+
return messageId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `${message.sender.email ?? 'unknown'}:${message.created}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const messageToDocument = (message: Message.Message): RDF.ExtractDocument => ({
|
|
33
|
+
text: Message.extractText(message),
|
|
34
|
+
source: messageSource(message),
|
|
35
|
+
...(message.sender.email ? { author: message.sender.email } : {}),
|
|
36
|
+
date: message.created,
|
|
37
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export * from './context';
|
|
6
|
+
export * from './extract-contacts';
|
|
7
|
+
export * from './extract-facts';
|
|
8
|
+
export * from './extract-facts-commit';
|
|
9
|
+
export * from './facts';
|
|
10
|
+
export * from './stats';
|
|
11
|
+
export * from './summarize';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Layer from 'effect/Layer';
|
|
7
|
+
import * as Stream from 'effect/Stream';
|
|
8
|
+
import { describe, test } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { EffectEx } from '@dxos/effect';
|
|
11
|
+
import { Pipeline } from '@dxos/pipeline';
|
|
12
|
+
import { Message } from '@dxos/types';
|
|
13
|
+
|
|
14
|
+
import { EmailPipelineCtx, emptyStats } from './context';
|
|
15
|
+
import { statsStage } from './stats';
|
|
16
|
+
|
|
17
|
+
describe('statsStage', () => {
|
|
18
|
+
test('tallies senders and spam across the stream', async ({ expect }) => {
|
|
19
|
+
const stats = emptyStats();
|
|
20
|
+
// Test-only stub: statsStage never touches `db`.
|
|
21
|
+
const ctx = Layer.succeed(EmailPipelineCtx, { db: {} as any, stats, summaries: [] });
|
|
22
|
+
const messages = [
|
|
23
|
+
Message.make({
|
|
24
|
+
created: '2001-05-14T10:00:00.000Z',
|
|
25
|
+
sender: { email: 'a@x.com' },
|
|
26
|
+
blocks: [],
|
|
27
|
+
properties: { spam: true },
|
|
28
|
+
}),
|
|
29
|
+
Message.make({ created: '2001-05-14T11:00:00.000Z', sender: { email: 'a@x.com' }, blocks: [] }),
|
|
30
|
+
];
|
|
31
|
+
await EffectEx.runPromise(
|
|
32
|
+
Stream.fromIterable(messages).pipe(statsStage, Pipeline.run({ sink: () => Effect.void }), Effect.provide(ctx)),
|
|
33
|
+
);
|
|
34
|
+
expect(stats.total).toBe(2);
|
|
35
|
+
expect(stats.spam).toBe(1);
|
|
36
|
+
expect(stats.from.get('a@x.com')).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
|
|
7
|
+
import { Stage } from '@dxos/pipeline';
|
|
8
|
+
import { Message } from '@dxos/types';
|
|
9
|
+
|
|
10
|
+
import { EmailPipelineCtx } from './context';
|
|
11
|
+
|
|
12
|
+
/** Pure-JS running tallies (senders, recipients, spam); pass message through. */
|
|
13
|
+
export const statsStage: Stage.Stage<Message.Message, Message.Message, never, EmailPipelineCtx> = Stage.map(
|
|
14
|
+
'stats',
|
|
15
|
+
(message) =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const { stats } = yield* EmailPipelineCtx;
|
|
18
|
+
stats.total += 1;
|
|
19
|
+
const sender = message.sender.email;
|
|
20
|
+
if (sender) {
|
|
21
|
+
stats.from.set(sender, (stats.from.get(sender) ?? 0) + 1);
|
|
22
|
+
}
|
|
23
|
+
const recipients = message.properties?.to;
|
|
24
|
+
if (Array.isArray(recipients)) {
|
|
25
|
+
for (const recipient of recipients) {
|
|
26
|
+
const address = String(recipient);
|
|
27
|
+
stats.to.set(address, (stats.to.get(address) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (message.properties?.spam) {
|
|
31
|
+
stats.spam += 1;
|
|
32
|
+
}
|
|
33
|
+
return message;
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as LanguageModel from '@effect/ai/LanguageModel';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Layer from 'effect/Layer';
|
|
8
|
+
|
|
9
|
+
import { AiService } from '@dxos/ai';
|
|
10
|
+
import { Stage } from '@dxos/pipeline';
|
|
11
|
+
import { ContentBlock, Message } from '@dxos/types';
|
|
12
|
+
import { trim } from '@dxos/util';
|
|
13
|
+
|
|
14
|
+
import { EmailPipelineCtx, type Summary } from './context';
|
|
15
|
+
|
|
16
|
+
const SUMMARIZE_MODEL = 'com.anthropic.model.claude-haiku-4-5.default';
|
|
17
|
+
|
|
18
|
+
const SUMMARIZE_PROMPT = trim`
|
|
19
|
+
Summarize the following email in one sentence, decide whether it is spam, and list up to five keywords.
|
|
20
|
+
Respond with ONLY a JSON object of the form {"summary": string, "isSpam": boolean, "keywords": string[]}.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
// Local models often wrap JSON in prose; extract the first object and coerce leniently.
|
|
24
|
+
const parseSummary = (raw: string): Summary => {
|
|
25
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return { summary: '', isSpam: false, keywords: [] };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(match[0]);
|
|
31
|
+
const keywords = parsed.keywords;
|
|
32
|
+
return {
|
|
33
|
+
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
|
|
34
|
+
isSpam: parsed.isSpam === true || parsed.spam === true || parsed.is_spam === true,
|
|
35
|
+
keywords: Array.isArray(keywords) ? keywords.map((keyword: unknown) => String(keyword)) : [],
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return { summary: '', isSpam: false, keywords: [] };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Summarize via AiService/LanguageModel; append a summary block and record spam/keywords + result. */
|
|
43
|
+
export const summarizeStage: Stage.Stage<
|
|
44
|
+
Message.Message,
|
|
45
|
+
Message.Message,
|
|
46
|
+
never,
|
|
47
|
+
EmailPipelineCtx | AiService.AiService
|
|
48
|
+
> = Stage.map('summarize', (message) =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const ctx = yield* EmailPipelineCtx;
|
|
51
|
+
const text = Message.extractText(message);
|
|
52
|
+
// Resolve the LanguageModel layer locally from AiService so `LanguageModel` is discharged and the
|
|
53
|
+
// stage's requirement correctly reduces to `EmailPipelineCtx | AiService.AiService`.
|
|
54
|
+
// Bound the LLM call: `orElse` only recovers failures, so without a timeout a hung/slow provider
|
|
55
|
+
// would block the stage indefinitely. On timeout the effect fails, then `orElse` degrades to ''.
|
|
56
|
+
const raw = yield* LanguageModel.generateText({ prompt: `${SUMMARIZE_PROMPT}\n\n${text}` }).pipe(
|
|
57
|
+
Effect.provide(AiService.model(SUMMARIZE_MODEL).pipe(Layer.orDie)),
|
|
58
|
+
Effect.timeout('30 seconds'),
|
|
59
|
+
Effect.map((response) => response.text),
|
|
60
|
+
Effect.orElse(() => Effect.succeed('')),
|
|
61
|
+
);
|
|
62
|
+
const summary = parseSummary(raw);
|
|
63
|
+
const messageId = String(message.properties?.messageId ?? message.id);
|
|
64
|
+
ctx.summaries.push({ messageId, summary });
|
|
65
|
+
const summaryBlock: ContentBlock.Text = { _tag: 'text', text: summary.summary };
|
|
66
|
+
// Preserve the original id: `Message.make` mints a fresh one when omitted, which would break the
|
|
67
|
+
// `message.id` fallback used for `messageId` downstream.
|
|
68
|
+
return Message.make({
|
|
69
|
+
id: message.id,
|
|
70
|
+
created: message.created,
|
|
71
|
+
sender: message.sender,
|
|
72
|
+
blocks: [...message.blocks, summaryBlock],
|
|
73
|
+
properties: { ...message.properties, spam: summary.isSpam, keywords: summary.keywords },
|
|
74
|
+
});
|
|
75
|
+
}),
|
|
76
|
+
);
|