@startsimpli/integrations 0.1.6 → 0.2.2

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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * EXTRACTION — conversations in, trackable commitments out (bd startsim-768w.38.25).
3
+ *
4
+ * The pipeline, server-side, in one function:
5
+ *
6
+ * 1. read documents from the tenant data API, already org-scoped by the
7
+ * server and narrowed the same way the Library narrows;
8
+ * 2. run the SCOPE GUARD over them — a third check, before anything is read
9
+ * into a prompt (see ./retrieval for why this is not a convenience);
10
+ * 3. RULES PASS: parse the commitments the summariser ALREADY wrote down.
11
+ * No model, deterministic, byte-stable between runs;
12
+ * 4. ONE model call over every document, told what the rules already found so
13
+ * it ADDS rather than restates (feedback_batch_llm_calls — never a loop);
14
+ * 5. resolve IDENTITY, dedupe within the run and against what is stored;
15
+ * 6. apply RESOLUTIONS from later meetings, conservatively;
16
+ * 7. write the result back with `external_id` doing the idempotency.
17
+ *
18
+ * STEP 3 IS THE ONE PEOPLE SKIP. The source material here is Granola's own AI
19
+ * summaries, and on all sixteen real documents on the live tandem tenant the
20
+ * last heading is `## Next Steps`, under which sit bolded imperatives with the
21
+ * owner in parentheses. That is an action list the vendor already produced and
22
+ * already paid for. Sending the document to a model and asking it to find the
23
+ * action items redoes that work, less reliably, at a per-token price, and then
24
+ * has to be deduped against the version the vendor wrote. The rules pass takes
25
+ * it verbatim; the model's job is what the summariser did NOT promote — items
26
+ * stated in the body, blockers, decisions, and the subject each one is about.
27
+ *
28
+ * A PROVIDER OUTAGE COSTS THE EXTRAS, NOT THE LIST. Step 4 is wrapped: if the
29
+ * model is unreachable the run still produces every commitment the vendor's own
30
+ * structure carried, and says so in `fromModel: 0`.
31
+ */
32
+ import { EXTRACTOR_VERSION, applyResolutions, mergeDrafts, reconcile } from './commitments';
33
+ import { enforceScope } from './retrieval';
34
+ import { outcomeSections } from './text';
35
+ import type {
36
+ Commitment,
37
+ CommitmentApi,
38
+ CommitmentDraft,
39
+ CommitmentStatus,
40
+ CorpusApi,
41
+ CorpusExtractorLlm,
42
+ CorpusScope,
43
+ ExtractionDraft,
44
+ ExtractionResult,
45
+ ResolutionClaim,
46
+ SourceDocument,
47
+ } from './types';
48
+
49
+ export interface ExtractCommitmentsOptions {
50
+ /** How many documents to read before extracting. */
51
+ documentLimit?: number;
52
+ /** Pages of the data API this run may read. Bounds one run's cost. */
53
+ maxPages?: number;
54
+ /** Characters of each document's text sent to the model. */
55
+ maxCharsPerDocument?: number;
56
+ }
57
+
58
+ const DEFAULTS: Required<ExtractCommitmentsOptions> = {
59
+ documentLimit: 200,
60
+ maxPages: 10,
61
+ maxCharsPerDocument: 12_000,
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // 3. the rules pass — the vendor already did this
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /** `- **Do the thing** (Owner)` — a bullet in an outcome section. */
69
+ const BULLET = /^\s{0,3}[-*]\s+(\S.*)$/;
70
+
71
+ /**
72
+ * THE BOLD IS REQUIRED, and that is the single most important line in the rules
73
+ * pass.
74
+ *
75
+ * Measured on the sixteen real records: accepting bare bullets took precision
76
+ * from 94% to 75%, because headings like "Action Items Review" and "Open Issues
77
+ * and Follow-ups" match the outcome regex while the bullets under them are
78
+ * DISCUSSION, not commitments — "Matter is on the responsible attorney's radar"
79
+ * is a status note and putting it on a to-do list is a lie about what was
80
+ * agreed. The summariser marks a real commitment by bolding it. Trusting that
81
+ * marker is what makes this pass worth having; guessing past it is what makes a
82
+ * to-do list nobody believes.
83
+ */
84
+ const LEADING_BOLD = /^\*\*(.+?)\*\*/;
85
+
86
+ /**
87
+ * A trailing parenthetical that is plausibly a person or a short list of them.
88
+ *
89
+ * Deliberately narrow, and it rejects ALL-CAPS: `(Jonathan, Kevin)` and `(Kate)`
90
+ * are owners, `(CTO)` is a job title inside the sentence and `(4 5 AC
91
+ * eight-plex)` is part of the sentence. Reading either of the last two as an
92
+ * owner puts junk in the column the whole screen is sorted by. Anything that
93
+ * fails this stays in the title and the model pass gets a chance at the owner.
94
+ */
95
+ const TRAILING_PAREN = /^(.*?)\s*\(([^()]{2,60})\)\s*$/;
96
+ const OWNER_SHAPE = /^[A-Z][A-Za-z.'’-]*(?:\s+[A-Za-z.'’-]+)*(?:\s*,\s*[A-Z][A-Za-z.'’-]*(?:\s+[A-Za-z.'’-]+)*)*$/;
97
+ const ACRONYM = /^[A-Z][A-Z0-9.\s,&-]*$/;
98
+
99
+ /**
100
+ * The commitments the source document already states as commitments.
101
+ *
102
+ * NO MODEL. Given the same text this returns the same drafts with the same
103
+ * `originKey`s forever, which is what makes the majority of the corpus perfectly
104
+ * idempotent rather than merely usually idempotent.
105
+ */
106
+ export function rulesExtract(document: SourceDocument): CommitmentDraft[] {
107
+ const text = [document.body, ...document.sections.map((section) => section.text)]
108
+ .filter(Boolean)
109
+ .join('\n\n');
110
+
111
+ const drafts: CommitmentDraft[] = [];
112
+ for (const section of outcomeSections(text)) {
113
+ let current: { line: string; detail: string[] } | null = null;
114
+ const flush = () => {
115
+ if (!current) return;
116
+ const draft = toDraft(document, current.line, current.detail.join('\n'));
117
+ if (draft) drafts.push(draft);
118
+ current = null;
119
+ };
120
+ for (const line of section.body.split('\n')) {
121
+ const bullet = BULLET.exec(line);
122
+ if (bullet) {
123
+ flush();
124
+ current = { line: line.trim(), detail: [] };
125
+ } else if (current && line.trim()) {
126
+ current.detail.push(line.trim());
127
+ }
128
+ }
129
+ flush();
130
+ }
131
+ return drafts;
132
+ }
133
+
134
+ function toDraft(document: SourceDocument, line: string, detail: string): CommitmentDraft | null {
135
+ const content = line.replace(BULLET, '$1').trim();
136
+ const bold = LEADING_BOLD.exec(content);
137
+ if (!bold || bold[1].trim().split(/\s+/).length < 3) return null;
138
+
139
+ // The owner is read from the LAST trailing parenthetical, whether the
140
+ // summariser wrote it inside the bold — `**Email Michelle … (Kate)**` — or
141
+ // after it — `**Complete the equity item** (Jonathan)`. Both spellings are
142
+ // present in the real corpus and there is no principled reason to honour one;
143
+ // measured across the sixteen records, reading only the outside form cost six
144
+ // of eighteen owners on a single document.
145
+ const flat = content.replace(/\*\*/g, '').trim();
146
+ const trailing = TRAILING_PAREN.exec(flat);
147
+ const candidate = trailing?.[2]?.trim() ?? '';
148
+ const isOwner =
149
+ Boolean(candidate) && OWNER_SHAPE.test(candidate) && !ACRONYM.test(candidate) && !/\d/.test(candidate);
150
+
151
+ const ownerName = isOwner ? candidate : undefined;
152
+ let title = isOwner ? trailing![1] : flat;
153
+
154
+ title = title.replace(/\s+/g, ' ').trim();
155
+ // Two words is a fragment, not a commitment. The model pass sees the whole
156
+ // document anyway, so nothing is lost by being strict here.
157
+ if (title.split(/\s+/).length < 3) return null;
158
+
159
+ return {
160
+ documentId: document.id,
161
+ // Byte-stable: derived from the vendor's own words, never from a model.
162
+ originKey: `rules:${slug(title)}`,
163
+ subtype: 'action',
164
+ title,
165
+ detail: detail.trim(),
166
+ status: 'open',
167
+ ...(ownerName ? { ownerName } : {}),
168
+ // The bullet exactly as written, so every row on the screen can be checked
169
+ // against the record without opening it.
170
+ quote: line.trim(),
171
+ extractor: 'rules',
172
+ };
173
+ }
174
+
175
+ const slug = (text: string) =>
176
+ text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // the prompt
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Every rule in here is also enforced in code, because a prompt is a request and
184
+ * a check is a guarantee. What the prompt uniquely buys is the two things code
185
+ * cannot derive: what a statement is ABOUT, and whether a later meeting settled
186
+ * an earlier one.
187
+ */
188
+ export function extractSystemPrompt(): string {
189
+ return [
190
+ 'You read meeting records for one company and turn them into trackable items.',
191
+ 'You are given documents that ALREADY have some items extracted from them, listed under ALREADY_FOUND. Do NOT repeat those. Find what they missed.',
192
+ 'An item is something a person now owes, has decided, needs, is at risk from, or has asked. It must be stated in the document — never inferred, never invented.',
193
+ 'For every item, copy a short verbatim "quote" from the document it came from, and echo that document\'s exact id.',
194
+ 'Set "subject" to what the item is ABOUT — a matter or case number, a property, a client, a system, a person. Copy an identifier verbatim when the text has one.',
195
+ 'Set "owner" only to a name the document actually names. Leave it out otherwise.',
196
+ 'Set "dueAt" only when the document states a date. Never estimate one.',
197
+ 'Also report RESOLUTIONS: statements that something previously outstanding is now done or abandoned. Give the subject, what happened, and a verbatim quote.',
198
+ 'Respond with JSON only: {"items":[{"documentId":string,"subtype":"action"|"decision"|"need"|"risk"|"question","title":string,"detail":string,"status":"open"|"done"|"blocked","owner":string,"subject":string,"dueAt":string|null,"quote":string}],"resolutions":[{"documentId":string,"subject":string,"statement":string,"quote":string,"outcome":"done"|"dropped"}]}',
199
+ '"title" must be one short imperative line.',
200
+ ].join('\n');
201
+ }
202
+
203
+ /** ONE payload carrying every document, each tagged with the id to echo. */
204
+ export function renderExtractPrompt(input: {
205
+ documents: Array<{
206
+ id: string; title: string; source: string; occurredAt: string | null;
207
+ participants: string[]; text: string; alreadyFound: string[];
208
+ }>;
209
+ }): string {
210
+ const blocks = input.documents.map((document) =>
211
+ [
212
+ `<document id="${document.id}">`,
213
+ `title: ${document.title}`,
214
+ `source: ${document.source}`,
215
+ `occurred_at: ${document.occurredAt ?? 'unknown'}`,
216
+ document.participants.length ? `participants: ${document.participants.join(', ')}` : '',
217
+ document.alreadyFound.length
218
+ ? `ALREADY_FOUND (do not repeat these):\n${document.alreadyFound.map((title) => `- ${title}`).join('\n')}`
219
+ : 'ALREADY_FOUND: none',
220
+ '',
221
+ document.text,
222
+ '</document>',
223
+ ]
224
+ .filter((line) => line !== '')
225
+ .join('\n'),
226
+ );
227
+ return [`DOCUMENTS (${input.documents.length}):`, blocks.join('\n\n')].join('\n');
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // the pipeline
232
+ // ---------------------------------------------------------------------------
233
+
234
+ export interface ExtractRequest {
235
+ scope: CorpusScope;
236
+ /** Narrow the run to specific records. Omit to sweep the corpus. */
237
+ documentIds?: string[];
238
+ }
239
+
240
+ export interface ExtractDeps {
241
+ corpus: CorpusApi & Partial<CommitmentApi>;
242
+ extractor: CorpusExtractorLlm;
243
+ }
244
+
245
+ export async function extractCommitments(
246
+ request: ExtractRequest,
247
+ deps: ExtractDeps,
248
+ options: ExtractCommitmentsOptions = {},
249
+ ): Promise<ExtractionResult> {
250
+ const { documentLimit, maxPages, maxCharsPerDocument } = { ...DEFAULTS, ...options };
251
+ const { corpus, extractor } = deps;
252
+
253
+ // An empty scope grants nothing (`isInScope`). Refusing here rather than
254
+ // downstream means an unscoped read is never MADE, not made and then filtered.
255
+ if (!request.scope?.orgId) {
256
+ return empty(extractor, 'no scope');
257
+ }
258
+
259
+ const { documents: candidates, matched } = await readDocuments(corpus, documentLimit, maxPages);
260
+ const { kept, dropped } = enforceScope(candidates, request.scope);
261
+ const documents = request.documentIds?.length
262
+ ? kept.filter((document) => request.documentIds!.includes(document.id))
263
+ : kept;
264
+
265
+ if (documents.length === 0) {
266
+ return { ...empty(extractor, 'nothing in scope'), droppedOutOfScope: dropped.length };
267
+ }
268
+
269
+ // 3. rules first, so the model can be told what not to repeat.
270
+ const ruleDrafts = documents.flatMap(rulesExtract);
271
+ const foundByDocument = new Map<string, string[]>();
272
+ for (const draft of ruleDrafts) {
273
+ foundByDocument.set(draft.documentId, [...(foundByDocument.get(draft.documentId) ?? []), draft.title]);
274
+ }
275
+
276
+ // 4. ONE call, every document, ids echoed.
277
+ let draft: ExtractionDraft = { items: [], resolutions: [] };
278
+ try {
279
+ draft = await extractor.extract({
280
+ documents: documents.map((document) => ({
281
+ id: document.id,
282
+ title: document.title,
283
+ source: document.sourceLabel || document.source,
284
+ occurredAt: document.occurredAt,
285
+ participants: document.participants.map((party) => party.name),
286
+ text: documentText(document, maxCharsPerDocument),
287
+ alreadyFound: foundByDocument.get(document.id) ?? [],
288
+ })),
289
+ });
290
+ } catch {
291
+ // The deterministic half does not depend on the probabilistic half.
292
+ draft = { items: [], resolutions: [] };
293
+ }
294
+
295
+ const allowed = new Set(documents.map((document) => document.id));
296
+ const modelDrafts = normaliseItems(draft.items ?? [], allowed);
297
+ const claims = normaliseResolutions(draft.resolutions ?? [], allowed);
298
+
299
+ // 5. identity, dedupe within the run, then against what is stored.
300
+ const existing = corpus.listCommitments
301
+ ? await corpus
302
+ .listCommitments({ pageSize: 500 })
303
+ .then((page) => page.results)
304
+ .catch(() => [] as Commitment[])
305
+ : [];
306
+
307
+ const groups = mergeDrafts([...ruleDrafts, ...modelDrafts]);
308
+ const reconciled = reconcile(existing, groups, { scope: request.scope, documents });
309
+
310
+ // 6. resolutions, against the documents the scope guard approved and nothing else.
311
+ const resolutions = applyResolutions(reconciled.commitments, claims, { documents });
312
+
313
+ // 7. write. ONE call carrying every row, keyed on `external_id`.
314
+ if (corpus.saveCommitments) {
315
+ await corpus.saveCommitments(resolutions.commitments);
316
+ }
317
+
318
+ return {
319
+ documentsProcessed: documents.length,
320
+ fromRules: ruleDrafts.length,
321
+ fromModel: modelDrafts.length,
322
+ created: reconciled.created,
323
+ merged: reconciled.merged,
324
+ resolved: resolutions.resolved,
325
+ needsReview: resolutions.needsReview,
326
+ droppedOutOfScope: dropped.length,
327
+ provider: extractor.describe(),
328
+ ...(matched > candidates.length ? { } : {}),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Read the corpus, PAST THE FIRST PAGE.
334
+ *
335
+ * The tenant's `EntityViewSet` pages with DRF's default and ignores `page_size`
336
+ * (verified on the wire against the live tandem tenant: asking for 3 returns
337
+ * all sixteen), so a caller that reads one page and calls it the corpus silently
338
+ * stops seeing older records at whatever `PAGE_SIZE` happens to be. Extraction
339
+ * over "the most recent page" would quietly stop extracting.
340
+ */
341
+ async function readDocuments(
342
+ corpus: CorpusApi,
343
+ limit: number,
344
+ maxPages: number,
345
+ ): Promise<{ documents: SourceDocument[]; matched: number }> {
346
+ const documents: SourceDocument[] = [];
347
+ let matched = 0;
348
+ for (let page = 1; page <= maxPages && documents.length < limit; page += 1) {
349
+ const response = await corpus.listDocuments({ page, pageSize: limit });
350
+ matched = response.count ?? response.results.length;
351
+ documents.push(...response.results);
352
+ if (!response.next || response.results.length === 0) break;
353
+ }
354
+ return { documents: documents.slice(0, limit), matched };
355
+ }
356
+
357
+ function documentText(document: SourceDocument, maxChars: number): string {
358
+ const parts = [document.body, ...document.sections.map((section) => `## ${section.label}\n${section.text}`)];
359
+ const joined = parts.filter(Boolean).join('\n\n');
360
+ return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
361
+ }
362
+
363
+ const SUBTYPES = new Set(['action', 'decision', 'need', 'risk', 'question']);
364
+ const STATUSES = new Set<CommitmentStatus>(['open', 'done', 'blocked', 'dropped']);
365
+
366
+ /**
367
+ * Shape-check what the model returned.
368
+ *
369
+ * An item naming a document the run never supplied is DROPPED, not repaired:
370
+ * the document id is the one field that could carry a reference into another
371
+ * workspace, and a plausible-looking id is exactly what a model produces when it
372
+ * is guessing.
373
+ */
374
+ function normaliseItems(
375
+ items: ExtractionDraft['items'],
376
+ allowed: Set<string>,
377
+ ): CommitmentDraft[] {
378
+ const out: CommitmentDraft[] = [];
379
+ for (const item of items) {
380
+ const documentId = String(item?.documentId ?? '').trim();
381
+ const title = String(item?.title ?? '').replace(/\s+/g, ' ').trim();
382
+ if (!allowed.has(documentId) || title.split(/\s+/).length < 3) continue;
383
+ const subtype = String(item.subtype ?? 'action').toLowerCase();
384
+ const status = String(item.status ?? 'open').toLowerCase() as CommitmentStatus;
385
+ const owner = String(item.owner ?? '').trim();
386
+ out.push({
387
+ documentId,
388
+ originKey: `model:${slug(title)}`,
389
+ subtype: SUBTYPES.has(subtype) ? subtype : 'action',
390
+ title,
391
+ detail: String(item.detail ?? '').trim(),
392
+ status: STATUSES.has(status) ? status : 'open',
393
+ ...(owner && owner.length <= 60 ? { ownerName: owner } : {}),
394
+ ...(item.subject ? { subject: String(item.subject).trim() } : {}),
395
+ ...(item.dueAt ? { dueAt: String(item.dueAt) } : {}),
396
+ quote: String(item.quote ?? '').trim(),
397
+ extractor: 'model',
398
+ });
399
+ }
400
+ return out;
401
+ }
402
+
403
+ function normaliseResolutions(
404
+ resolutions: NonNullable<ExtractionDraft['resolutions']>,
405
+ allowed: Set<string>,
406
+ ): ResolutionClaim[] {
407
+ const out: ResolutionClaim[] = [];
408
+ for (const claim of resolutions) {
409
+ const documentId = String(claim?.documentId ?? '').trim();
410
+ const statement = String(claim?.statement ?? '').trim();
411
+ if (!allowed.has(documentId) || !statement) continue;
412
+ out.push({
413
+ documentId,
414
+ ...(claim.subject ? { subject: String(claim.subject).trim() } : {}),
415
+ statement,
416
+ quote: String(claim.quote ?? '').trim(),
417
+ outcome: claim.outcome === 'dropped' ? 'dropped' : 'done',
418
+ });
419
+ }
420
+ return out;
421
+ }
422
+
423
+ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
424
+ void _why;
425
+ return {
426
+ documentsProcessed: 0,
427
+ fromRules: 0,
428
+ fromModel: 0,
429
+ created: 0,
430
+ merged: 0,
431
+ resolved: 0,
432
+ needsReview: [],
433
+ droppedOutOfScope: 0,
434
+ provider: extractor.describe(),
435
+ };
436
+ }
437
+
438
+ export { EXTRACTOR_VERSION };
@@ -43,6 +43,7 @@ import {
43
43
  toSourceDocument,
44
44
  } from './entities';
45
45
  import type { TenantEntity, TenantRelationship } from './entities';
46
+ import { splitSections } from './text';
46
47
  import type {
47
48
  CorpusApi,
48
49
  CorpusPage,
@@ -311,6 +312,116 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
311
312
  }),
312
313
  ];
