@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.
- package/package.json +2 -2
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +206 -18
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +288 -5
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +602 -186
- package/src/corpus/index.ts +57 -1
- package/src/corpus/llm.ts +134 -2
- 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 };
|