@startsimpli/integrations 0.2.14 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/corpus-field-gate.test.ts +246 -0
- package/src/__tests__/corpus-llm-timeout.test.ts +91 -0
- package/src/__tests__/corpus-pages.test.tsx +47 -0
- package/src/__tests__/corpus-runs.test.ts +631 -0
- package/src/__tests__/corpus-tracker.test.tsx +254 -1
- package/src/components/pages/AskPage.tsx +11 -0
- package/src/components/pages/LibraryPage.tsx +12 -0
- package/src/components/pages/TrackerPage.tsx +326 -2
- package/src/corpus/ask.ts +21 -0
- package/src/corpus/client.ts +57 -1
- package/src/corpus/entities.ts +117 -1
- 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 +204 -2
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXTRACTION AS A JOB, not as a request (bd startsim-768w.38.28).
|
|
3
|
+
*
|
|
4
|
+
* The measurement that forced this: on the live tandem tenant `POST
|
|
5
|
+
* /corpus/extract` returns 504 at EVERY slice size, `limit=1` included, because
|
|
6
|
+
* one document takes about twenty seconds against claude-sonnet-5 and there are
|
|
7
|
+
* two independent sixty-second walls in front of it — the tenant gateway's, and
|
|
8
|
+
* the provider client's own. Seventeen documents is roughly six minutes of model
|
|
9
|
+
* time. No slice size fits a browser request, so no synchronous design works.
|
|
10
|
+
*
|
|
11
|
+
* This file tests the four properties that make an asynchronous one worth having,
|
|
12
|
+
* in the order in which getting them wrong destroys the product:
|
|
13
|
+
*
|
|
14
|
+
* 1. RESUMPTION. A sweep interrupted at document nine keeps those nine and
|
|
15
|
+
* starts at ten. A "resumable" job that redoes finished work is a job that
|
|
16
|
+
* never finishes on a corpus large enough to need one.
|
|
17
|
+
* 2. IDEMPOTENCE UNDER RETRY. A queue retries. A retry that duplicates is
|
|
18
|
+
* worse than a queue that gives up — and this exact failure is on the
|
|
19
|
+
* record: a second live run created sixteen duplicates (82 → 98) because
|
|
20
|
+
* the model reworded its own subject.
|
|
21
|
+
* 3. LEGIBLE FAILURE. A run whose model call timed out has to SAY so. The old
|
|
22
|
+
* code swallowed it and reported `fromModel: 0`, which reads exactly like
|
|
23
|
+
* "the model found nothing to add" — that lie is what hid this bug for a
|
|
24
|
+
* week, so killing it is part of the fix, not a nicety.
|
|
25
|
+
* 4. ONE DRIVER. Two loops over the same cursor race, and two model calls over
|
|
26
|
+
* the same document produce two differently-worded items that dedupe cannot
|
|
27
|
+
* see. The lease is a correctness mechanism, not an optimisation.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
advanceExtractionRun,
|
|
33
|
+
driveExtractionRun,
|
|
34
|
+
isLeaseLive,
|
|
35
|
+
isStalled,
|
|
36
|
+
newExtractionRun,
|
|
37
|
+
runProgress,
|
|
38
|
+
startExtractionRun,
|
|
39
|
+
} from '../corpus/runs';
|
|
40
|
+
import {
|
|
41
|
+
createFixtureCorpusApi,
|
|
42
|
+
createFixtureExtractor,
|
|
43
|
+
createFixtureRunStore,
|
|
44
|
+
FIXTURE_ORG,
|
|
45
|
+
FIXTURE_TRACKED_DOCUMENTS,
|
|
46
|
+
} from '../corpus/fixtures';
|
|
47
|
+
import { extractCommitments } from '../corpus/extract';
|
|
48
|
+
import type { Commitment, CommitmentApi, CorpusApi, CorpusExtractorLlm } from '../corpus/types';
|
|
49
|
+
|
|
50
|
+
const TANDEM = { orgId: FIXTURE_ORG };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A corpus that stores what it is given, so a sweep's effect on the workspace is
|
|
54
|
+
* observable — which is the only way "run it twice and the count holds" can be
|
|
55
|
+
* asserted rather than asserted about.
|
|
56
|
+
*/
|
|
57
|
+
function trackingCorpus(options: { documents?: typeof FIXTURE_TRACKED_DOCUMENTS } = {}) {
|
|
58
|
+
const corpus = createFixtureCorpusApi({
|
|
59
|
+
scope: TANDEM,
|
|
60
|
+
documents: options.documents ?? FIXTURE_TRACKED_DOCUMENTS,
|
|
61
|
+
}) as CorpusApi & CommitmentApi;
|
|
62
|
+
const tracked = new Map<string, Commitment>();
|
|
63
|
+
|
|
64
|
+
corpus.listCommitments = async () => ({
|
|
65
|
+
count: tracked.size,
|
|
66
|
+
next: null,
|
|
67
|
+
previous: null,
|
|
68
|
+
results: [...tracked.values()],
|
|
69
|
+
});
|
|
70
|
+
corpus.saveCommitments = async (items: Commitment[]) => {
|
|
71
|
+
for (const item of items) tracked.set(item.externalId, item);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return { corpus, tracked };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A model that proposes the SAME thing every time, whatever it is told is
|
|
79
|
+
* already found.
|
|
80
|
+
*
|
|
81
|
+
* The stock fixture extractor deliberately skips anything in ALREADY_FOUND and
|
|
82
|
+
* reaches for the next bullet instead, which makes a second sweep DISCOVER more
|
|
83
|
+
* — legitimate behaviour, and useless for testing duplication, because a growing
|
|
84
|
+
* count is then the correct answer. These two extractors hold the findings fixed
|
|
85
|
+
* so that any growth at all is a duplicate.
|
|
86
|
+
*/
|
|
87
|
+
function stableExtractor(): CorpusExtractorLlm {
|
|
88
|
+
return {
|
|
89
|
+
describe: () => ({ provider: 'fixture', model: 'stable', live: false }),
|
|
90
|
+
async extract(input) {
|
|
91
|
+
return {
|
|
92
|
+
items: input.documents.map((document) => ({
|
|
93
|
+
documentId: document.id,
|
|
94
|
+
subtype: 'action',
|
|
95
|
+
title: 'Send the revised schedule to the client',
|
|
96
|
+
detail: '',
|
|
97
|
+
status: 'open',
|
|
98
|
+
subject: 'schedule',
|
|
99
|
+
quote: firstSentence(document.text),
|
|
100
|
+
})),
|
|
101
|
+
resolutions: [],
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* THE PRODUCTION FAILURE MODE, reproduced.
|
|
109
|
+
*
|
|
110
|
+
* On the live tandem tenant a second run created sixteen duplicates — 82 rows
|
|
111
|
+
* became 98 — for one reason: the model reworded its own subject, so the item it
|
|
112
|
+
* had already written did not resolve to the same identity. This extractor does
|
|
113
|
+
* exactly that, and the count still has to hold.
|
|
114
|
+
*/
|
|
115
|
+
function rewordingExtractor(): CorpusExtractorLlm {
|
|
116
|
+
return {
|
|
117
|
+
describe: () => ({ provider: 'fixture', model: 'rewording', live: false }),
|
|
118
|
+
async extract(input) {
|
|
119
|
+
return {
|
|
120
|
+
items: input.documents.map((document) => ({
|
|
121
|
+
documentId: document.id,
|
|
122
|
+
subtype: 'action',
|
|
123
|
+
title: 'Send the client the revised scheduling document',
|
|
124
|
+
detail: '',
|
|
125
|
+
status: 'open',
|
|
126
|
+
subject: 'scheduling',
|
|
127
|
+
quote: firstSentence(document.text),
|
|
128
|
+
})),
|
|
129
|
+
resolutions: [],
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function firstSentence(text: string): string {
|
|
136
|
+
const line = text.split('\n').map((row) => row.replace(/^[#\-*\s]+/, '').trim())
|
|
137
|
+
.find((row) => row.split(/\s+/).length >= 6);
|
|
138
|
+
return line ?? text.slice(0, 120);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** An extractor whose model half fails the way a blown timeout really fails. */
|
|
142
|
+
function timingOutExtractor(): CorpusExtractorLlm {
|
|
143
|
+
const base = createFixtureExtractor();
|
|
144
|
+
return {
|
|
145
|
+
describe: base.describe,
|
|
146
|
+
extract: async () => {
|
|
147
|
+
throw Object.assign(new Error('Request timed out after 60000ms'), {
|
|
148
|
+
name: 'LLMProviderError',
|
|
149
|
+
code: 'TIMEOUT',
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// 1. resumption
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe('a sweep is resumable, so partial progress is real progress', () => {
|
|
160
|
+
it('starts queued at nothing, and does no model work in the call that starts it', async () => {
|
|
161
|
+
const { corpus } = trackingCorpus();
|
|
162
|
+
const store = createFixtureRunStore();
|
|
163
|
+
const extractor = createFixtureExtractor();
|
|
164
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
165
|
+
|
|
166
|
+
const run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
167
|
+
|
|
168
|
+
expect(run.status).toBe('queued');
|
|
169
|
+
expect(run.cursor).toBe(0);
|
|
170
|
+
// THE WHOLE POINT. Starting a run must return before a single 20-second
|
|
171
|
+
// model call, or the gateway kills the thing that was supposed to survive it.
|
|
172
|
+
expect(spy).not.toHaveBeenCalled();
|
|
173
|
+
// And it is durable the moment it exists, so a poll from another replica —
|
|
174
|
+
// or after this process dies — can see it.
|
|
175
|
+
expect((await store.readRun())?.runId).toBe(run.runId);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('advances the cursor one slice at a time and persists it', async () => {
|
|
179
|
+
const { corpus } = trackingCorpus();
|
|
180
|
+
const store = createFixtureRunStore();
|
|
181
|
+
const extractor = createFixtureExtractor();
|
|
182
|
+
|
|
183
|
+
const started = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
184
|
+
const after = await advanceExtractionRun(started, { corpus, extractor, runs: store }, {
|
|
185
|
+
documentsPerSlice: 1,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(after.cursor).toBe(1);
|
|
189
|
+
expect(after.totals.documentsProcessed).toBe(1);
|
|
190
|
+
expect(after.status).toBe('running');
|
|
191
|
+
expect((await store.readRun())?.cursor).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('resumes from where it stopped instead of starting over', async () => {
|
|
195
|
+
const { corpus } = trackingCorpus();
|
|
196
|
+
const store = createFixtureRunStore();
|
|
197
|
+
const extractor = createFixtureExtractor();
|
|
198
|
+
|
|
199
|
+
// Two documents' worth of progress, then the process "dies".
|
|
200
|
+
let run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
201
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
202
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
203
|
+
const processed = [...run.processedIds];
|
|
204
|
+
expect(processed).toHaveLength(2);
|
|
205
|
+
|
|
206
|
+
// The lease dies with the process.
|
|
207
|
+
await store.writeRun({ ...run, leaseUntil: new Date(Date.now() - 60_000).toISOString() });
|
|
208
|
+
|
|
209
|
+
const resumed = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
210
|
+
expect(resumed.runId).toBe(run.runId); // the SAME sweep, not a new one
|
|
211
|
+
expect(resumed.cursor).toBe(2);
|
|
212
|
+
|
|
213
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
214
|
+
await advanceExtractionRun(resumed, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
215
|
+
const sent = spy.mock.calls[0][0].documents.map((document) => document.id);
|
|
216
|
+
// Not one of the two already done. Redoing them would cost twenty seconds
|
|
217
|
+
// each for a guaranteed no-op.
|
|
218
|
+
expect(sent.some((id) => processed.includes(id))).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('reports progress as a fraction of the corpus, so a spinner never has to lie', async () => {
|
|
222
|
+
const { corpus } = trackingCorpus();
|
|
223
|
+
const store = createFixtureRunStore();
|
|
224
|
+
const extractor = createFixtureExtractor();
|
|
225
|
+
|
|
226
|
+
let run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
227
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
228
|
+
|
|
229
|
+
const progress = runProgress(run);
|
|
230
|
+
expect(progress.total).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
231
|
+
expect(progress.done).toBe(1);
|
|
232
|
+
expect(progress.percent).toBeGreaterThan(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('finishes the corpus and says so', async () => {
|
|
236
|
+
const { corpus } = trackingCorpus();
|
|
237
|
+
const store = createFixtureRunStore();
|
|
238
|
+
const extractor = createFixtureExtractor();
|
|
239
|
+
|
|
240
|
+
const started = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
241
|
+
const done = await driveExtractionRun(started, { corpus, extractor, runs: store }, {
|
|
242
|
+
documentsPerSlice: 1,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(done.status).toBe('succeeded');
|
|
246
|
+
expect(done.cursor).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
247
|
+
expect(done.totals.documentsProcessed).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
248
|
+
expect(done.finishedAt).toBeTruthy();
|
|
249
|
+
expect(done.leaseUntil).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 2. idempotence under retry
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe('a queue retries, and a retry must not duplicate', () => {
|
|
258
|
+
it('a full second sweep leaves the workspace exactly as the first did', async () => {
|
|
259
|
+
const { corpus, tracked } = trackingCorpus();
|
|
260
|
+
const store = createFixtureRunStore();
|
|
261
|
+
const extractor = stableExtractor();
|
|
262
|
+
|
|
263
|
+
const first = await driveExtractionRun(
|
|
264
|
+
await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store }),
|
|
265
|
+
{ corpus, extractor, runs: store },
|
|
266
|
+
{ documentsPerSlice: 1 },
|
|
267
|
+
);
|
|
268
|
+
const afterFirst = tracked.size;
|
|
269
|
+
expect(afterFirst).toBeGreaterThan(0);
|
|
270
|
+
expect(first.totals.created).toBe(afterFirst);
|
|
271
|
+
|
|
272
|
+
const second = await driveExtractionRun(
|
|
273
|
+
await startExtractionRun({ scope: TANDEM, restart: true }, { corpus, extractor, runs: store }),
|
|
274
|
+
{ corpus, extractor, runs: store },
|
|
275
|
+
{ documentsPerSlice: 1 },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// THE NUMBER THAT MATTERS. 82 → 98 is what happened on the live tenant the
|
|
279
|
+
// last time this was got wrong.
|
|
280
|
+
expect(tracked.size).toBe(afterFirst);
|
|
281
|
+
expect(second.totals.created).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('holds the count even when the model rewords its own findings between sweeps', async () => {
|
|
285
|
+
const { corpus, tracked } = trackingCorpus();
|
|
286
|
+
const store = createFixtureRunStore();
|
|
287
|
+
|
|
288
|
+
await driveExtractionRun(
|
|
289
|
+
await startExtractionRun(
|
|
290
|
+
{ scope: TANDEM },
|
|
291
|
+
{ corpus, extractor: stableExtractor(), runs: store },
|
|
292
|
+
),
|
|
293
|
+
{ corpus, extractor: stableExtractor(), runs: store },
|
|
294
|
+
{ documentsPerSlice: 1 },
|
|
295
|
+
);
|
|
296
|
+
const afterFirst = tracked.size;
|
|
297
|
+
|
|
298
|
+
const second = await driveExtractionRun(
|
|
299
|
+
await startExtractionRun(
|
|
300
|
+
{ scope: TANDEM, restart: true },
|
|
301
|
+
{ corpus, extractor: rewordingExtractor(), runs: store },
|
|
302
|
+
),
|
|
303
|
+
{ corpus, extractor: rewordingExtractor(), runs: store },
|
|
304
|
+
{ documentsPerSlice: 1 },
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(tracked.size).toBe(afterFirst);
|
|
308
|
+
expect(second.totals.created).toBe(0);
|
|
309
|
+
// It did not just refuse the reworded item — it recognised it.
|
|
310
|
+
expect(second.totals.merged).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('re-running an already-processed slice creates nothing', async () => {
|
|
314
|
+
const { corpus, tracked } = trackingCorpus();
|
|
315
|
+
const store = createFixtureRunStore();
|
|
316
|
+
const extractor = createFixtureExtractor();
|
|
317
|
+
|
|
318
|
+
const started = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
319
|
+
const once = await advanceExtractionRun(started, { corpus, extractor, runs: store }, {
|
|
320
|
+
documentsPerSlice: 1,
|
|
321
|
+
});
|
|
322
|
+
const afterOne = tracked.size;
|
|
323
|
+
|
|
324
|
+
// The same slice again — what a retry after a lost acknowledgement looks like.
|
|
325
|
+
const again = await advanceExtractionRun(
|
|
326
|
+
{ ...once, cursor: 0 },
|
|
327
|
+
{ corpus, extractor, runs: store },
|
|
328
|
+
{ documentsPerSlice: 1 },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(tracked.size).toBe(afterOne);
|
|
332
|
+
expect(again.totals.created).toBe(once.totals.created);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('a slice that throws keeps the cursor, so the retry does not restart the sweep', async () => {
|
|
336
|
+
const { corpus } = trackingCorpus();
|
|
337
|
+
const store = createFixtureRunStore();
|
|
338
|
+
const extractor = createFixtureExtractor();
|
|
339
|
+
|
|
340
|
+
let run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
341
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
342
|
+
expect(run.cursor).toBe(1);
|
|
343
|
+
|
|
344
|
+
const exploding = {
|
|
345
|
+
...corpus,
|
|
346
|
+
listDocuments: async () => {
|
|
347
|
+
throw Object.assign(new Error('the workspace is unreachable'), { status: 502 });
|
|
348
|
+
},
|
|
349
|
+
} as CorpusApi & CommitmentApi;
|
|
350
|
+
|
|
351
|
+
const failed = await driveExtractionRun(run, { corpus: exploding, extractor, runs: store }, {
|
|
352
|
+
documentsPerSlice: 1,
|
|
353
|
+
maxSliceAttempts: 2,
|
|
354
|
+
retryDelayMs: 0,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(failed.status).toBe('failed');
|
|
358
|
+
expect(failed.error).toContain('unreachable');
|
|
359
|
+
// Everything already done is still done.
|
|
360
|
+
expect(failed.cursor).toBe(1);
|
|
361
|
+
expect((await store.readRun())?.cursor).toBe(1);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// 3. legible failure
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
describe('a run that the model did not answer says so', () => {
|
|
370
|
+
it('reports the timeout instead of reporting zero findings', async () => {
|
|
371
|
+
const { corpus } = trackingCorpus();
|
|
372
|
+
const result = await extractCommitments(
|
|
373
|
+
{ scope: TANDEM, limit: 1 },
|
|
374
|
+
{ corpus, extractor: timingOutExtractor() },
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// The old behaviour: fromModel 0 and nothing else, which reads as "the model
|
|
378
|
+
// found nothing to add" and is how a rules-only run got reported as a model
|
|
379
|
+
// run for a week.
|
|
380
|
+
expect(result.fromModel).toBe(0);
|
|
381
|
+
expect(result.modelFailure).not.toBeNull();
|
|
382
|
+
expect(result.modelFailure?.stage).toBe('extract');
|
|
383
|
+
expect(result.modelFailure?.timedOut).toBe(true);
|
|
384
|
+
expect(result.modelFailure?.message).toMatch(/timed out/i);
|
|
385
|
+
// The deterministic half still landed — a provider outage costs the extras.
|
|
386
|
+
expect(result.fromRules).toBeGreaterThan(0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('distinguishes a verifier that did not answer from a verifier that refused', async () => {
|
|
390
|
+
const { corpus } = trackingCorpus();
|
|
391
|
+
const base = createFixtureExtractor();
|
|
392
|
+
const result = await extractCommitments(
|
|
393
|
+
{ scope: TANDEM },
|
|
394
|
+
{
|
|
395
|
+
corpus,
|
|
396
|
+
extractor: {
|
|
397
|
+
describe: base.describe,
|
|
398
|
+
extract: base.extract,
|
|
399
|
+
verify: async () => {
|
|
400
|
+
throw Object.assign(new Error('Request timed out after 60000ms'), { code: 'TIMEOUT' });
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
// Enough budget that the slice really carries candidates for the verifier
|
|
405
|
+
// to be asked about — with none, no check is NEEDED and none failing is
|
|
406
|
+
// the honest answer.
|
|
407
|
+
{ maxCharsPerRun: 200_000 },
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(result.verified).toBe(false);
|
|
411
|
+
expect(result.modelFailure?.stage).toBe('verify');
|
|
412
|
+
expect(result.modelFailure?.timedOut).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('says which meetings got only the deterministic pass', async () => {
|
|
416
|
+
const { corpus } = trackingCorpus();
|
|
417
|
+
const store = createFixtureRunStore();
|
|
418
|
+
|
|
419
|
+
const run = await driveExtractionRun(
|
|
420
|
+
await startExtractionRun(
|
|
421
|
+
{ scope: TANDEM },
|
|
422
|
+
{ corpus, extractor: timingOutExtractor(), runs: store },
|
|
423
|
+
),
|
|
424
|
+
{ corpus, extractor: timingOutExtractor(), runs: store },
|
|
425
|
+
{ documentsPerSlice: 1 },
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
expect(run.status).toBe('succeeded');
|
|
429
|
+
expect(run.totals.modelFailures).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
430
|
+
expect(run.totals.modelCalls).toBe(0);
|
|
431
|
+
expect(run.lastModelFailure?.timedOut).toBe(true);
|
|
432
|
+
// Named, so a later run can be pointed at them.
|
|
433
|
+
expect(run.modelIncompleteIds.length).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
434
|
+
// And the rules half still ran on every one of them.
|
|
435
|
+
expect(run.totals.fromRules).toBeGreaterThan(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('a clean run carries no failure at all', async () => {
|
|
439
|
+
const { corpus } = trackingCorpus();
|
|
440
|
+
const store = createFixtureRunStore();
|
|
441
|
+
const extractor = createFixtureExtractor();
|
|
442
|
+
|
|
443
|
+
const run = await driveExtractionRun(
|
|
444
|
+
await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store }),
|
|
445
|
+
{ corpus, extractor, runs: store },
|
|
446
|
+
{ documentsPerSlice: 1 },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(run.lastModelFailure).toBeNull();
|
|
450
|
+
expect(run.totals.modelFailures).toBe(0);
|
|
451
|
+
expect(run.totals.modelCalls).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// 4. one driver
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
describe('exactly one driver at a time', () => {
|
|
460
|
+
it('a second start hands back the running sweep rather than a second one', async () => {
|
|
461
|
+
const { corpus } = trackingCorpus();
|
|
462
|
+
const store = createFixtureRunStore();
|
|
463
|
+
const extractor = createFixtureExtractor();
|
|
464
|
+
|
|
465
|
+
const first = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
466
|
+
const second = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
467
|
+
|
|
468
|
+
expect(second.runId).toBe(first.runId);
|
|
469
|
+
expect(second.adopted).toBe(false);
|
|
470
|
+
expect(isLeaseLive(second, Date.now())).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('a lease that has expired is adoptable, and the run says it stalled', async () => {
|
|
474
|
+
const { corpus } = trackingCorpus();
|
|
475
|
+
const store = createFixtureRunStore();
|
|
476
|
+
const extractor = createFixtureExtractor();
|
|
477
|
+
|
|
478
|
+
const first = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
479
|
+
const stale = { ...first, leaseUntil: new Date(Date.now() - 1000).toISOString() };
|
|
480
|
+
await store.writeRun(stale);
|
|
481
|
+
|
|
482
|
+
expect(isStalled(stale, Date.now())).toBe(true);
|
|
483
|
+
|
|
484
|
+
const adopted = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
485
|
+
expect(adopted.runId).toBe(first.runId);
|
|
486
|
+
expect(adopted.adopted).toBe(true);
|
|
487
|
+
expect(isStalled(adopted, Date.now())).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('a driver that loses its lease stops instead of racing the one that took it', async () => {
|
|
491
|
+
const { corpus } = trackingCorpus();
|
|
492
|
+
const store = createFixtureRunStore();
|
|
493
|
+
const extractor = createFixtureExtractor();
|
|
494
|
+
|
|
495
|
+
const run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
496
|
+
// Somebody else takes it between slices.
|
|
497
|
+
await store.writeRun({
|
|
498
|
+
...run,
|
|
499
|
+
leaseOwner: 'another-replica',
|
|
500
|
+
leaseUntil: new Date(Date.now() + 60_000).toISOString(),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const stopped = await driveExtractionRun(run, { corpus, extractor, runs: store }, {
|
|
504
|
+
documentsPerSlice: 1,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(stopped.leaseOwner).toBe('another-replica');
|
|
508
|
+
expect(stopped.status).not.toBe('succeeded');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('a sweep that FAILED resumes from its cursor, it does not start over', async () => {
|
|
512
|
+
// Thirty minutes is the life of the token that starts a sweep and ten is
|
|
513
|
+
// the length of one, so "the session expired at meeting twelve" is a normal
|
|
514
|
+
// event rather than an exotic one. Starting over would throw away twelve
|
|
515
|
+
// meetings of model time to redo work that is already written.
|
|
516
|
+
const { corpus } = trackingCorpus();
|
|
517
|
+
const store = createFixtureRunStore();
|
|
518
|
+
const extractor = createFixtureExtractor();
|
|
519
|
+
|
|
520
|
+
let run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
521
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
522
|
+
await store.writeRun({ ...run, status: 'failed', error: 'api/v1/entities responded 502', leaseUntil: null });
|
|
523
|
+
|
|
524
|
+
const resumed = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
525
|
+
expect(resumed.runId).toBe(run.runId);
|
|
526
|
+
expect(resumed.cursor).toBe(1);
|
|
527
|
+
expect(resumed.status).toBe('running');
|
|
528
|
+
expect(resumed.error).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('an expired session pauses the sweep rather than failing it', async () => {
|
|
532
|
+
// A 401 retried is still a 401, and it is not a fact about the corpus. The
|
|
533
|
+
// stint ends, the lease is dropped, the cursor stays, and whoever presents a
|
|
534
|
+
// live credential next carries on.
|
|
535
|
+
const { corpus } = trackingCorpus();
|
|
536
|
+
const store = createFixtureRunStore();
|
|
537
|
+
const extractor = createFixtureExtractor();
|
|
538
|
+
|
|
539
|
+
let run = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
540
|
+
run = await advanceExtractionRun(run, { corpus, extractor, runs: store }, { documentsPerSlice: 1 });
|
|
541
|
+
|
|
542
|
+
const expired = {
|
|
543
|
+
...corpus,
|
|
544
|
+
listDocuments: async () => {
|
|
545
|
+
throw Object.assign(new Error('api/v1/entities/ responded 401'), { status: 401 });
|
|
546
|
+
},
|
|
547
|
+
} as CorpusApi & CommitmentApi;
|
|
548
|
+
|
|
549
|
+
const paused = await driveExtractionRun(run, { corpus: expired, extractor, runs: store }, {
|
|
550
|
+
documentsPerSlice: 1,
|
|
551
|
+
retryDelayMs: 0,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(paused.status).toBe('running');
|
|
555
|
+
expect(paused.cursor).toBe(1);
|
|
556
|
+
expect(paused.leaseUntil).toBeNull();
|
|
557
|
+
expect(isStalled(paused, Date.now())).toBe(true);
|
|
558
|
+
expect(paused.error).toMatch(/expired/i);
|
|
559
|
+
|
|
560
|
+
// And it is adoptable, from the cursor, by a caller with a live session.
|
|
561
|
+
const resumed = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
562
|
+
expect(resumed.cursor).toBe(1);
|
|
563
|
+
expect(resumed.error).toBeNull();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('a finished run is never resumed by accident', async () => {
|
|
567
|
+
const { corpus } = trackingCorpus();
|
|
568
|
+
const store = createFixtureRunStore();
|
|
569
|
+
const extractor = createFixtureExtractor();
|
|
570
|
+
|
|
571
|
+
const done = await driveExtractionRun(
|
|
572
|
+
await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store }),
|
|
573
|
+
{ corpus, extractor, runs: store },
|
|
574
|
+
{ documentsPerSlice: 1 },
|
|
575
|
+
);
|
|
576
|
+
expect(done.status).toBe('succeeded');
|
|
577
|
+
|
|
578
|
+
// Pressing the button again is a NEW sweep, from the top, not a resume of a
|
|
579
|
+
// finished one — otherwise "run it again" silently does nothing.
|
|
580
|
+
const next = await startExtractionRun({ scope: TANDEM }, { corpus, extractor, runs: store });
|
|
581
|
+
expect(next.runId).not.toBe(done.runId);
|
|
582
|
+
expect(next.cursor).toBe(0);
|
|
583
|
+
expect(next.history[0]?.runId).toBe(done.runId);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// the run record itself
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
describe('the run record', () => {
|
|
592
|
+
it('round-trips through the tenant entity store without losing a field', async () => {
|
|
593
|
+
const store = createFixtureRunStore();
|
|
594
|
+
const run = {
|
|
595
|
+
...newExtractionRun({ totalDocuments: 17 }),
|
|
596
|
+
cursor: 9,
|
|
597
|
+
status: 'running' as const,
|
|
598
|
+
totals: {
|
|
599
|
+
documentsProcessed: 9, fromRules: 40, fromModel: 12, ungrounded: 3, unconfirmed: 7,
|
|
600
|
+
created: 44, merged: 8, resolved: 1, droppedOutOfScope: 0, modelFailures: 2, modelCalls: 7,
|
|
601
|
+
},
|
|
602
|
+
processedIds: ['a', 'b'],
|
|
603
|
+
modelIncompleteIds: ['b'],
|
|
604
|
+
lastModelFailure: { stage: 'extract' as const, message: 'Request timed out after 60000ms', timedOut: true },
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
await store.writeRun(run);
|
|
608
|
+
const read = await store.readRun();
|
|
609
|
+
|
|
610
|
+
expect(read).toEqual(expect.objectContaining({
|
|
611
|
+
runId: run.runId,
|
|
612
|
+
cursor: 9,
|
|
613
|
+
status: 'running',
|
|
614
|
+
processedIds: ['a', 'b'],
|
|
615
|
+
modelIncompleteIds: ['b'],
|
|
616
|
+
}));
|
|
617
|
+
expect(read?.totals).toEqual(run.totals);
|
|
618
|
+
expect(read?.lastModelFailure).toEqual(run.lastModelFailure);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('refuses a run with no workspace, the same way extraction does', async () => {
|
|
622
|
+
const { corpus } = trackingCorpus();
|
|
623
|
+
const store = createFixtureRunStore();
|
|
624
|
+
await expect(
|
|
625
|
+
startExtractionRun(
|
|
626
|
+
{ scope: { orgId: '' } },
|
|
627
|
+
{ corpus, extractor: createFixtureExtractor(), runs: store },
|
|
628
|
+
),
|
|
629
|
+
).rejects.toThrow(/workspace/i);
|
|
630
|
+
});
|
|
631
|
+
});
|