313
314
 
315
+ /**
316
+ * `source_document` rows in the shape THE TRACKED CORPUS REALLY HAS
317
+ * (bd startsim-768w.38.25).
318
+ *
319
+ * These exist because the rows above, faithful as they are to the canonical
320
+ * attributes, are not faithful to the TEXT. On the live tandem tenant every one
321
+ * of the sixteen real records carries its whole content as MARKDOWN under
322
+ * `data.summary` — `##` headings, one per topic of the meeting, and a final
323
+ * `## Next Steps` block of bolded imperatives with the owner in parentheses.
324
+ * There is no `body`, there are no `sections`, and there is no transcript.
325
+ *
326
+ * That structure is not incidental: it is the input the rules pass reads, and a
327
+ * fixture that omitted it would let the extractor pass its tests against a shape
328
+ * production has never once produced — which is the exact failure bd
329
+ * startsim-768w.38.22 was filed for. The content below is invented; the SHAPE is
330
+ * copied from the wire.
331
+ *
332
+ * Three documents, arranged so the hard cases are present rather than assumed:
333
+ * • `mtg_a` and `mtg_b` both raise the SAME commitment in different words —
334
+ * that is the cross-meeting dedupe;
335
+ * • `mtg_c` states that one of them came back — that is the resolution;
336
+ * • two different matters carry near-identical wording, which is the merge
337
+ * that must NOT happen.
338
+ */
339
+ export const FIXTURE_TENANT_TRACKED: TenantEntity[] = [
340
+ row({
341
+ type: 'source_document', id: 'mtg_a', source: 'granola', vendorId: 'not_TrackA0001',
342
+ name: 'Matter review — 2148, 2198, billing numbers',
343
+ ownerSub: OWNER_A, createdAt: '2026-08-01T16:10:00Z',
344
+ data: {
345
+ subtype: 'meeting',
346
+ title: 'Matter review — 2148, 2198, billing numbers',
347
+ occurred_at: '2026-08-01T15:00:00Z',
348
+ participant_count: 3,
349
+ summary: [
350
+ '## Matter Status',
351
+ '',
352
+ '- **2148:** waiting on comments from the primary inventor before drafting continues',
353
+ '- **2198:** substantial feedback received in several rounds; nearly ready to return',
354
+ '',
355
+ '## Billing Numbers',
356
+ '',
357
+ '- Client asked for matter numbers on every report; no field exists for them today',
358
+ '- Proposal is a custom field plus a one-time backfill',
359
+ '',
360
+ '## Next Steps',
361
+ '',
362
+ '- **Chase the primary inventor for comments on 2148** (Rae)',
363
+ '',
364
+ ' Status in the system reads "comments received", which is misleading.',
365
+ '- **Add a billing number field and backfill it once** (Ola)',
366
+ '',
367
+ ' One-time manual entry, automated after that.',
368
+ ].join('\n'),
369
+ },
370
+ }),
371
+ row({
372
+ type: 'source_document', id: 'mtg_b', source: 'granola', vendorId: 'not_TrackB0002',
373
+ name: 'Weekly catch-up — outstanding items',
374
+ ownerSub: OWNER_A, createdAt: '2026-08-04T16:10:00Z',
375
+ data: {
376
+ subtype: 'meeting',
377
+ title: 'Weekly catch-up — outstanding items',
378
+ occurred_at: '2026-08-04T15:00:00Z',
379
+ participant_count: 2,
380
+ summary: [
381
+ '## Open Threads',
382
+ '',
383
+ '- 2148 is still blocked on the inventor; nothing has come back yet',
384
+ '- The parking lot quote arrived with a phased payment breakdown',
385
+ '',
386
+ '## Next Steps',
387
+ '',
388
+ // Same commitment as mtg_a's first bullet, in different words. The
389
+ // dedupe has to see through the rewording or the list doubles.
390
+ '- **Comments on 2148 still need chasing with the inventor** (Rae)',
391
+ '',
392
+ ' Second week running.',
393
+ // Same VERB, different matter. The dedupe must NOT collapse these.
394
+ '- **Chase the primary inventor for comments on 2198**',
395
+ '',
396
+ ' Different matter, same person.',
397
+ ].join('\n'),
398
+ },
399
+ }),
400
+ row({
401
+ type: 'source_document', id: 'mtg_c', source: 'granola', vendorId: 'not_TrackC0003',
402
+ name: 'Matter review — 2148 back from the inventor',
403
+ ownerSub: OWNER_A, createdAt: '2026-08-07T16:10:00Z',
404
+ data: {
405
+ subtype: 'meeting',
406
+ title: 'Matter review — 2148 back from the inventor',
407
+ occurred_at: '2026-08-07T15:00:00Z',
408
+ participant_count: 3,
409
+ summary: [
410
+ '## Matter Status',
411
+ '',
412
+ '- 2148: comments came back from the primary inventor, drafting resumes this week',
413
+ '- 2198: no change',
414
+ '',
415
+ '## Next Steps',
416
+ '',
417
+ '- **Resume drafting on 2148 now that comments are in** (Rae)',
418
+ '',
419
+ ' Target a first pass by the end of the week.',
420
+ ].join('\n'),
421
+ },
422
+ }),
423
+ ];
424
+
314
425
  /** `money_event` rows — amounts in MINOR units, under the declared attribute. */
