@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.
- package/package.json +2 -2
- package/src/__tests__/connections.test.tsx +176 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/ConnectionsPage.tsx +229 -11
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/components/pages/types.ts +25 -0
- package/src/corpus/client.ts +85 -4
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +134 -0
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +176 -0
- package/src/corpus/index.ts +46 -0
- package/src/corpus/llm.ts +107 -1
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
|
@@ -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 };
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -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 }),
|