@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.
- package/package.json +1 -1
- package/src/__tests__/corpus-llm-timeout.test.ts +91 -0
- package/src/__tests__/corpus-runs.test.ts +631 -0
- package/src/__tests__/corpus-tracker.test.tsx +254 -1
- package/src/components/pages/TrackerPage.tsx +326 -2
- package/src/corpus/client.ts +57 -1
- package/src/corpus/entities.ts +79 -0
- package/src/corpus/extract.ts +85 -8
- package/src/corpus/fixtures.ts +34 -0
- package/src/corpus/index.ts +29 -0
- package/src/corpus/llm.ts +32 -3
- package/src/corpus/runs.ts +563 -0
- package/src/corpus/types.ts +176 -2
package/src/corpus/extract.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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
|
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/corpus/index.ts
CHANGED
|
@@ -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);
|