@startsimpli/integrations 0.2.2 → 0.2.5
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -198,6 +198,60 @@ describe('idempotence: running it twice changes nothing', () => {
|
|
|
198
198
|
);
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
it('survives the MODEL rewording its own output between runs', () => {
|
|
202
|
+
// THE PRODUCTION BUG, pinned. On the live tandem tenant a second extraction
|
|
203
|
+
// over the same four documents created SIXTEEN duplicate rows: the model
|
|
204
|
+
// reworded its own `subject` ("ketamine regimen" -> "ketamine treatment")
|
|
205
|
+
// while emitting a byte-identical title, and a disagreeing subject was
|
|
206
|
+
// vetoing the match. Prose subjects are now weak evidence, not a veto.
|
|
207
|
+
const first = reconcile([], mergeDrafts([
|
|
208
|
+
{ ...draft('m1', 'Find a sustainable ketamine regimen', '2026-08-01'), subject: 'ketamine regimen', extractor: 'model' as const },
|
|
209
|
+
]), { scope: TANDEM, documents: [documentFor('m1', '2026-08-01')] });
|
|
210
|
+
|
|
211
|
+
const second = reconcile(first.commitments, mergeDrafts([
|
|
212
|
+
{ ...draft('m1', 'Find a sustainable ketamine regimen', '2026-08-01'), subject: "Quinn's ketamine treatment", extractor: 'model' as const },
|
|
213
|
+
]), { scope: TANDEM, documents: [documentFor('m1', '2026-08-01')] });
|
|
214
|
+
|
|
215
|
+
expect(second.created).toBe(0);
|
|
216
|
+
expect(second.commitments).toHaveLength(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('still refuses to merge two MATTER NUMBERS that disagree, however alike the words', () => {
|
|
220
|
+
// The other side of the same rule. An identifier is authoritative: "chase
|
|
221
|
+
// for comments on 2148" and "...on 2198" have identical action stems and are
|
|
222
|
+
// two different obligations. Merging them deletes work.
|
|
223
|
+
const { commitments } = reconcile([], mergeDrafts([
|
|
224
|
+
draft('m1', 'Chase for comments on 2148', '2026-08-01'),
|
|
225
|
+
draft('m1', 'Chase for comments on 2198', '2026-08-01'),
|
|
226
|
+
]), { scope: TANDEM, documents: [documentFor('m1', '2026-08-01')] });
|
|
227
|
+
expect(commitments).toHaveLength(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('tells the model what is ALREADY TRACKED for a document, not just what the rules found', async () => {
|
|
231
|
+
// The structural half of idempotency: the fuzzy matcher is a safety net, and
|
|
232
|
+
// not asking the model to invent the same item twice is the actual fix.
|
|
233
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
234
|
+
const tracked: Commitment[] = [];
|
|
235
|
+
(corpus as { listCommitments?: unknown }).listCommitments = async () => ({
|
|
236
|
+
count: tracked.length, next: null, previous: null, results: tracked,
|
|
237
|
+
});
|
|
238
|
+
(corpus as { saveCommitments?: unknown }).saveCommitments = async (items: Commitment[]) => {
|
|
239
|
+
tracked.splice(0, tracked.length, ...items);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const extractor = createFixtureExtractor();
|
|
243
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
244
|
+
|
|
245
|
+
await extractCommitments({ scope: TANDEM }, { corpus, extractor });
|
|
246
|
+
const firstAsk = spy.mock.calls[0][0].documents.flatMap((d) => d.alreadyFound).length;
|
|
247
|
+
|
|
248
|
+
await extractCommitments({ scope: TANDEM }, { corpus, extractor });
|
|
249
|
+
const secondAsk = spy.mock.calls[1][0].documents.flatMap((d) => d.alreadyFound).length;
|
|
250
|
+
|
|
251
|
+
expect(tracked.length).toBeGreaterThan(0);
|
|
252
|
+
expect(secondAsk).toBeGreaterThan(firstAsk);
|
|
253
|
+
});
|
|
254
|
+
|
|
201
255
|
it('keeps a human’s edit when the extractor runs again over the same source', () => {
|
|
202
256
|
const drafts = rulesExtract(FIXTURE_TRACKED_DOCUMENTS[0]);
|
|
203
257
|
const first = reconcile([], mergeDrafts(drafts), { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
@@ -431,6 +485,28 @@ describe('extractCommitments', () => {
|
|
|
431
485
|
expect(result.created).toBeGreaterThan(0);
|
|
432
486
|
});
|
|
433
487
|
|
|
488
|
+
it('slices a sweep so one call fits the gateway, and says where to resume', async () => {
|
|
489
|
+
// A deployed tenant closes a request at 60s and one call over the whole
|
|
490
|
+
// corpus does not finish inside it (measured on tandem — 504). The sweep is
|
|
491
|
+
// therefore BATCHED INTO CALLS THAT FIT, never one call per document.
|
|
492
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
493
|
+
const extractor = createFixtureExtractor();
|
|
494
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
495
|
+
|
|
496
|
+
const first = await extractCommitments({ scope: TANDEM, limit: 2 }, { corpus, extractor });
|
|
497
|
+
expect(first.documentsProcessed).toBe(2);
|
|
498
|
+
expect(first.totalDocuments).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
499
|
+
expect(first.nextOffset).toBe(2);
|
|
500
|
+
expect(spy.mock.calls[0][0].documents).toHaveLength(2); // still N per call
|
|
501
|
+
|
|
502
|
+
const second = await extractCommitments(
|
|
503
|
+
{ scope: TANDEM, offset: first.nextOffset!, limit: 2 },
|
|
504
|
+
{ corpus, extractor },
|
|
505
|
+
);
|
|
506
|
+
expect(second.documentsProcessed).toBe(1);
|
|
507
|
+
expect(second.nextOffset).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
434
510
|
it('refuses outright when the session carries no workspace claim', async () => {
|
|
435
511
|
const corpus = createFixtureCorpusApi({ leakScope: true, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
436
512
|
const listDocuments = vi.spyOn(corpus, 'listDocuments');
|
|
@@ -196,6 +196,8 @@ describe('TrackerPage — what is outstanding', () => {
|
|
|
196
196
|
describe('TrackerPage — running the extraction', () => {
|
|
197
197
|
const RESULT = {
|
|
198
198
|
documentsProcessed: 16,
|
|
199
|
+
totalDocuments: 16,
|
|
200
|
+
nextOffset: null,
|
|
199
201
|
fromRules: 24,
|
|
200
202
|
fromModel: 11,
|
|
201
203
|
created: 30,
|
|
@@ -61,6 +61,23 @@ export const EXTRACTOR_VERSION = 'rules+model@1';
|
|
|
61
61
|
*/
|
|
62
62
|
const MERGE_THRESHOLD = 0.5;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The bar when the two statements do NOT agree on a subject — either because
|
|
66
|
+
* one of them has none, or because they disagree.
|
|
67
|
+
*
|
|
68
|
+
* A DISAGREEING SUBJECT MUST NOT VETO A MATCH, and that was a real production
|
|
69
|
+
* bug: on the live tandem tenant a second extraction run over the same four
|
|
70
|
+
* documents created sixteen duplicate rows, because the model had rephrased its
|
|
71
|
+
* own `subject` between runs ("ketamine regimen" vs "ketamine treatment") and
|
|
72
|
+
* the veto threw away pairs whose TITLES were byte-identical. Measured across
|
|
73
|
+
* the resulting 98 rows: five same-document duplicate pairs sat at 0.5 or above
|
|
74
|
+
* on title overlap, and ZERO cross-document pairs reached even 0.5 — so an
|
|
75
|
+
* unanchored bar of 0.7 catches the rewording with no measured false merge.
|
|
76
|
+
*
|
|
77
|
+
* An agreeing subject still buys the lower bar, because it is real evidence.
|
|
78
|
+
*/
|
|
79
|
+
const UNANCHORED_MERGE_THRESHOLD = 0.7;
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* The bar a resolution has to clear before it CLOSES something, which is higher
|
|
66
83
|
* than the bar for merging because the failure is worse. A wrong merge shows one
|
|
@@ -100,6 +117,19 @@ const MATTER_BARE = /\b(?!(?:19|20)\d{2}\b)\d{4}\b/;
|
|
|
100
117
|
|
|
101
118
|
export interface CommitmentIdentity {
|
|
102
119
|
subjectKey: string;
|
|
120
|
+
/**
|
|
121
|
+
* True when the subject is an IDENTIFIER (a matter number) rather than prose.
|
|
122
|
+
*
|
|
123
|
+
* The distinction is load-bearing. Two identifiers that disagree are two
|
|
124
|
+
* different things and no amount of shared wording changes that — "chase for
|
|
125
|
+
* comments on 2148" and "chase for comments on 2198" are two obligations whose
|
|
126
|
+
* action stems are identical. Two PROSE subjects that disagree mean much less:
|
|
127
|
+
* measured on the live tenant, the model described one commitment as being
|
|
128
|
+
* about "ketamine regimen" on one run and "ketamine treatment" on the next,
|
|
129
|
+
* while emitting a byte-identical title. So an identifier vetoes; prose does
|
|
130
|
+
* not, it only raises the bar.
|
|
131
|
+
*/
|
|
132
|
+
subjectIsIdentifier: boolean;
|
|
103
133
|
actionKey: string;
|
|
104
134
|
actionTokens: string[];
|
|
105
135
|
dedupeKey: string;
|
|
@@ -128,20 +158,43 @@ export function commitmentIdentity(input: {
|
|
|
128
158
|
const subjectTokens = matter ? [matter] : unique(tokenize(subject));
|
|
129
159
|
const subjectKey = matter ? matter : subjectTokens.slice().sort().join('.');
|
|
130
160
|
|
|
131
|
-
|
|
161
|
+
// ONLY AN IDENTIFIER IS REMOVED FROM THE ACTION. Removing prose subject words
|
|
162
|
+
// too would couple the two halves of the key: when the model reworded a
|
|
163
|
+
// subject from "ketamine regimen" to "Quinn's ketamine treatment", a
|
|
164
|
+
// byte-identical title produced two different action sets and the row
|
|
165
|
+
// duplicated. The matter number is noise in the action ("chase 2148" is a
|
|
166
|
+
// chase); the prose words are part of what the title says.
|
|
167
|
+
const drop = new Set(matter ? [matter] : []);
|
|
132
168
|
const actionTokens = unique(
|
|
133
169
|
tokenize(title).filter((token) => !drop.has(token) && !ACTION_NOISE.has(token)),
|
|
134
170
|
);
|
|
135
171
|
const actionKey = actionTokens.slice().sort().join('.');
|
|
136
172
|
|
|
137
|
-
return {
|
|
173
|
+
return {
|
|
174
|
+
subjectKey,
|
|
175
|
+
subjectIsIdentifier: Boolean(matter),
|
|
176
|
+
actionKey,
|
|
177
|
+
actionTokens,
|
|
178
|
+
dedupeKey: hash(`${subjectKey}|${actionKey}`),
|
|
179
|
+
};
|
|
138
180
|
}
|
|
139
181
|
|
|
140
182
|
/** Do two identities describe one commitment? Tier 1, then Tier 2. */
|
|
141
183
|
export function isSameCommitment(a: CommitmentIdentity, b: CommitmentIdentity): boolean {
|
|
142
184
|
if (a.dedupeKey === b.dedupeKey) return true;
|
|
143
|
-
if (
|
|
144
|
-
|
|
185
|
+
if (subjectsConflict(a, b)) return false;
|
|
186
|
+
const anchored = Boolean(a.subjectKey) && a.subjectKey === b.subjectKey;
|
|
187
|
+
const bar = anchored ? MERGE_THRESHOLD : UNANCHORED_MERGE_THRESHOLD;
|
|
188
|
+
return jaccard(a.actionTokens, b.actionTokens) >= bar;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Two IDENTIFIERS that disagree. The one veto worth having. */
|
|
192
|
+
function subjectsConflict(a: CommitmentIdentity, b: CommitmentIdentity): boolean {
|
|
193
|
+
return (
|
|
194
|
+
a.subjectIsIdentifier &&
|
|
195
|
+
b.subjectIsIdentifier &&
|
|
196
|
+
a.subjectKey !== b.subjectKey
|
|
197
|
+
);
|
|
145
198
|
}
|
|
146
199
|
|
|
147
200
|
// ---------------------------------------------------------------------------
|
|
@@ -295,6 +348,7 @@ function toMention(draft: CommitmentDraft, document: SourceDocument): Commitment
|
|
|
295
348
|
function identityOf(commitment: Commitment): CommitmentIdentity {
|
|
296
349
|
return {
|
|
297
350
|
subjectKey: commitment.subjectKey,
|
|
351
|
+
subjectIsIdentifier: MATTER_HYPHENATED.test(commitment.subjectKey) || MATTER_BARE.test(commitment.subjectKey),
|
|
298
352
|
actionKey: commitment.actionKey,
|
|
299
353
|
actionTokens: commitment.actionKey ? commitment.actionKey.split('.') : [],
|
|
300
354
|
dedupeKey: commitment.dedupeKey,
|
|
@@ -347,13 +401,15 @@ export function applyResolutions(
|
|
|
347
401
|
for (const commitment of out) {
|
|
348
402
|
if (commitment.status !== 'open' && commitment.status !== 'blocked') continue;
|
|
349
403
|
if (at && commitment.firstSeenAt && commitment.firstSeenAt > at) continue;
|
|
404
|
+
const other = identityOf(commitment);
|
|
405
|
+
// Two IDENTIFIERS that disagree are different things, and no amount of
|
|
406
|
+
// shared wording makes them the same one. Disagreeing prose is only weak
|
|
407
|
+
// evidence and raises the bar instead of vetoing — see `subjectsConflict`.
|
|
408
|
+
if (subjectsConflict(identity, other)) continue;
|
|
350
409
|
const anchored = Boolean(identity.subjectKey) && identity.subjectKey === commitment.subjectKey;
|
|
351
|
-
// Two subjects that both exist and disagree are different things, and no
|
|
352
|
-
// amount of shared wording makes them the same one.
|
|
353
|
-
if (identity.subjectKey && commitment.subjectKey && !anchored) continue;
|
|
354
410
|
const candidate = {
|
|
355
411
|
commitment,
|
|
356
|
-
score: jaccard(identity.actionTokens,
|
|
412
|
+
score: jaccard(identity.actionTokens, other.actionTokens),
|
|
357
413
|
bar: anchored ? RESOLVE_THRESHOLD : UNANCHORED_RESOLVE_THRESHOLD,
|
|
358
414
|
};
|
|
359
415
|
if (!best || candidate.score > best.score) best = candidate;
|
package/src/corpus/extract.ts
CHANGED
|
@@ -235,6 +235,17 @@ export interface ExtractRequest {
|
|
|
235
235
|
scope: CorpusScope;
|
|
236
236
|
/** Narrow the run to specific records. Omit to sweep the corpus. */
|
|
237
237
|
documentIds?: string[];
|
|
238
|
+
/**
|
|
239
|
+
* Where this call starts in the in-scope corpus, and how many documents it
|
|
240
|
+
* may take. Omit both to do the whole thing in one model call.
|
|
241
|
+
*
|
|
242
|
+
* This exists for a deployment fact, not a design preference: the tandem ALB
|
|
243
|
+
* closes a request at 60 seconds, and one batched call over all sixteen real
|
|
244
|
+
* documents does not finish inside it (measured — 504). Slicing keeps every
|
|
245
|
+
* call batched over N documents while letting the caller walk the corpus.
|
|
246
|
+
*/
|
|
247
|
+
offset?: number;
|
|
248
|
+
limit?: number;
|
|
238
249
|
}
|
|
239
250
|
|
|
240
251
|
export interface ExtractDeps {
|
|
@@ -258,19 +269,55 @@ export async function extractCommitments(
|
|
|
258
269
|
|
|
259
270
|
const { documents: candidates, matched } = await readDocuments(corpus, documentLimit, maxPages);
|
|
260
271
|
const { kept, dropped } = enforceScope(candidates, request.scope);
|
|
261
|
-
const
|
|
272
|
+
const inScope = request.documentIds?.length
|
|
262
273
|
? kept.filter((document) => request.documentIds!.includes(document.id))
|
|
263
274
|
: kept;
|
|
264
275
|
|
|
276
|
+
// The slice this call handles. `listDocuments` orders stably (the tenant's
|
|
277
|
+
// (org_id, entity_type, -created_at) index), so an offset means the same thing
|
|
278
|
+
// between calls of one sweep.
|
|
279
|
+
const offset = Math.max(0, request.offset ?? 0);
|
|
280
|
+
const documents = request.limit ? inScope.slice(offset, offset + request.limit) : inScope.slice(offset);
|
|
281
|
+
const consumed = offset + documents.length;
|
|
282
|
+
const nextOffset = consumed < inScope.length ? consumed : null;
|
|
283
|
+
|
|
265
284
|
if (documents.length === 0) {
|
|
266
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
...empty(extractor, 'nothing in scope'),
|
|
287
|
+
totalDocuments: inScope.length,
|
|
288
|
+
droppedOutOfScope: dropped.length,
|
|
289
|
+
};
|
|
267
290
|
}
|
|
268
291
|
|
|
292
|
+
const allowedIds = new Set(documents.map((document) => document.id));
|
|
293
|
+
|
|
269
294
|
// 3. rules first, so the model can be told what not to repeat.
|
|
270
295
|
const ruleDrafts = documents.flatMap(rulesExtract);
|
|
271
296
|
const foundByDocument = new Map<string, string[]>();
|
|
272
|
-
|
|
273
|
-
foundByDocument.set(
|
|
297
|
+
const note = (documentId: string, title: string) => {
|
|
298
|
+
foundByDocument.set(documentId, [...(foundByDocument.get(documentId) ?? []), title]);
|
|
299
|
+
};
|
|
300
|
+
for (const draft of ruleDrafts) note(draft.documentId, draft.title);
|
|
301
|
+
|
|
302
|
+
// WHAT IS ALREADY TRACKED IS ALSO "ALREADY FOUND". Read BEFORE the model call,
|
|
303
|
+
// not after, so a second run over the same document tells the model what it
|
|
304
|
+
// produced last time.
|
|
305
|
+
//
|
|
306
|
+
// This is the STRUCTURAL half of idempotency and it exists because the fuzzy
|
|
307
|
+
// half was not enough: on the live tandem tenant a re-run over four documents
|
|
308
|
+
// created sixteen duplicates purely because the model reworded its own output.
|
|
309
|
+
// Matching after the fact is a safety net; not asking the model to invent the
|
|
310
|
+
// same item twice is the fix.
|
|
311
|
+
const existing = corpus.listCommitments
|
|
312
|
+
? await corpus
|
|
313
|
+
.listCommitments({ pageSize: 500 })
|
|
314
|
+
.then((page) => page.results)
|
|
315
|
+
.catch(() => [] as Commitment[])
|
|
316
|
+
: [];
|
|
317
|
+
for (const commitment of existing) {
|
|
318
|
+
for (const mention of commitment.mentions) {
|
|
319
|
+
if (allowedIds.has(mention.documentId)) note(mention.documentId, commitment.title);
|
|
320
|
+
}
|
|
274
321
|
}
|
|
275
322
|
|
|
276
323
|
// 4. ONE call, every document, ids echoed.
|
|
@@ -292,18 +339,10 @@ export async function extractCommitments(
|
|
|
292
339
|
draft = { items: [], resolutions: [] };
|
|
293
340
|
}
|
|
294
341
|
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const claims = normaliseResolutions(draft.resolutions ?? [], allowed);
|
|
342
|
+
const modelDrafts = normaliseItems(draft.items ?? [], allowedIds);
|
|
343
|
+
const claims = normaliseResolutions(draft.resolutions ?? [], allowedIds);
|
|
298
344
|
|
|
299
345
|
// 5. identity, dedupe within the run, then against what is stored.
|
|
300
|
-
const existing = corpus.listCommitments
|
|
301
|
-
? await corpus
|
|
302
|
-
.listCommitments({ pageSize: 500 })
|
|
303
|
-
.then((page) => page.results)
|
|
304
|
-
.catch(() => [] as Commitment[])
|
|
305
|
-
: [];
|
|
306
|
-
|
|
307
346
|
const groups = mergeDrafts([...ruleDrafts, ...modelDrafts]);
|
|
308
347
|
const reconciled = reconcile(existing, groups, { scope: request.scope, documents });
|
|
309
348
|
|
|
@@ -317,6 +356,8 @@ export async function extractCommitments(
|
|
|
317
356
|
|
|
318
357
|
return {
|
|
319
358
|
documentsProcessed: documents.length,
|
|
359
|
+
totalDocuments: inScope.length,
|
|
360
|
+
nextOffset,
|
|
320
361
|
fromRules: ruleDrafts.length,
|
|
321
362
|
fromModel: modelDrafts.length,
|
|
322
363
|
created: reconciled.created,
|
|
@@ -325,8 +366,8 @@ export async function extractCommitments(
|
|
|
325
366
|
needsReview: resolutions.needsReview,
|
|
326
367
|
droppedOutOfScope: dropped.length,
|
|
327
368
|
provider: extractor.describe(),
|
|
328
|
-
...(matched > candidates.length ? { } : {}),
|
|
329
369
|
};
|
|
370
|
+
void matched;
|
|
330
371
|
}
|
|
331
372
|
|
|
332
373
|
/**
|
|
@@ -424,6 +465,8 @@ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
|
|
|
424
465
|
void _why;
|
|
425
466
|
return {
|
|
426
467
|
documentsProcessed: 0,
|
|
468
|
+
totalDocuments: 0,
|
|
469
|
+
nextOffset: null,
|
|
427
470
|
fromRules: 0,
|
|
428
471
|
fromModel: 0,
|
|
429
472
|
created: 0,
|
package/src/corpus/types.ts
CHANGED
|
@@ -565,12 +565,30 @@ export interface CommitmentTrackerApi extends CommitmentApi {
|
|
|
565
565
|
* Run extraction over documents that have not been processed. Optional: a
|
|
566
566
|
* read-only deployment omits it and the control does not render.
|
|
567
567
|
*/
|
|
568
|
-
extract?(input: {
|
|
568
|
+
extract?(input: {
|
|
569
|
+
scope: CorpusScope;
|
|
570
|
+
documentIds?: string[];
|
|
571
|
+
/** Resume point for a batched sweep. See `ExtractionResult.nextOffset`. */
|
|
572
|
+
offset?: number;
|
|
573
|
+
limit?: number;
|
|
574
|
+
}): Promise<ExtractionResult>;
|
|
569
575
|
}
|
|
570
576
|
|
|
571
577
|
export interface ExtractionResult {
|
|
572
|
-
/** Documents read and sent to the extractor. */
|
|
578
|
+
/** Documents read and sent to the extractor IN THIS CALL. */
|
|
573
579
|
documentsProcessed: number;
|
|
580
|
+
/** Documents in scope altogether, so a partial sweep can say what it is of. */
|
|
581
|
+
totalDocuments: number;
|
|
582
|
+
/**
|
|
583
|
+
* Where a resumed sweep should start, or `null` when the corpus is done.
|
|
584
|
+
*
|
|
585
|
+
* A run is RESUMABLE because it has to be: a deployed tenant sits behind a
|
|
586
|
+
* gateway with a request timeout (60s on the tandem ALB, measured — a single
|
|
587
|
+
* call over sixteen documents 504s), and one model call per document is
|
|
588
|
+
* forbidden. So the sweep is batched into calls that fit, and the caller walks
|
|
589
|
+
* the offsets. Still N documents per model call; just not always all of them.
|
|
590
|
+
*/
|
|
591
|
+
nextOffset: number | null;
|
|
574
592
|
/** Items the vendor's own structure produced, with no model involved. */
|
|
575
593
|
fromRules: number;
|
|
576
594
|
/** Items only the model found. */
|