@startsimpli/integrations 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };