@startsimpli/integrations 0.2.13 → 0.2.14

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.
@@ -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
- const text = [document.body, ...document.sections.map((section) => section.text)]
108
- .filter(Boolean)
109
- .join('\n\n');
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
- 'For every item, copy a short verbatim "quote" from the document it came from, and echo that document\'s exact id.',
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 documents = request.limit ? inScope.slice(offset, offset + request.limit) : inScope.slice(offset);
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: documentText(document, maxCharsPerDocument),
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 modelDrafts = normaliseItems(draft.items ?? [], allowedIds);
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,
@@ -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
- transcript: GRANOLA_TRANSCRIPT,
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 { extractSystemPrompt, renderExtractPrompt } from './extract';
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
  }
@@ -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
- /** Fields are weighted because a title hit means more than a transcript hit. */
101
- const FIELD_WEIGHTS = { title: 3, participants: 2, body: 1, sections: 0.6 } as const;
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
- const haystacks = [document.body, ...document.sections.map((section) => section.text)];
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([...title.matched, ...participants.matched, ...body.matched, ...sections.matched]),
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;