@startsimpli/integrations 0.2.16 → 0.2.18

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.
@@ -27,7 +27,19 @@
27
27
  *
28
28
  * A PROVIDER OUTAGE COSTS THE EXTRAS, NOT THE LIST. Step 4 is wrapped: if the
29
29
  * model is unreachable the run still produces every commitment the vendor's own
30
- * structure carried, and says so in `fromModel: 0`.
30
+ * structure carried.
31
+ *
32
+ * AND IT SAYS SO, IN `modelFailure`, WHICH IS THE POINT. This used to be
33
+ * reported as `fromModel: 0` and nothing else, and `fromModel: 0` reads exactly
34
+ * like "the model found nothing to add". It did not: on the live tandem tenant
35
+ * the provider client's own sixty-second deadline was being blown on every slice
36
+ * of more than one document, so a rules-only pass was presented as a model pass
37
+ * for a week (bd startsim-768w.38.28). Degrading is fine. Degrading quietly is
38
+ * not, and every caller of this function now gets the reason.
39
+ *
40
+ * ONE CALL OF THIS FUNCTION IS ONE SLICE, NOT A SWEEP. A whole corpus does not
41
+ * fit in a request — one document is about twenty seconds — so the sweep is a
42
+ * durable, resumable, leased RUN in `./runs`, and this is the step it is made of.
31
43
  */
32
44
  import { EXTRACTOR_VERSION, applyResolutions, mergeDrafts, reconcile } from './commitments';
33
45
  import { enforceScope } from './retrieval';
