@startsimpli/integrations 0.2.13 → 0.2.16
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 +165 -0
- package/src/__tests__/corpus-ask.test.ts +19 -1
- package/src/__tests__/corpus-copy.test.tsx +4 -1
- package/src/__tests__/corpus-field-gate.test.ts +246 -0
- package/src/__tests__/corpus-pages.test.tsx +58 -2
- package/src/__tests__/corpus-tracker.test.tsx +43 -0
- package/src/__tests__/corpus-transcript.test.ts +476 -0
- package/src/components/CredentialSchemaForm.tsx +55 -8
- package/src/components/pages/AskPage.tsx +11 -0
- package/src/components/pages/ConnectionsPage.tsx +142 -5
- package/src/components/pages/LibraryPage.tsx +56 -3
- package/src/components/pages/TrackerPage.tsx +17 -0
- package/src/corpus/ask.ts +33 -8
- package/src/corpus/entities.ts +56 -2
- package/src/corpus/extract.ts +340 -15
- package/src/corpus/fixtures.ts +9 -1
- package/src/corpus/llm.ts +80 -1
- package/src/corpus/retrieval.ts +28 -4
- package/src/corpus/text.ts +76 -0
- package/src/corpus/types.ts +108 -2
package/src/corpus/extract.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { EXTRACTOR_VERSION, applyResolutions, mergeDrafts, reconcile } from './commitments';
|
|
33
33
|
import { enforceScope } from './retrieval';
|
|
34
|
-
import { outcomeSections } from './text';
|
|
34
|
+
import { documentText, outcomeSections, documentParts, words } from './text';
|
|
35
35
|
import type {
|
|
36
36
|
Commitment,
|
|
37
37
|
CommitmentApi,
|
|
@@ -44,8 +44,11 @@ import type {
|
|
|
44
44
|
ExtractionResult,
|
|
45
45
|
ResolutionClaim,
|
|
46
46
|
SourceDocument,
|
|
47
|
+
VerificationRequest,
|
|
47
48
|
} from './types';
|
|
48
49
|
|
|
50
|
+
export { documentText };
|
|
51
|
+
|
|
49
52
|
export interface ExtractCommitmentsOptions {
|
|
50
53
|
/** How many documents to read before extracting. */
|
|
51
54
|
documentLimit?: number;
|
|
@@ -53,12 +56,39 @@ export interface ExtractCommitmentsOptions {
|
|
|
53
56
|
maxPages?: number;
|
|
54
57
|
/** Characters of each document's text sent to the model. */
|
|
55
58
|
maxCharsPerDocument?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Characters of document text ONE call may carry, across every document in it.
|
|
61
|
+
*
|
|
62
|
+
* THE RIGHT AXIS, and `limit` is the wrong one. A summary is about 3,400
|
|
63
|
+
* characters and a forty-minute transcript is ten times that, so "four
|
|
64
|
+
* documents per call" means two completely different requests depending on
|
|
65
|
+
* whether `ingest_transcripts` happens to be on — and the tenant gateway closes
|
|
66
|
+
* a request at 60 seconds either way (bd startsim-768w.38.28). Budgeting the
|
|
67
|
+
* TEXT keeps one call one size whatever the corpus is made of, and the caller
|
|
68
|
+
* walks `nextOffset` for the rest.
|
|
69
|
+
*/
|
|
70
|
+
maxCharsPerRun?: number;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
const DEFAULTS: Required<ExtractCommitmentsOptions> = {
|
|
59
74
|
documentLimit: 200,
|
|
60
75
|
maxPages: 10,
|
|
61
76
|
maxCharsPerDocument: 12_000,
|
|
77
|
+
// MEASURED, not chosen. Against claude-sonnet-5 over the real tandem corpus:
|
|
78
|
+
// one document (3,101 prompt characters) takes 19.6 seconds; TWO documents
|
|
79
|
+
// (7,909 characters) do not finish inside the provider client's 60-second
|
|
80
|
+
// timeout. Not the gateway's 60 seconds — the LLM client's own, a second wall
|
|
81
|
+
// nobody had measured. And because `extract` is deliberately wrapped so a
|
|
82
|
+
// provider outage costs the extras and not the list, blowing through it is
|
|
83
|
+
// SILENT: the run returns every rules-derived commitment and `fromModel: 0`,
|
|
84
|
+
// which reads exactly like "the model found nothing to add".
|
|
85
|
+
//
|
|
86
|
+
// So the budget is one document's worth of text. A sixteen-document sweep is
|
|
87
|
+
// therefore sixteen calls of about twenty seconds, which does not fit in a
|
|
88
|
+
// request at all and is why bd startsim-768w.38.28 (extraction belongs on a
|
|
89
|
+
// queue) is a PREREQUISITE rather than an adjacent nicety. Transcripts make
|
|
90
|
+
// each of those calls larger again.
|
|
91
|
+
maxCharsPerRun: 6_000,
|
|
62
92
|
};
|
|
63
93
|
|
|
64
94
|
// ---------------------------------------------------------------------------
|
|
@@ -104,9 +134,11 @@ const ACRONYM = /^[A-Z][A-Z0-9.\s,&-]*$/;
|
|
|
104
134
|
* idempotent rather than merely usually idempotent.
|
|
105
135
|
*/
|
|
106
136
|
export function rulesExtract(document: SourceDocument): CommitmentDraft[] {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
137
|
+
// EVERY piece of the document, summary included and de-duplicated. The
|
|
138
|
+
// outcome block lives in the summary — and only in the summary, because a
|
|
139
|
+
// transcript is speech and nobody speaks a markdown heading — so a version of
|
|
140
|
+
// this that read `body` alone produced ZERO on the day transcripts landed.
|
|
141
|
+
const text = documentParts(document).join('\n\n');
|
|
110
142
|
|
|
111
143
|
const drafts: CommitmentDraft[] = [];
|
|
112
144
|
for (const section of outcomeSections(text)) {
|
|
@@ -189,8 +221,11 @@ export function extractSystemPrompt(): string {
|
|
|
189
221
|
return [
|
|
190
222
|
'You read meeting records for one company and turn them into trackable items.',
|
|
191
223
|
'You are given documents that ALREADY have some items extracted from them, listed under ALREADY_FOUND. Do NOT repeat those. Find what they missed.',
|
|
224
|
+
'A document may contain a curated summary AND the verbatim transcript of the same meeting. ALREADY_FOUND was taken from the summary, so the items worth reporting are the ones that were said out loud and never made it into the summary.',
|
|
192
225
|
'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
|
-
'
|
|
226
|
+
'People in conversation say things they are not committing to. "We should probably look at that", "maybe next quarter", thinking aloud, reading an agenda, or describing what somebody else already did are NOT items.',
|
|
227
|
+
'BUT A VAGUE OPENING FOLLOWED BY A DECISION IS AN ITEM. "Somebody has to cancel that seat" — "put it on Megan" is one commitment with an owner, not two hedges. So is "we need to ask the city about the sidewalk" when nobody argues and the group moves on. Read the exchange, not the sentence: what matters is whether the thing ends up settled, and who ends up holding it.',
|
|
228
|
+
'For every item, copy a short verbatim "quote" from the document it came from, and echo that document\'s exact id. The quote must appear in the document CHARACTER FOR CHARACTER — it is checked, and an item whose quote is not found is discarded.',
|
|
194
229
|
'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
230
|
'Set "owner" only to a name the document actually names. Leave it out otherwise.',
|
|
196
231
|
'Set "dueAt" only when the document states a date. Never estimate one.',
|
|
@@ -227,6 +262,80 @@ export function renderExtractPrompt(input: {
|
|
|
227
262
|
return [`DOCUMENTS (${input.documents.length}):`, blocks.join('\n\n')].join('\n');
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// the verification prompt
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* THE PRICE OF READING MORE TEXT.
|
|
271
|
+
*
|
|
272
|
+
* Measured on the summaries alone, adding the model pass took recall 56.1% →
|
|
273
|
+
* 65.9% and precision 95.8% → 67.5%. A transcript is roughly ten times the text
|
|
274
|
+
* and it is text of a different KIND — verbatim speech is full of sentences that
|
|
275
|
+
* have the grammar of a commitment and none of the substance. "I'll take a look
|
|
276
|
+
* at that", said and forgotten, is indistinguishable from a commitment to any
|
|
277
|
+
* extractor working sentence by sentence. So more text does not make the model
|
|
278
|
+
* pass more precise; it makes it less precise, and the recall it buys is only
|
|
279
|
+
* worth having if something else pays the precision back.
|
|
280
|
+
*
|
|
281
|
+
* This is that something else. A SECOND, CHEAPER, ADVERSARIAL pass whose only
|
|
282
|
+
* job is to say no: given the record and a candidate, is this a thing a named
|
|
283
|
+
* person actually took on, and where exactly does it say so. Anything it will
|
|
284
|
+
* not stand behind with a span from the record is dropped.
|
|
285
|
+
*
|
|
286
|
+
* It is deliberately asked as a REFUSAL task rather than a scoring one. "Rate
|
|
287
|
+
* your confidence 0-1" produces a number nobody can act on and a threshold
|
|
288
|
+
* nobody can defend; "quote the sentence where this was agreed, or say it was
|
|
289
|
+
* not" produces evidence, and evidence can be checked in code.
|
|
290
|
+
*/
|
|
291
|
+
export function verifySystemPrompt(): string {
|
|
292
|
+
return [
|
|
293
|
+
'You are checking a list of proposed commitments against the meeting records they were taken from. You are the last check before these appear on somebody\'s to-do list.',
|
|
294
|
+
'For each candidate, decide ONE thing: did a specific person, in this document, actually take on this specific thing?',
|
|
295
|
+
'Confirm it ONLY if you can copy the span of the document that says so. The span must appear in the document character for character.',
|
|
296
|
+
// MEASURED, and the obvious loosening is the wrong call. This line used to
|
|
297
|
+
// read "an unowned item can still be real — judge whether the document
|
|
298
|
+
// states it as settled or required, not whether it names a person", which is
|
|
299
|
+
// a perfectly good argument: the `need` subtype exists precisely for a
|
|
300
|
+
// stated requirement with nobody on it. Run it and the verifier keeps 56% of
|
|
301
|
+
// candidates instead of 37%, and the whole run scores 66.1% recall at 65.5%
|
|
302
|
+
// precision instead of 65.3% at 75.5%. One point of recall for ten points of
|
|
303
|
+
// precision. Requiring somebody to be on the hook is what makes this pass
|
|
304
|
+
// worth its own model call; without it the verifier agrees with the
|
|
305
|
+
// extractor, which is not a check.
|
|
306
|
+
'Refuse it if: nobody owns it; it is a suggestion, a hypothetical, a hedge ("we should", "maybe", "at some point"); it describes something already finished; it is somebody reading an agenda or restating a topic; it is a scheduling note like when the next call is; or you cannot find where the document says it.',
|
|
307
|
+
'Refusing a real commitment costs one missing row. Confirming an invented one puts work on a person who never agreed to it and makes the whole list untrustworthy. When you are unsure, refuse.',
|
|
308
|
+
'Echo every candidate id back exactly once.',
|
|
309
|
+
'Respond with JSON only: {"verdicts":[{"id":string,"confirmed":boolean,"quote":string,"reason":string}]}',
|
|
310
|
+
].join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** ONE payload carrying every candidate and the records they claim to come from. */
|
|
314
|
+
export function renderVerifyPrompt(input: VerificationRequest): string {
|
|
315
|
+
const documents = input.documents.map((document) =>
|
|
316
|
+
[`<document id="${document.id}">`, `title: ${document.title}`, '', document.text, '</document>'].join('\n'),
|
|
317
|
+
);
|
|
318
|
+
const candidates = input.candidates.map((candidate) =>
|
|
319
|
+
[
|
|
320
|
+
`<candidate id="${candidate.id}" document="${candidate.documentId}">`,
|
|
321
|
+
`kind: ${candidate.subtype}`,
|
|
322
|
+
`statement: ${candidate.title}`,
|
|
323
|
+
candidate.owner ? `claimed owner: ${candidate.owner}` : '',
|
|
324
|
+
`claimed evidence: ${candidate.quote}`,
|
|
325
|
+
'</candidate>',
|
|
326
|
+
]
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
.join('\n'),
|
|
329
|
+
);
|
|
330
|
+
return [
|
|
331
|
+
`DOCUMENTS (${input.documents.length}):`,
|
|
332
|
+
documents.join('\n\n'),
|
|
333
|
+
'',
|
|
334
|
+
`CANDIDATES (${input.candidates.length}):`,
|
|
335
|
+
candidates.join('\n'),
|
|
336
|
+
].join('\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
230
339
|
// ---------------------------------------------------------------------------
|
|
231
340
|
// the pipeline
|
|
232
341
|
// ---------------------------------------------------------------------------
|
|
@@ -258,7 +367,7 @@ export async function extractCommitments(
|
|
|
258
367
|
deps: ExtractDeps,
|
|
259
368
|
options: ExtractCommitmentsOptions = {},
|
|
260
369
|
): Promise<ExtractionResult> {
|
|
261
|
-
const { documentLimit, maxPages, maxCharsPerDocument } = { ...DEFAULTS, ...options };
|
|
370
|
+
const { documentLimit, maxPages, maxCharsPerDocument, maxCharsPerRun } = { ...DEFAULTS, ...options };
|
|
262
371
|
const { corpus, extractor } = deps;
|
|
263
372
|
|
|
264
373
|
// An empty scope grants nothing (`isInScope`). Refusing here rather than
|
|
@@ -277,7 +386,14 @@ export async function extractCommitments(
|
|
|
277
386
|
// (org_id, entity_type, -created_at) index), so an offset means the same thing
|
|
278
387
|
// between calls of one sweep.
|
|
279
388
|
const offset = Math.max(0, request.offset ?? 0);
|
|
280
|
-
const
|
|
389
|
+
const asked = request.limit ? inScope.slice(offset, offset + request.limit) : inScope.slice(offset);
|
|
390
|
+
// AND THEN CUT AGAIN, BY SIZE. Whatever the caller asked for, one call carries
|
|
391
|
+
// a bounded amount of text — otherwise a corpus that grew a transcript
|
|
392
|
+
// overnight turns yesterday's working slice into today's gateway timeout, and
|
|
393
|
+
// the only symptom is a 504 with no clue in it. Always at least one document,
|
|
394
|
+
// because a single record larger than the whole budget still has to be
|
|
395
|
+
// processed rather than skipped forever.
|
|
396
|
+
const documents = takeWithinBudget(asked, maxCharsPerDocument, maxCharsPerRun);
|
|
281
397
|
const consumed = offset + documents.length;
|
|
282
398
|
const nextOffset = consumed < inScope.length ? consumed : null;
|
|
283
399
|
|
|
@@ -289,6 +405,10 @@ export async function extractCommitments(
|
|
|
289
405
|
};
|
|
290
406
|
}
|
|
291
407
|
|
|
408
|
+
const promptText = new Map(
|
|
409
|
+
documents.map((document) => [document.id, documentText(document, maxCharsPerDocument)]),
|
|
410
|
+
);
|
|
411
|
+
|
|
292
412
|
const allowedIds = new Set(documents.map((document) => document.id));
|
|
293
413
|
|
|
294
414
|
// 3. rules first, so the model can be told what not to repeat.
|
|
@@ -330,7 +450,7 @@ export async function extractCommitments(
|
|
|
330
450
|
source: document.sourceLabel || document.source,
|
|
331
451
|
occurredAt: document.occurredAt,
|
|
332
452
|
participants: document.participants.map((party) => party.name),
|
|
333
|
-
text:
|
|
453
|
+
text: promptText.get(document.id) ?? '',
|
|
334
454
|
alreadyFound: foundByDocument.get(document.id) ?? [],
|
|
335
455
|
})),
|
|
336
456
|
});
|
|
@@ -339,9 +459,17 @@ export async function extractCommitments(
|
|
|
339
459
|
draft = { items: [], resolutions: [] };
|
|
340
460
|
}
|
|
341
461
|
|
|
342
|
-
const
|
|
462
|
+
const proposed = normaliseItems(draft.items ?? [], allowedIds);
|
|
343
463
|
const claims = normaliseResolutions(draft.resolutions ?? [], allowedIds);
|
|
344
464
|
|
|
465
|
+
// 4b. VERIFY the model's half. Not the rules half — see `verifyDrafts`.
|
|
466
|
+
const checked = await verifyDrafts(proposed, {
|
|
467
|
+
documents,
|
|
468
|
+
promptText,
|
|
469
|
+
extractor,
|
|
470
|
+
});
|
|
471
|
+
const modelDrafts = checked.kept;
|
|
472
|
+
|
|
345
473
|
// 5. identity, dedupe within the run, then against what is stored.
|
|
346
474
|
const groups = mergeDrafts([...ruleDrafts, ...modelDrafts]);
|
|
347
475
|
const reconciled = reconcile(existing, groups, { scope: request.scope, documents });
|
|
@@ -367,6 +495,9 @@ export async function extractCommitments(
|
|
|
367
495
|
nextOffset,
|
|
368
496
|
fromRules: ruleDrafts.length,
|
|
369
497
|
fromModel: modelDrafts.length,
|
|
498
|
+
ungrounded: checked.ungrounded,
|
|
499
|
+
unconfirmed: checked.unconfirmed,
|
|
500
|
+
verified: checked.verified,
|
|
370
501
|
created: reconciled.created,
|
|
371
502
|
merged: reconciled.merged,
|
|
372
503
|
resolved: resolutions.resolved,
|
|
@@ -377,6 +508,203 @@ export async function extractCommitments(
|
|
|
377
508
|
void matched;
|
|
378
509
|
}
|
|
379
510
|
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// the verification stage
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* How much of a quote has to be found in the record for it to count as grounded.
|
|
517
|
+
*
|
|
518
|
+
* NOT an exact substring match, and that is a considered choice. Models
|
|
519
|
+
* normalise as they copy — a curly apostrophe becomes straight, a line break
|
|
520
|
+
* becomes a space, a speaker label gets dropped — and failing an otherwise
|
|
521
|
+
* correct item over a character of whitespace would delete real commitments to
|
|
522
|
+
* enforce a rule about punctuation. So the comparison is over WORDS, and the bar
|
|
523
|
+
* is that essentially all of the quoted words appear, in the record, in order.
|
|
524
|
+
*/
|
|
525
|
+
const GROUNDING_THRESHOLD = 0.9;
|
|
526
|
+
|
|
527
|
+
/** The shortest quote worth checking. Below this, a match means nothing. */
|
|
528
|
+
const MIN_QUOTE_WORDS = 4;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* And at least this many of them have to carry meaning. A four-word quote of
|
|
532
|
+
* "and then we were" matches every document ever written.
|
|
533
|
+
*/
|
|
534
|
+
const MIN_CONTENT_WORDS = 2;
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Is this span really in this document?
|
|
538
|
+
*
|
|
539
|
+
* FREE, DETERMINISTIC, AND IT RUNS FIRST. An item whose quote does not appear in
|
|
540
|
+
* the record is the signature of an invented commitment, and catching it costs
|
|
541
|
+
* no tokens and no time. Doing it before the model call also makes the model
|
|
542
|
+
* call cheaper, because it never sees the candidates that are already disproved.
|
|
543
|
+
*/
|
|
544
|
+
export function isGrounded(quote: string, text: string): boolean {
|
|
545
|
+
// The LENGTH gate counts words as written; the MATCH is over content words.
|
|
546
|
+
// Counting only content words for both rejected "the number they gave us is
|
|
547
|
+
// high" — a perfectly real seven-word quote — because five of its words are
|
|
548
|
+
// stopwords. A quote is short or not short in the language it was said in.
|
|
549
|
+
const written = quote.trim().split(/\s+/).filter(Boolean);
|
|
550
|
+
if (written.length < MIN_QUOTE_WORDS) return false;
|
|
551
|
+
const needle = words(quote);
|
|
552
|
+
if (needle.length < MIN_CONTENT_WORDS) return false;
|
|
553
|
+
const haystack = words(text);
|
|
554
|
+
if (haystack.length === 0) return false;
|
|
555
|
+
|
|
556
|
+
// Walk the record once, in order, consuming quote words as they appear. Order
|
|
557
|
+
// matters: the same words in a different order are a different sentence.
|
|
558
|
+
let cursor = 0;
|
|
559
|
+
let found = 0;
|
|
560
|
+
for (const word of needle) {
|
|
561
|
+
const at = haystack.indexOf(word, cursor);
|
|
562
|
+
if (at === -1) continue;
|
|
563
|
+
cursor = at + 1;
|
|
564
|
+
found += 1;
|
|
565
|
+
}
|
|
566
|
+
return found / needle.length >= GROUNDING_THRESHOLD;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
interface VerifyContext {
|
|
570
|
+
documents: SourceDocument[];
|
|
571
|
+
promptText: Map<string, string>;
|
|
572
|
+
extractor: CorpusExtractorLlm;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
interface VerifyOutcome {
|
|
576
|
+
kept: CommitmentDraft[];
|
|
577
|
+
/** Quoted something that is not in the record. */
|
|
578
|
+
ungrounded: number;
|
|
579
|
+
/** Grounded, but the verifier would not stand behind it. */
|
|
580
|
+
unconfirmed: number;
|
|
581
|
+
verified: boolean;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Two gates over the MODEL's candidates, and none over the rules pass.
|
|
586
|
+
*
|
|
587
|
+
* THE RULES PASS IS NOT VERIFIED, deliberately. Its quote is the vendor's own
|
|
588
|
+
* bullet, copied by a regular expression — there is nothing for a model to
|
|
589
|
+
* confirm that the code did not already guarantee, and asking anyway would spend
|
|
590
|
+
* tokens for a chance to lose a correct item. Its measured precision is 95.8%
|
|
591
|
+
* with 100% owner accuracy; the way to keep those numbers is to leave it alone.
|
|
592
|
+
*
|
|
593
|
+
* FAIL-CLOSED. When verification is offered and the call fails, every model
|
|
594
|
+
* candidate is dropped and the run says `verified: false`. That is the same rule
|
|
595
|
+
* the extract call already follows one level down — the deterministic half does
|
|
596
|
+
* not depend on the probabilistic half — applied to the check rather than to the
|
|
597
|
+
* proposal. An unverified model item is exactly the row this whole stage exists
|
|
598
|
+
* to keep off somebody's list.
|
|
599
|
+
*/
|
|
600
|
+
async function verifyDrafts(
|
|
601
|
+
proposed: CommitmentDraft[],
|
|
602
|
+
context: VerifyContext,
|
|
603
|
+
): Promise<VerifyOutcome> {
|
|
604
|
+
if (proposed.length === 0) {
|
|
605
|
+
return { kept: [], ungrounded: 0, unconfirmed: 0, verified: Boolean(context.extractor.verify) };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Gate 1 — grounding. No model, no cost.
|
|
609
|
+
const grounded: CommitmentDraft[] = [];
|
|
610
|
+
let ungrounded = 0;
|
|
611
|
+
for (const draft of proposed) {
|
|
612
|
+
if (isGrounded(draft.quote, context.promptText.get(draft.documentId) ?? '')) grounded.push(draft);
|
|
613
|
+
else ungrounded += 1;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const verify = context.extractor.verify;
|
|
617
|
+
if (!verify) {
|
|
618
|
+
// Nothing confirmed these. They are grounded, which is not nothing, and the
|
|
619
|
+
// result says `verified: false` so the number is never read as more.
|
|
620
|
+
return { kept: grounded, ungrounded, unconfirmed: 0, verified: false };
|
|
621
|
+
}
|
|
622
|
+
if (grounded.length === 0) {
|
|
623
|
+
return { kept: [], ungrounded, unconfirmed: 0, verified: true };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Gate 2 — ONE call over every survivor, ids echoed.
|
|
627
|
+
const ids = new Map<string, CommitmentDraft>();
|
|
628
|
+
const candidates = grounded.map((draft, index) => {
|
|
629
|
+
const id = `c${index + 1}`;
|
|
630
|
+
ids.set(id, draft);
|
|
631
|
+
return {
|
|
632
|
+
id,
|
|
633
|
+
documentId: draft.documentId,
|
|
634
|
+
subtype: draft.subtype,
|
|
635
|
+
title: draft.title,
|
|
636
|
+
...(draft.ownerName ? { owner: draft.ownerName } : {}),
|
|
637
|
+
quote: draft.quote,
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const referenced = new Set(grounded.map((draft) => draft.documentId));
|
|
642
|
+
let verdicts: Array<{ id: string; confirmed: boolean; quote?: string }> = [];
|
|
643
|
+
try {
|
|
644
|
+
const response = await verify({
|
|
645
|
+
documents: context.documents
|
|
646
|
+
.filter((document) => referenced.has(document.id))
|
|
647
|
+
.map((document) => ({
|
|
648
|
+
id: document.id,
|
|
649
|
+
title: document.title,
|
|
650
|
+
text: context.promptText.get(document.id) ?? '',
|
|
651
|
+
})),
|
|
652
|
+
candidates,
|
|
653
|
+
});
|
|
654
|
+
verdicts = response?.verdicts ?? [];
|
|
655
|
+
} catch {
|
|
656
|
+
return { kept: [], ungrounded, unconfirmed: grounded.length, verified: false };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const confirmed = new Map<string, string>();
|
|
660
|
+
for (const verdict of verdicts) {
|
|
661
|
+
const id = String(verdict?.id ?? '');
|
|
662
|
+
if (!ids.has(id) || verdict?.confirmed !== true) continue;
|
|
663
|
+
confirmed.set(id, String(verdict.quote ?? '').trim());
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const kept: CommitmentDraft[] = [];
|
|
667
|
+
for (const [id, draft] of ids) {
|
|
668
|
+
if (!confirmed.has(id)) continue;
|
|
669
|
+
// A verdict may CORRECT the span, and a corrected span is checked against
|
|
670
|
+
// the record too — a verifier that invents its evidence is not a check.
|
|
671
|
+
const offered = confirmed.get(id) ?? '';
|
|
672
|
+
const quote =
|
|
673
|
+
offered && isGrounded(offered, context.promptText.get(draft.documentId) ?? '')
|
|
674
|
+
? offered
|
|
675
|
+
: draft.quote;
|
|
676
|
+
kept.push({ ...draft, quote });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
kept,
|
|
681
|
+
ungrounded,
|
|
682
|
+
// A candidate with NO verdict is unconfirmed, not confirmed. Silence from
|
|
683
|
+
// the verifier is not assent.
|
|
684
|
+
unconfirmed: grounded.length - kept.length,
|
|
685
|
+
verified: true,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* As many of `documents` as fit in one call's text budget, always at least one.
|
|
691
|
+
*/
|
|
692
|
+
function takeWithinBudget(
|
|
693
|
+
documents: SourceDocument[],
|
|
694
|
+
maxCharsPerDocument: number,
|
|
695
|
+
maxCharsPerRun: number,
|
|
696
|
+
): SourceDocument[] {
|
|
697
|
+
const taken: SourceDocument[] = [];
|
|
698
|
+
let spent = 0;
|
|
699
|
+
for (const document of documents) {
|
|
700
|
+
const size = Math.min(documentText(document, maxCharsPerDocument).length, maxCharsPerDocument);
|
|
701
|
+
if (taken.length > 0 && spent + size > maxCharsPerRun) break;
|
|
702
|
+
taken.push(document);
|
|
703
|
+
spent += size;
|
|
704
|
+
}
|
|
705
|
+
return taken;
|
|
706
|
+
}
|
|
707
|
+
|
|
380
708
|
/**
|
|
381
709
|
* Read the corpus, PAST THE FIRST PAGE.
|
|
382
710
|
*
|
|
@@ -402,12 +730,6 @@ async function readDocuments(
|
|
|
402
730
|
return { documents: documents.slice(0, limit), matched };
|
|
403
731
|
}
|
|
404
732
|
|
|
405
|
-
function documentText(document: SourceDocument, maxChars: number): string {
|
|
406
|
-
const parts = [document.body, ...document.sections.map((section) => `## ${section.label}\n${section.text}`)];
|
|
407
|
-
const joined = parts.filter(Boolean).join('\n\n');
|
|
408
|
-
return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
733
|
const SUBTYPES = new Set(['action', 'decision', 'need', 'risk', 'question']);
|
|
412
734
|
const STATUSES = new Set<CommitmentStatus>(['open', 'done', 'blocked', 'dropped']);
|
|
413
735
|
|
|
@@ -476,6 +798,9 @@ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
|
|
|
476
798
|
nextOffset: null,
|
|
477
799
|
fromRules: 0,
|
|
478
800
|
fromModel: 0,
|
|
801
|
+
ungrounded: 0,
|
|
802
|
+
unconfirmed: 0,
|
|
803
|
+
verified: Boolean(extractor.verify),
|
|
479
804
|
created: 0,
|
|
480
805
|
merged: 0,
|
|
481
806
|
resolved: 0,
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -210,7 +210,15 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
|
210
210
|
// A COUNT, and no names. This is the whole of what the document knows
|
|
211
211
|
// about who was there; everyone else is an edge.
|
|
212
212
|
participant_count: 4,
|
|
213
|
-
|
|
213
|
+
// THE ATTRIBUTE THE PIPELINE REALLY WRITES, and it used to be `transcript`
|
|
214
|
+
// here — which is why nothing caught the mapper collapsing `summary` into
|
|
215
|
+
// `body`. `granola.declare_canonical()` maps the `meeting_transcript`
|
|
216
|
+
// record onto the note's own document with `merge_into`, filling the
|
|
217
|
+
// canonical `body` attribute; the canonical schema declares `summary` and
|
|
218
|
+
// `body` as two fields for exactly this reason. A fixture that used a key
|
|
219
|
+
// the connector never emits is a fixture that proves the wrong thing —
|
|
220
|
+
// the whole lesson of bd startsim-768w.38.22, one field over.
|
|
221
|
+
body: GRANOLA_TRANSCRIPT,
|
|
214
222
|
},
|
|
215
223
|
}),
|
|
216
224
|
row({
|
package/src/corpus/llm.ts
CHANGED
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
import { extractJson, LLMService } from '@startsimpli/llm';
|
|
19
19
|
|
|
20
20
|
import { answerSystemPrompt, renderAnswerPrompt } from './ask';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
extractSystemPrompt,
|
|
23
|
+
renderExtractPrompt,
|
|
24
|
+
renderVerifyPrompt,
|
|
25
|
+
verifySystemPrompt,
|
|
26
|
+
} from './extract';
|
|
22
27
|
import type {
|
|
23
28
|
AnswerDraft,
|
|
24
29
|
AnswerRequest,
|
|
@@ -27,6 +32,8 @@ import type {
|
|
|
27
32
|
ExtractionDraft,
|
|
28
33
|
ExtractionRequest,
|
|
29
34
|
LlmDescription,
|
|
35
|
+
VerificationDraft,
|
|
36
|
+
VerificationRequest,
|
|
30
37
|
} from './types';
|
|
31
38
|
|
|
32
39
|
export interface CorpusLlmOptions {
|
|
@@ -256,6 +263,34 @@ export function parseExtractionDraft(raw: string): ExtractionDraft | null {
|
|
|
256
263
|
};
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Parse + shape-check the verifier's JSON.
|
|
268
|
+
*
|
|
269
|
+
* A MISSING VERDICT IS NOT A YES. Anything that is not an explicit `true`
|
|
270
|
+
* becomes `confirmed: false`, so a truncated response, a dropped id or a
|
|
271
|
+
* `"confirmed": "yes"` all fail closed. The caller drops what it is not told to
|
|
272
|
+
* keep, which means the failure modes of this parser all point at losing a real
|
|
273
|
+
* item rather than shipping an invented one.
|
|
274
|
+
*/
|
|
275
|
+
export function parseVerificationDraft(raw: string): VerificationDraft | null {
|
|
276
|
+
const extraction = extractJson(raw);
|
|
277
|
+
if (!extraction.success || !extraction.json || typeof extraction.json !== 'object') return null;
|
|
278
|
+
const json = extraction.json as Record<string, unknown>;
|
|
279
|
+
const verdicts = Array.isArray(json.verdicts) ? json.verdicts : null;
|
|
280
|
+
if (!verdicts) return null;
|
|
281
|
+
return {
|
|
282
|
+
verdicts: verdicts
|
|
283
|
+
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === 'object')
|
|
284
|
+
.map((entry) => ({
|
|
285
|
+
id: String(entry.id ?? '').trim(),
|
|
286
|
+
confirmed: entry.confirmed === true,
|
|
287
|
+
quote: typeof entry.quote === 'string' ? entry.quote : '',
|
|
288
|
+
reason: typeof entry.reason === 'string' ? entry.reason : '',
|
|
289
|
+
}))
|
|
290
|
+
.filter((verdict) => verdict.id !== ''),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
259
294
|
/**
|
|
260
295
|
* A `CorpusExtractorLlm` backed by `@startsimpli/llm`.
|
|
261
296
|
*
|
|
@@ -325,5 +360,49 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
|
|
|
325
360
|
lastModel = repair.model || lastModel;
|
|
326
361
|
return parseExtractionDraft(repair.content) ?? { items: [], resolutions: [] };
|
|
327
362
|
},
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The precision half. ONE call over every candidate, ids echoed, same shape
|
|
366
|
+
* as `extract` — including the single JSON-repair retry, which is a
|
|
367
|
+
* correction of one request rather than a loop over items.
|
|
368
|
+
*
|
|
369
|
+
* IT THROWS RATHER THAN RETURNING NOTHING when the provider is unreachable.
|
|
370
|
+
* An empty verdict list and a failed call mean opposite things to the
|
|
371
|
+
* caller: the first is "the verifier refused everything", the second is "no
|
|
372
|
+
* check happened". Collapsing them would let a provider outage read as a
|
|
373
|
+
* corpus with no real commitments in it.
|
|
374
|
+
*/
|
|
375
|
+
async verify(input: VerificationRequest): Promise<VerificationDraft> {
|
|
376
|
+
if (!service.isAvailable()) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
'No LLM provider is reachable. Configure ANTHROPIC_API_KEY for this tenant.',
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const system = verifySystemPrompt();
|
|
383
|
+
const user = renderVerifyPrompt(input);
|
|
384
|
+
|
|
385
|
+
const first = await service.chat(user, system);
|
|
386
|
+
lastModel = first.model || lastModel;
|
|
387
|
+
const parsed = parseVerificationDraft(first.content);
|
|
388
|
+
if (parsed) return parsed;
|
|
389
|
+
|
|
390
|
+
const repair = await service.chat(
|
|
391
|
+
[
|
|
392
|
+
'Your previous response was not valid JSON in the required shape.',
|
|
393
|
+
'Return ONLY the JSON object, with the key "verdicts".',
|
|
394
|
+
'',
|
|
395
|
+
'Your previous response was:',
|
|
396
|
+
first.content,
|
|
397
|
+
].join('\n'),
|
|
398
|
+
system,
|
|
399
|
+
);
|
|
400
|
+
lastModel = repair.model || lastModel;
|
|
401
|
+
const repaired = parseVerificationDraft(repair.content);
|
|
402
|
+
if (repaired) return repaired;
|
|
403
|
+
// Unparseable twice is not a set of refusals — it is a verifier that did
|
|
404
|
+
// not answer, and the caller must be able to tell the difference.
|
|
405
|
+
throw new Error('The verification pass did not return a usable result.');
|
|
406
|
+
},
|
|
328
407
|
};
|
|
329
408
|
}
|
package/src/corpus/retrieval.ts
CHANGED
|
@@ -97,8 +97,16 @@ export function enforceScope<T extends { scope: CorpusScope }>(
|
|
|
97
97
|
/** Half-life of the recency prior, in days. */
|
|
98
98
|
const RECENCY_HALF_LIFE_DAYS = 120;
|
|
99
99
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Fields are weighted because a title hit means more than a transcript hit.
|
|
102
|
+
*
|
|
103
|
+
* `summary` sits between the title and the full text on purpose. It is the one
|
|
104
|
+
* piece of a record somebody (or something) already decided was the point, so a
|
|
105
|
+
* term appearing there is better evidence than the same term appearing once in
|
|
106
|
+
* forty minutes of verbatim speech — and once transcripts are ingested, `body`
|
|
107
|
+
* is verbatim speech.
|
|
108
|
+
*/
|
|
109
|
+
const FIELD_WEIGHTS = { title: 3, participants: 2, summary: 1.5, body: 1, sections: 0.6 } as const;
|
|
102
110
|
|
|
103
111
|
function recencyWeight(occurredAt: string | null | undefined, now: number): number {
|
|
104
112
|
if (!occurredAt) return 0.5; // undated: neither fresh nor stale, just less certain
|
|
@@ -125,7 +133,13 @@ function countHits(tokens: string[], terms: Set<string>): { hits: number; matche
|
|
|
125
133
|
* WHY this record came back rather than making them open it to find out.
|
|
126
134
|
*/
|
|
127
135
|
export function snippetFor(document: SourceDocument, terms: Set<string>, length = 220): string {
|
|
128
|
-
|
|
136
|
+
// The summary is searched FIRST, so a citation card quotes the curated
|
|
137
|
+
// sentence rather than the moment somebody said the word out loud.
|
|
138
|
+
const haystacks = [
|
|
139
|
+
document.summary ?? '',
|
|
140
|
+
document.body,
|
|
141
|
+
...document.sections.map((section) => section.text),
|
|
142
|
+
];
|
|
129
143
|
for (const text of haystacks) {
|
|
130
144
|
if (!text) continue;
|
|
131
145
|
const lower = text.toLowerCase();
|
|
@@ -189,6 +203,12 @@ export function rankDocuments(
|
|
|
189
203
|
tokenize(document.participants.map((party) => `${party.name} ${party.email ?? ''}`).join(' ')),
|
|
190
204
|
terms,
|
|
191
205
|
);
|
|
206
|
+
// Scored SEPARATELY from `body`, and only when they are actually different
|
|
207
|
+
// strings — `body` falls back to the summary on a corpus that has never
|
|
208
|
+
// delivered a full text, and counting the same words twice there would
|
|
209
|
+
// silently re-weight every document on the tenant.
|
|
210
|
+
const summaryText = document.summary && document.summary !== document.body ? document.summary : '';
|
|
211
|
+
const summary = countHits(tokenize(summaryText), terms);
|
|
192
212
|
const body = countHits(tokenize(document.body), terms);
|
|
193
213
|
const sections = countHits(
|
|
194
214
|
tokenize(document.sections.map((section) => section.text).join(' ')),
|
|
@@ -200,13 +220,17 @@ export function rankDocuments(
|
|
|
200
220
|
const termScore =
|
|
201
221
|
FIELD_WEIGHTS.title * Math.log1p(title.hits) +
|
|
202
222
|
FIELD_WEIGHTS.participants * Math.log1p(participants.hits) +
|
|
223
|
+
FIELD_WEIGHTS.summary * Math.log1p(summary.hits) +
|
|
203
224
|
FIELD_WEIGHTS.body * Math.log1p(body.hits) +
|
|
204
225
|
FIELD_WEIGHTS.sections * Math.log1p(sections.hits);
|
|
205
226
|
|
|
206
227
|
if (termScore <= 0) continue;
|
|
207
228
|
|
|
208
229
|
const matched = [
|
|
209
|
-
...new Set([
|
|
230
|
+
...new Set([
|
|
231
|
+
...title.matched, ...participants.matched, ...summary.matched,
|
|
232
|
+
...body.matched, ...sections.matched,
|
|
233
|
+
]),
|
|
210
234
|
];
|
|
211
235
|
// Coverage rewards matching MORE OF the question rather than one term often.
|
|
212
236
|
const coverage = matched.length / terms.size;
|