315
426
  export const FIXTURE_TENANT_MONEY_EVENTS: TenantEntity[] = [
316
427
  row({
@@ -398,6 +509,12 @@ export const FIXTURE_DOCUMENTS: SourceDocument[] = FIXTURE_TENANT_DOCUMENTS.map(
398
509
  ),
399
510
  );
400
511
 
512
+ /** Derived through the SAME mapper, so the markdown arrives as `body` exactly the
513
+ * way `data.summary` does on the wire. */
514
+ export const FIXTURE_TRACKED_DOCUMENTS: SourceDocument[] = FIXTURE_TENANT_TRACKED.map((entity) =>
515
+ toSourceDocument(entity, FALLBACK_SCOPE, FIXTURE_SOURCE_LABELS, []),
516
+ );
517
+
401
518
  export const FIXTURE_MONEY_EVENTS: MoneyEvent[] = FIXTURE_TENANT_MONEY_EVENTS.map((entity) =>
402
519
  toMoneyEvent(
403
520
  entity,
@@ -698,6 +815,65 @@ export function createFixtureCorpusHttp(options: {
698
815
  * `live: false` from `describe()`, and every surface renders that, so a run
699
816
  * powered by this can never be mistaken on screen for a run powered by a model.
700
817
  */
818
+ /**
819
+ * A `CorpusExtractorLlm` that extracts WITHOUT a provider, deterministically.
820
+ *
821
+ * It stands in for the model's actual job — reading the parts of a document the
822
+ * summariser did not promote into its own Next Steps list — by taking the first
823
+ * substantial bullet under each non-outcome heading. That is not what a model
824
+ * does, and it is not meant to be: what it has to be is DETERMINISTIC and to
825
+ * echo the ids it was given, so the identity, dedupe, resolution and scope
826
+ * machinery can be driven end to end in vitest with no API key and no variance.
827
+ *
828
+ * It reports `live: false`, and every surface renders that, so a run powered by
829
+ * this can never be mistaken on screen for a run powered by a model.
830
+ */
831
+ export function createFixtureExtractor(): import('./types').CorpusExtractorLlm {
832
+ return {
833
+ describe: () => ({ provider: 'fixture', model: 'fixture-rules', live: false }),
834
+ async extract(input) {
835
+ const items: import('./types').ExtractionDraft['items'] = [];
836
+ const resolutions: NonNullable<import('./types').ExtractionDraft['resolutions']> = [];
837
+
838
+ for (const document of input.documents) {
839
+ const already = new Set(input.documents.flatMap((entry) => entry.alreadyFound));
840
+ for (const section of splitSections(document.text)) {
841
+ if (!section.heading || /next\s*steps?|action\s*items?|follow/i.test(section.heading)) continue;
842
+ const bullet = section.body
843
+ .split('\n')
844
+ .map((line) => line.replace(/^\s{0,3}[-*]\s+/, '').replace(/\*\*/g, '').trim())
845
+ .find((line) => line.split(/\s+/).length >= 5 && !already.has(line));
846
+ if (!bullet) continue;
847
+
848
+ // A statement that something has come back is a RESOLUTION, not a new
849
+ // item — which is the one distinction the model exists to make.
850
+ if (/came back|now (?:in|complete|done)|has landed/i.test(bullet)) {
851
+ resolutions.push({
852
+ documentId: document.id,
853
+ subject: /\b\d{4}(?:-\d{2})?\b/.exec(bullet)?.[0] ?? section.heading,
854
+ statement: bullet,
855
+ quote: bullet,
856
+ outcome: 'done',
857
+ });
858
+ continue;
859
+ }
860
+
861
+ items.push({
862
+ documentId: document.id,
863
+ subtype: 'decision',
864
+ title: bullet.slice(0, 120),
865
+ detail: '',
866
+ status: 'open',
867
+ subject: /\b\d{4}(?:-\d{2})?\b/.exec(bullet)?.[0] ?? section.heading,
868
+ quote: bullet,
869
+ });
870
+ }
871
+ }
872
+ return { items, resolutions };
873
+ },
874
+ };
875
+ }
876
+
701
877
  export function createFixtureLlm(): import('./types').CorpusLlm {
702
878
  return {
703
879
  describe: () => ({ provider: 'fixture', model: 'fixture-echo', live: false }),