@@ -41,6 +53,7 @@ import type {
41
53
  CorpusExtractorLlm,
42
54
  CorpusScope,
43
55
  ExtractionDraft,
56
+ ExtractionModelFailure,
44
57
  ExtractionResult,
45
58
  ResolutionClaim,
46
59
  SourceDocument,
@@ -442,6 +455,7 @@ export async function extractCommitments(
442
455
 
443
456
  // 4. ONE call, every document, ids echoed.
444
457
  let draft: ExtractionDraft = { items: [], resolutions: [] };
458
+ let modelFailure: ExtractionModelFailure | null = null;
445
459
  try {
446
460
  draft = await extractor.extract({
447
461
  documents: documents.map((document) => ({
@@ -454,9 +468,12 @@ export async function extractCommitments(
454
468
  alreadyFound: foundByDocument.get(document.id) ?? [],
455
469
  })),
456
470
  });
457
- } catch {
458
- // The deterministic half does not depend on the probabilistic half.
471
+ } catch (err) {
472
+ // The deterministic half does not depend on the probabilistic half — but the
473
+ // REPORT does depend on saying which half ran. Swallowing this into
474
+ // `fromModel: 0` is the bug that hid bd startsim-768w.38.28 for a week.
459
475
  draft = { items: [], resolutions: [] };
476
+ modelFailure = describeModelFailure('extract', err);
460
477
  }
461
478
 
462
479
  const proposed = normaliseItems(draft.items ?? [], allowedIds);
@@ -491,6 +508,7 @@ export async function extractCommitments(
491
508
 
492
509
  return {
493
510
  documentsProcessed: documents.length,
511
+ documentIds: documents.map((document) => document.id),
494
512
  totalDocuments: inScope.length,
495
513
  nextOffset,
496
514
  fromRules: ruleDrafts.length,
@@ -504,10 +522,48 @@ export async function extractCommitments(
504
522
  needsReview: resolutions.needsReview,
505
523
  droppedOutOfScope: dropped.length,
506
524
  provider: extractor.describe(),
525
+ // The PROPOSING call's failure is the more important one to report, because
526
+ // it is the one that empties the list; a verifier that did not answer is
527
+ // reported when the proposer did.
528
+ modelFailure: modelFailure ?? checked.failure,
507
529
  };
508
530
  void matched;
509
531
  }
510
532
 
533
+ /**
534
+ * Name the failure, and separate a blown deadline from everything else.
535
+ *
536
+ * The distinction is operational, not cosmetic: a timeout is fixed by giving the
537
+ * run more time or less text, and a 401 or a 429 is not. `@startsimpli/llm`
538
+ * raises `LLMProviderError` with `code: 'TIMEOUT'` when its own AbortController
539
+ * fires, so the code is read first and the message only as a fallback — a caller
540
+ * that wrapped the error keeps the sentence even when it loses the class.
541
+ */
542
+ function describeModelFailure(stage: 'extract' | 'verify', err: unknown): ExtractionModelFailure {
543
+ const raw = (err as { message?: string })?.message || '';
544
+ // A provider whose error body is not the shape the client expects arrives as
545
+ // the literal string "Unknown error", which is not a report — it is the same
546
+ // silence in a different font. The status code is the only thing that
547
+ // distinguishes a retired model (404) from a bad key (401) from a rate limit
548
+ // (429), so when the message says nothing the status says it instead. Found
549
+ // by this very field: a local base-URL override made every call 404, and the
550
+ // run said so within a second instead of reporting `fromModel: 0`.
551
+ const status = (err as { statusCode?: number; status?: number })?.statusCode
552
+ ?? (err as { status?: number })?.status;
553
+ const useless = !raw || /^unknown error$/i.test(raw);
554
+ const message = useless
555
+ ? `The model did not answer${status ? ` (HTTP ${status})` : ''}.`
556
+ : status && !raw.includes(String(status))
557
+ ? `${raw} (HTTP ${status})`
558
+ : raw;
559
+ const code = String((err as { code?: unknown })?.code ?? '');
560
+ const timedOut =
561
+ code === 'TIMEOUT' ||
562
+ (err as { name?: string })?.name === 'AbortError' ||
563
+ /timed?\s*out|timeout|aborted/i.test(message);
564
+ return { stage, message, timedOut };
565
+ }
566
+
511
567
  // ---------------------------------------------------------------------------
512
568
  // the verification stage
513
569
  // ---------------------------------------------------------------------------
@@ -579,6 +635,16 @@ interface VerifyOutcome {
579
635
  /** Grounded, but the verifier would not stand behind it. */
580
636
  unconfirmed: number;
581
637
  verified: boolean;
638
+ /**
639
+ * WHY the check did not happen, when it did not.
640
+ *
641
+ * `verified: false` already says a check did not happen, and it is the field
642
+ * the screen reads — but it cannot distinguish "this deployment has no
643
+ * verifier" from "the verifier timed out and every model candidate was
644
+ * dropped", and those are opposite instructions to whoever is looking at a
645
+ * suddenly shorter list.
646
+ */
647
+ failure: ExtractionModelFailure | null;
582
648
  }
583
649
 
584
650
  /**
@@ -602,7 +668,10 @@ async function verifyDrafts(
602
668
  context: VerifyContext,
603
669
  ): Promise<VerifyOutcome> {
604
670
  if (proposed.length === 0) {
605
- return { kept: [], ungrounded: 0, unconfirmed: 0, verified: Boolean(context.extractor.verify) };
671
+ return {
672
+ kept: [], ungrounded: 0, unconfirmed: 0,
673
+ verified: Boolean(context.extractor.verify), failure: null,
674
+ };
606
675
  }
607
676
 
608
677
  // Gate 1 — grounding. No model, no cost.
@@ -617,10 +686,10 @@ async function verifyDrafts(
617
686
  if (!verify) {
618
687
  // Nothing confirmed these. They are grounded, which is not nothing, and the
619
688
  // result says `verified: false` so the number is never read as more.
620
- return { kept: grounded, ungrounded, unconfirmed: 0, verified: false };
689
+ return { kept: grounded, ungrounded, unconfirmed: 0, verified: false, failure: null };
621
690
  }
622
691
  if (grounded.length === 0) {
623
- return { kept: [], ungrounded, unconfirmed: 0, verified: true };
692
+ return { kept: [], ungrounded, unconfirmed: 0, verified: true, failure: null };
624
693
  }
625
694
 
626
695
  // Gate 2 — ONE call over every survivor, ids echoed.
@@ -652,8 +721,11 @@ async function verifyDrafts(
652
721
  candidates,
653
722
  });
654
723
  verdicts = response?.verdicts ?? [];
655
- } catch {
656
- return { kept: [], ungrounded, unconfirmed: grounded.length, verified: false };
724
+ } catch (err) {
725
+ return {
726
+ kept: [], ungrounded, unconfirmed: grounded.length, verified: false,
727
+ failure: describeModelFailure('verify', err),
728
+ };
657
729
  }
658
730
 
659
731
  const confirmed = new Map<string, string>();
@@ -683,6 +755,7 @@ async function verifyDrafts(
683
755
  // the verifier is not assent.
684
756
  unconfirmed: grounded.length - kept.length,
685
757
  verified: true,
758
+ failure: null,
686
759
  };
687
760
  }
688
761
 
@@ -794,6 +867,7 @@ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
794
867
  void _why;
795
868
  return {
796
869
  documentsProcessed: 0,
870
+ documentIds: [],
797
871
  totalDocuments: 0,
798
872
  nextOffset: null,
799
873
  fromRules: 0,
@@ -807,6 +881,9 @@ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
807
881
  needsReview: [],
808
882
  droppedOutOfScope: 0,
809
883
  provider: extractor.describe(),
884
+ // Nothing was asked of the model, so nothing failed. `null` here and
885
+ // `fromModel: 0` together mean "no work", which is the truth.
886
+ modelFailure: null,
810
887
  };
811
888
  }
812
889
 
@@ -908,3 +908,37 @@ export function createFixtureLlm(): import('./types').CorpusLlm {
908
908
  },
909
909
  };
910
910
  }
911
+
912
+ /**
913
+ * An `ExtractionRunStore` in memory, with the tenant's own round-trip in the
914
+ * middle of it (bd startsim-768w.38.28).
915
+ *
916
+ * IT SERIALISES. The real store writes the record into `Entity.data` as JSON and
917
+ * reads it back through the same snake_case↔camelCase transform every other row
918
+ * goes through, so a fixture that handed the same object back would test nothing
919
+ * about the one thing that actually breaks: a field that does not survive
920
+ * storage. This one stringifies on write and parses on read, so a run whose
921
+ * cursor or totals do not round-trip fails here rather than on a live tenant
922
+ * halfway through a six-minute sweep.
923
+ */
924
+ export function createFixtureRunStore(
925
+ initial?: import('./types').ExtractionRun | null,
926
+ ): import('./types').ExtractionRunStore & { writes: number } {
927
+ let stored = initial ? JSON.stringify(initial) : null;
928
+ const store = {
929
+ writes: 0,
930
+ async readRun() {
931
+ return stored ? (JSON.parse(stored) as import('./types').ExtractionRun) : null;
932
+ },
933
+ async writeRun(run: import('./types').ExtractionRun) {
934
+ store.writes += 1;
935
+ // `adopted` is a fact about a request, not about the run, and it must not
936
+ // survive into the record — a stored `adopted: true` would make every
937
+ // later reader think a stalled sweep had just been taken over.
938
+ const { adopted: _adopted, ...durable } = run;
939
+ stored = JSON.stringify(durable);
940
+ return JSON.parse(stored) as import('./types').ExtractionRun;
941
+ },
942
+ };
943
+ return store;
944
+ }
@@ -19,8 +19,14 @@ export type {
19
19
  CommitmentTrackerApi,
20
20
  CorpusExtractorLlm,
21
21
  ExtractionDraft,
22
+ ExtractionModelFailure,
22
23
  ExtractionRequest,
23
24
  ExtractionResult,
25
+ ExtractionRun,
26
+ ExtractionRunStatus,
27
+ ExtractionRunStore,
28
+ ExtractionRunSummary,
29
+ ExtractionRunTotals,
24
30
  ResolutionClaim,
25
31
  AskRequest,
26
32
  AskResult,
@@ -46,9 +52,12 @@ export type {
46
52
 
47
53
  export {
48
54
  DEFAULT_PARTICIPANT_EDGES,
55
+ DEFAULT_RUN_KEY,
49
56
  DEFAULT_TYPE_KEYS,
50
57
  indexParticipants,
51
58
  commitmentToEntity,
59
+ extractionRunToEntity,
60
+ toExtractionRun,
52
61
  mergeParticipants,
53
62
  moneyEventAsDocument,
54
63
  toCommitment,
@@ -91,6 +100,25 @@ export {
91
100
  } from './extract';
92
101
  export type { ExtractCommitmentsOptions, ExtractDeps, ExtractRequest } from './extract';
93
102
 
103
+ // Extraction as a JOB — durable, leased, resumable (bd startsim-768w.38.28).
104
+ export {
105
+ EMPTY_TOTALS,
106
+ advanceExtractionRun,
107
+ defaultDriverId,
108
+ driveExtractionRun,
109
+ isLeaseLive,
110
+ isStalled,
111
+ newExtractionRun,
112
+ runCoverage,
113
+ runProgress,
114
+ startExtractionRun,
115
+ } from './runs';
116
+ export type {
117
+ ExtractionRunDeps,
118
+ ExtractionRunOptions,
119
+ StartExtractionRunRequest,
120
+ } from './runs';
121
+
94
122
  export {
95
123
  applyResolutions,
96
124
  commitmentIdentity,
@@ -117,6 +145,7 @@ export {
117
145
  createFixtureCorpusHttp,
118
146
  createFixtureExtractor,
119
147
  createFixtureLlm,
148
+ createFixtureRunStore,
120
149
  FIXTURE_DOCUMENTS,
121
150
  FIXTURE_MONEY_EVENTS,
122
151
  FIXTURE_ORG,
package/src/corpus/llm.ts CHANGED
@@ -43,10 +43,34 @@ export interface CorpusLlmOptions {
43
43
  model?: string;
44
44
  temperature?: number;
45
45
  maxTokens?: number;
46
+ /**
47
+ * Per-request deadline, in milliseconds.
48
+ *
49
+ * THE SECOND SIXTY-SECOND WALL, and for a week nobody knew it was there. The
50
+ * provider clients in `@startsimpli/llm` default to 60,000ms, and against
51
+ * claude-sonnet-5 over the real tandem corpus one document takes 19.6 seconds
52
+ * while two do not finish at all. Because `extract` is wrapped so a provider
53
+ * outage costs the extras and not the list, blowing it was SILENT.
54
+ *
55
+ * A QUEUED RUN CAN AFFORD MORE TIME THAN A REQUEST CAN. That is most of the
56
+ * point of putting extraction on a job: nothing downstream is waiting on a
57
+ * socket, so the deadline can be set by how long the work honestly takes
58
+ * rather than by what a gateway will tolerate. Passed per call so the same
59
+ * process can hold a short deadline for an interactive Ask and a long one for
60
+ * a background sweep; `LLM_TIMEOUT_MS` remains the deployment-wide override.
61
+ */
62
+ timeoutMs?: number;
46
63
  /** Injectable environment, so the configured-model lookup is testable. */
47
64
  env?: Record<string, string | undefined>;
48
65
  }
49
66
 
67
+ /** The deadline this call should carry, if any. */
68
+ function callOptions(options: CorpusLlmOptions): { timeout?: number } | undefined {
69
+ const env = options.env ?? (typeof process !== 'undefined' ? process.env : {});
70
+ const timeout = options.timeoutMs ?? (Number(env.LLM_TIMEOUT_MS) || undefined);
71
+ return timeout ? { timeout } : undefined;
72
+ }
73
+
50
74
  /**
51
75
  * The model this deployment CONFIGURED for a provider, from its own environment.
52
76
  *
@@ -176,6 +200,7 @@ export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
176
200
 
177
201
  let lastModel = options.model ?? '';
178
202
  const env = options.env ?? (typeof process !== 'undefined' ? process.env : {});
203
+ const call = callOptions(options);
179
204
 
180
205
  const describe = (): LlmDescription => {
181
206
  const available = service.getAvailableProviders();
@@ -206,7 +231,7 @@ export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
206
231
  const system = answerSystemPrompt(input.mode);
207
232
  const user = renderAnswerPrompt(input);
208
233
 
209
- const first = await service.chat(user, system);
234
+ const first = await service.chat(user, system, call);
210
235
  lastModel = first.model || lastModel;
211
236
  const parsed = parseAnswerDraft(first.content);
212
237
  if (parsed) return parsed;
@@ -222,6 +247,7 @@ export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
222
247
  first.content,
223
248
  ].join('\n'),
224
249
  system,
250
+ call,
225
251
  );
226
252
  lastModel = repair.model || lastModel;
227
253
  const repaired = parseAnswerDraft(repair.content);
@@ -317,6 +343,7 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
317
343
 
318
344
  let lastModel = options.model ?? '';
319
345
  const env = options.env ?? (typeof process !== 'undefined' ? process.env : {});
346
+ const call = callOptions(options);
320
347
 
321
348
  return {
322
349
  describe(): LlmDescription {
@@ -342,7 +369,7 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
342
369
  const system = extractSystemPrompt();
343
370
  const user = renderExtractPrompt(input);
344
371
 
345
- const first = await service.chat(user, system);
372
+ const first = await service.chat(user, system, call);
346
373
  lastModel = first.model || lastModel;
347
374
  const parsed = parseExtractionDraft(first.content);
348
375
  if (parsed) return parsed;
@@ -356,6 +383,7 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
356
383
  first.content,
357
384
  ].join('\n'),
358
385
  system,
386
+ call,
359
387
  );
360
388
  lastModel = repair.model || lastModel;
361
389
  return parseExtractionDraft(repair.content) ?? { items: [], resolutions: [] };
@@ -382,7 +410,7 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
382
410
  const system = verifySystemPrompt();
383
411
  const user = renderVerifyPrompt(input);
384
412
 
385
- const first = await service.chat(user, system);
413
+ const first = await service.chat(user, system, call);
386
414
  lastModel = first.model || lastModel;
387
415
  const parsed = parseVerificationDraft(first.content);
388
416
  if (parsed) return parsed;
@@ -396,6 +424,7 @@ export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExt
396
424
  first.content,
397
425
  ].join('\n'),
398
426
  system,
427
+ call,
399
428
  );
400
429
  lastModel = repair.model || lastModel;
401
430
  const repaired = parseVerificationDraft(repair.content);