@startsimpli/integrations 0.2.3 → 0.2.7
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-extract.test.ts +101 -0
- package/src/corpus/commitments.ts +103 -10
- package/src/corpus/extract.ts +38 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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 });
|
|
@@ -453,6 +507,53 @@ describe('extractCommitments', () => {
|
|
|
453
507
|
expect(second.nextOffset).toBeNull();
|
|
454
508
|
});
|
|
455
509
|
|
|
510
|
+
it('writes only what it CHANGED, not the whole corpus', async () => {
|
|
511
|
+
// Not a micro-optimisation. The tenant has no bulk upsert, so every row is a
|
|
512
|
+
// request; rewriting untouched rows makes a one-document slice cost one
|
|
513
|
+
// request per row in the workspace. On the live tenant that pushed a
|
|
514
|
+
// single-document run past the 60-second gateway at ninety-eight rows.
|
|
515
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
516
|
+
const tracked: Commitment[] = [];
|
|
517
|
+
const writes: number[] = [];
|
|
518
|
+
(corpus as { listCommitments?: unknown }).listCommitments = async () => ({
|
|
519
|
+
count: tracked.length, next: null, previous: null, results: tracked,
|
|
520
|
+
});
|
|
521
|
+
(corpus as { saveCommitments?: unknown }).saveCommitments = async (items: Commitment[]) => {
|
|
522
|
+
writes.push(items.length);
|
|
523
|
+
const byId = new Map(tracked.map((item) => [item.externalId, item]));
|
|
524
|
+
for (const item of items) byId.set(item.externalId, item);
|
|
525
|
+
tracked.splice(0, tracked.length, ...byId.values());
|
|
526
|
+
};
|
|
527
|
+
const extractor = createFixtureExtractor();
|
|
528
|
+
|
|
529
|
+
await extractCommitments(
|
|
530
|
+
{ scope: TANDEM, documentIds: [FIXTURE_TRACKED_DOCUMENTS[0].id] },
|
|
531
|
+
{ corpus, extractor },
|
|
532
|
+
);
|
|
533
|
+
const afterFirst = tracked.length;
|
|
534
|
+
await extractCommitments(
|
|
535
|
+
{ scope: TANDEM, documentIds: [FIXTURE_TRACKED_DOCUMENTS[1].id] },
|
|
536
|
+
{ corpus, extractor },
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// The second slice never rewrites the first slice's untouched rows.
|
|
540
|
+
expect(writes[1]).toBeLessThan(tracked.length);
|
|
541
|
+
expect(afterFirst).toBeGreaterThan(0);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('marks NOTHING dirty when a pass changes nothing', () => {
|
|
545
|
+
// The guarantee stated where it is unambiguous — on the pure reconciler,
|
|
546
|
+
// with no extractor in the way. Identical input, second time: zero writes.
|
|
547
|
+
const drafts = mergeDrafts(FIXTURE_TRACKED_DOCUMENTS.flatMap(rulesExtract));
|
|
548
|
+
const context = { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS };
|
|
549
|
+
const first = reconcile([], drafts, context);
|
|
550
|
+
expect(first.touched.length).toBe(first.commitments.length);
|
|
551
|
+
|
|
552
|
+
const second = reconcile(first.commitments, drafts, context);
|
|
553
|
+
expect(second.created).toBe(0);
|
|
554
|
+
expect(second.touched).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
456
557
|
it('refuses outright when the session carries no workspace claim', async () => {
|
|
457
558
|
const corpus = createFixtureCorpusApi({ leakScope: true, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
458
559
|
const listDocuments = vi.spyOn(corpus, 'listDocuments');
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -193,6 +246,15 @@ export interface ReconcileResult {
|
|
|
193
246
|
commitments: Commitment[];
|
|
194
247
|
created: number;
|
|
195
248
|
merged: number;
|
|
249
|
+
/**
|
|
250
|
+
* The `externalId`s this pass actually changed.
|
|
251
|
+
*
|
|
252
|
+
* A run must write what it CHANGED, not what it holds. Rewriting the whole
|
|
253
|
+
* corpus on every pass is one request per row per slice — O(corpus) writes for
|
|
254
|
+
* O(slice) work — and on the live tenant that alone pushed a single-document
|
|
255
|
+
* run past the 60-second gateway once there were ninety-eight rows.
|
|
256
|
+
*/
|
|
257
|
+
touched: string[];
|
|
196
258
|
}
|
|
197
259
|
|
|
198
260
|
export function reconcile(
|
|
@@ -202,6 +264,7 @@ export function reconcile(
|
|
|
202
264
|
): ReconcileResult {
|
|
203
265
|
const byId = new Map(context.documents.map((document) => [document.id, document]));
|
|
204
266
|
const out = existing.map(cloneCommitment);
|
|
267
|
+
const touched = new Set<string>();
|
|
205
268
|
let created = 0;
|
|
206
269
|
let merged = 0;
|
|
207
270
|
|
|
@@ -216,6 +279,7 @@ export function reconcile(
|
|
|
216
279
|
|
|
217
280
|
if (match) {
|
|
218
281
|
merged += 1;
|
|
282
|
+
const before = signature(match);
|
|
219
283
|
applyMentions(match, mentions);
|
|
220
284
|
// The extractor NEVER overwrites a status, an owner or a due date that is
|
|
221
285
|
// already recorded. A person ticking something off on the screen must
|
|
@@ -223,6 +287,11 @@ export function reconcile(
|
|
|
223
287
|
if (!match.ownerName && lead.ownerName) match.ownerName = lead.ownerName;
|
|
224
288
|
if (!match.dueAt && lead.dueAt) match.dueAt = lead.dueAt;
|
|
225
289
|
if (!match.detail && lead.detail) match.detail = lead.detail;
|
|
290
|
+
// Only a row that ACTUALLY changed is written. Marking every match dirty
|
|
291
|
+
// would make a repeat run rewrite the corpus for no reason, which is the
|
|
292
|
+
// difference between "running it twice is a no-op" as a claim and as a
|
|
293
|
+
// fact you can watch on the network tab.
|
|
294
|
+
if (signature(match) !== before) touched.add(match.externalId);
|
|
226
295
|
continue;
|
|
227
296
|
}
|
|
228
297
|
|
|
@@ -255,9 +324,10 @@ export function reconcile(
|
|
|
255
324
|
};
|
|
256
325
|
applyMentions(fresh, mentions);
|
|
257
326
|
out.push(fresh);
|
|
327
|
+
touched.add(fresh.externalId);
|
|
258
328
|
}
|
|
259
329
|
|
|
260
|
-
return { commitments: out, created, merged };
|
|
330
|
+
return { commitments: out, created, merged, touched: [...touched] };
|
|
261
331
|
}
|
|
262
332
|
|
|
263
333
|
/**
|
|
@@ -282,6 +352,22 @@ function applyMentions(commitment: Commitment, mentions: CommitmentMention[]) {
|
|
|
282
352
|
|
|
283
353
|
const mentionKey = (mention: CommitmentMention) => `${mention.documentId}::${mention.originKey}`;
|
|
284
354
|
|
|
355
|
+
/** Everything a re-run could legitimately change, as one comparable string. */
|
|
356
|
+
function signature(commitment: Commitment): string {
|
|
357
|
+
return [
|
|
358
|
+
commitment.title,
|
|
359
|
+
commitment.detail,
|
|
360
|
+
commitment.status,
|
|
361
|
+
commitment.statusReason ?? '',
|
|
362
|
+
commitment.ownerName ?? '',
|
|
363
|
+
commitment.dueAt ?? '',
|
|
364
|
+
commitment.resolvedBy ?? '',
|
|
365
|
+
commitment.firstSeenAt ?? '',
|
|
366
|
+
commitment.lastSeenAt ?? '',
|
|
367
|
+
commitment.mentions.map((mention) => `${mentionKey(mention)}|${mention.quote}`).join('~'),
|
|
368
|
+
].join('\u0000');
|
|
369
|
+
}
|
|
370
|
+
|
|
285
371
|
function toMention(draft: CommitmentDraft, document: SourceDocument): CommitmentMention {
|
|
286
372
|
return {
|
|
287
373
|
documentId: draft.documentId,
|
|
@@ -295,6 +381,7 @@ function toMention(draft: CommitmentDraft, document: SourceDocument): Commitment
|
|
|
295
381
|
function identityOf(commitment: Commitment): CommitmentIdentity {
|
|
296
382
|
return {
|
|
297
383
|
subjectKey: commitment.subjectKey,
|
|
384
|
+
subjectIsIdentifier: MATTER_HYPHENATED.test(commitment.subjectKey) || MATTER_BARE.test(commitment.subjectKey),
|
|
298
385
|
actionKey: commitment.actionKey,
|
|
299
386
|
actionTokens: commitment.actionKey ? commitment.actionKey.split('.') : [],
|
|
300
387
|
dedupeKey: commitment.dedupeKey,
|
|
@@ -307,6 +394,8 @@ function identityOf(commitment: Commitment): CommitmentIdentity {
|
|
|
307
394
|
|
|
308
395
|
export interface ResolutionResult {
|
|
309
396
|
commitments: Commitment[];
|
|
397
|
+
/** The `externalId`s this pass closed. Same reasoning as `ReconcileResult`. */
|
|
398
|
+
touched: string[];
|
|
310
399
|
resolved: number;
|
|
311
400
|
needsReview: Array<{ commitmentId: string; documentId: string; quote: string }>;
|
|
312
401
|
}
|
|
@@ -335,6 +424,7 @@ export function applyResolutions(
|
|
|
335
424
|
const byId = new Map(context.documents.map((document) => [document.id, document]));
|
|
336
425
|
const out = commitments.map(cloneCommitment);
|
|
337
426
|
const needsReview: ResolutionResult['needsReview'] = [];
|
|
427
|
+
const touched = new Set<string>();
|
|
338
428
|
let resolved = 0;
|
|
339
429
|
|
|
340
430
|
for (const claim of claims) {
|
|
@@ -347,13 +437,15 @@ export function applyResolutions(
|
|
|
347
437
|
for (const commitment of out) {
|
|
348
438
|
if (commitment.status !== 'open' && commitment.status !== 'blocked') continue;
|
|
349
439
|
if (at && commitment.firstSeenAt && commitment.firstSeenAt > at) continue;
|
|
440
|
+
const other = identityOf(commitment);
|
|
441
|
+
// Two IDENTIFIERS that disagree are different things, and no amount of
|
|
442
|
+
// shared wording makes them the same one. Disagreeing prose is only weak
|
|
443
|
+
// evidence and raises the bar instead of vetoing — see `subjectsConflict`.
|
|
444
|
+
if (subjectsConflict(identity, other)) continue;
|
|
350
445
|
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
446
|
const candidate = {
|
|
355
447
|
commitment,
|
|
356
|
-
score: jaccard(identity.actionTokens,
|
|
448
|
+
score: jaccard(identity.actionTokens, other.actionTokens),
|
|
357
449
|
bar: anchored ? RESOLVE_THRESHOLD : UNANCHORED_RESOLVE_THRESHOLD,
|
|
358
450
|
};
|
|
359
451
|
if (!best || candidate.score > best.score) best = candidate;
|
|
@@ -365,6 +457,7 @@ export function applyResolutions(
|
|
|
365
457
|
best.commitment.statusReason = claim.quote || claim.statement;
|
|
366
458
|
best.commitment.resolvedBy = claim.documentId;
|
|
367
459
|
best.commitment.resolvedAt = at;
|
|
460
|
+
touched.add(best.commitment.externalId);
|
|
368
461
|
resolved += 1;
|
|
369
462
|
} else {
|
|
370
463
|
// ONE review row per claim — its single best candidate. Listing every open
|
|
@@ -377,7 +470,7 @@ export function applyResolutions(
|
|
|
377
470
|
}
|
|
378
471
|
}
|
|
379
472
|
|
|
380
|
-
return { commitments: out, resolved, needsReview };
|
|
473
|
+
return { commitments: out, touched: [...touched], resolved, needsReview };
|
|
381
474
|
}
|
|
382
475
|
|
|
383
476
|
// ---------------------------------------------------------------------------
|
package/src/corpus/extract.ts
CHANGED
|
@@ -289,11 +289,35 @@ export async function extractCommitments(
|
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
const allowedIds = new Set(documents.map((document) => document.id));
|
|
293
|
+
|
|
292
294
|
// 3. rules first, so the model can be told what not to repeat.
|
|
293
295
|
const ruleDrafts = documents.flatMap(rulesExtract);
|
|
294
296
|
const foundByDocument = new Map<string, string[]>();
|
|
295
|
-
|
|
296
|
-
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
|
+
}
|
|
297
321
|
}
|
|
298
322
|
|
|
299
323
|
// 4. ONE call, every document, ids echoed.
|
|
@@ -315,27 +339,26 @@ export async function extractCommitments(
|
|
|
315
339
|
draft = { items: [], resolutions: [] };
|
|
316
340
|
}
|
|
317
341
|
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
const claims = normaliseResolutions(draft.resolutions ?? [], allowed);
|
|
342
|
+
const modelDrafts = normaliseItems(draft.items ?? [], allowedIds);
|
|
343
|
+
const claims = normaliseResolutions(draft.resolutions ?? [], allowedIds);
|
|
321
344
|
|
|
322
345
|
// 5. identity, dedupe within the run, then against what is stored.
|
|
323
|
-
const existing = corpus.listCommitments
|
|
324
|
-
? await corpus
|
|
325
|
-
.listCommitments({ pageSize: 500 })
|
|
326
|
-
.then((page) => page.results)
|
|
327
|
-
.catch(() => [] as Commitment[])
|
|
328
|
-
: [];
|
|
329
|
-
|
|
330
346
|
const groups = mergeDrafts([...ruleDrafts, ...modelDrafts]);
|
|
331
347
|
const reconciled = reconcile(existing, groups, { scope: request.scope, documents });
|
|
332
348
|
|
|
333
349
|
// 6. resolutions, against the documents the scope guard approved and nothing else.
|
|
334
350
|
const resolutions = applyResolutions(reconciled.commitments, claims, { documents });
|
|
335
351
|
|
|
336
|
-
// 7. write
|
|
337
|
-
|
|
338
|
-
|
|
352
|
+
// 7. write WHAT CHANGED, keyed on `external_id`.
|
|
353
|
+
//
|
|
354
|
+
// Not the whole corpus. The tenant has no bulk upsert (a list body is
|
|
355
|
+
// rejected — see bd startsim-768w.38.12), so every row is a request, and
|
|
356
|
+
// rewriting untouched rows makes a one-document slice cost one request per row
|
|
357
|
+
// in the workspace. On the live tenant that took a single-document run past
|
|
358
|
+
// the 60-second gateway once there were ninety-eight of them.
|
|
359
|
+
const changed = new Set([...reconciled.touched, ...resolutions.touched]);
|
|
360
|
+
if (corpus.saveCommitments && changed.size) {
|
|
361
|
+
await corpus.saveCommitments(resolutions.commitments.filter((item) => changed.has(item.externalId)));
|
|
339
362
|
}
|
|
340
363
|
|
|
341
364
|
return {
|