@startsimpli/integrations 0.1.6 → 0.2.2
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__/connections.test.tsx +176 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/ConnectionsPage.tsx +229 -11
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/components/pages/types.ts +25 -0
- package/src/corpus/client.ts +85 -4
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +134 -0
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +176 -0
- package/src/corpus/index.ts +46 -0
- package/src/corpus/llm.ts +107 -1
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXTRACTION — conversations in, trackable commitments out (bd startsim-768w.38.25).
|
|
3
|
+
*
|
|
4
|
+
* The bead's thesis is that the hard part is extraction and identity, and that
|
|
5
|
+
* tracking is trivial once the material is structured. This file tests the hard
|
|
6
|
+
* part. It is organised by the four ways it can be wrong, in the order they
|
|
7
|
+
* would destroy the product:
|
|
8
|
+
*
|
|
9
|
+
* 1. IDEMPOTENCE. Run it twice, get the same list. A pipeline that grows the
|
|
10
|
+
* list every time it runs is worse than no pipeline.
|
|
11
|
+
* 2. DEDUPE. One commitment discussed in three meetings is ONE item with three
|
|
12
|
+
* mentions. Get this wrong and the list is instantly useless.
|
|
13
|
+
* 3. RESOLUTION. A later meeting closing an earlier item is the difference
|
|
14
|
+
* between a to-do list and a system of record — and a WRONG close is the
|
|
15
|
+
* one direction a to-do list must never lie in.
|
|
16
|
+
* 4. ISOLATION. Same adversarial posture as `corpus-scope.test.ts`: every case
|
|
17
|
+
* runs against an API deliberately configured to leak.
|
|
18
|
+
*
|
|
19
|
+
* The markdown in the fixtures is the REAL shape — bolded imperatives with an
|
|
20
|
+
* owner in parentheses under a `## Next Steps` heading — because that is what
|
|
21
|
+
* all sixteen documents on the live tandem tenant actually contain.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
applyResolutions,
|
|
27
|
+
commitmentIdentity,
|
|
28
|
+
mergeDrafts,
|
|
29
|
+
reconcile,
|
|
30
|
+
} from '../corpus/commitments';
|
|
31
|
+
import { extractCommitments, rulesExtract } from '../corpus/extract';
|
|
32
|
+
import {
|
|
33
|
+
createFixtureCorpusApi,
|
|
34
|
+
createFixtureExtractor,
|
|
35
|
+
FIXTURE_ORG,
|
|
36
|
+
FIXTURE_OTHER_ORG,
|
|
37
|
+
FIXTURE_TRACKED_DOCUMENTS,
|
|
38
|
+
} from '../corpus/fixtures';
|
|
39
|
+
import { jaccard, outcomeSections, stem, tokenize } from '../corpus/text';
|
|
40
|
+
import type { Commitment, CommitmentDraft, SourceDocument } from '../corpus/types';
|
|
41
|
+
|
|
42
|
+
const TANDEM = { orgId: FIXTURE_ORG };
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// the normaliser identity is built on
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('normalisation', () => {
|
|
49
|
+
it('collapses the inflections two people use for one commitment', () => {
|
|
50
|
+
const same = (a: string, b: string) => expect(stem(a)).toBe(stem(b));
|
|
51
|
+
same('agree', 'agreed');
|
|
52
|
+
same('agree', 'agreement');
|
|
53
|
+
same('agree', 'agreements');
|
|
54
|
+
same('bill', 'billed');
|
|
55
|
+
same('bill', 'billing');
|
|
56
|
+
same('file', 'filed');
|
|
57
|
+
same('file', 'filing');
|
|
58
|
+
same('renew', 'renewal');
|
|
59
|
+
same('sign', 'signed');
|
|
60
|
+
same('schedule', 'scheduling');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does NOT collapse words that merely share a prefix', () => {
|
|
64
|
+
// Over-eager stemming merges two unrelated commitments into one row, which
|
|
65
|
+
// silently deletes work. That is worse than listing something twice.
|
|
66
|
+
const differs = (a: string, b: string) => expect(stem(a)).not.toBe(stem(b));
|
|
67
|
+
differs('bill', 'billion');
|
|
68
|
+
differs('sign', 'signal');
|
|
69
|
+
differs('file', 'filet');
|
|
70
|
+
differs('rent', 'rental');
|
|
71
|
+
differs('patent', 'patient');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('leaves matter numbers alone, because they are the strongest identity there is', () => {
|
|
75
|
+
expect(stem('2040-01')).toBe('2040-01');
|
|
76
|
+
expect(tokenize('Matter 2040-01 drafting')).toContain('2040-01');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// the vendor already did half the work
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
describe('the deterministic pre-pass over the summariser’s own structure', () => {
|
|
85
|
+
const NEXT_STEPS = [
|
|
86
|
+
'## Rexford Paperwork',
|
|
87
|
+
'- Easement documents sent over for signature',
|
|
88
|
+
'',
|
|
89
|
+
'## Next Steps',
|
|
90
|
+
'',
|
|
91
|
+
'- **Complete the equity item today** (Jonathan)',
|
|
92
|
+
'',
|
|
93
|
+
' Needs to go out urgently; other party will forward it once done.',
|
|
94
|
+
'- **Schedule call with Peggy for next week**',
|
|
95
|
+
'',
|
|
96
|
+
' Also coordinate calls with Julia and Lindsay.',
|
|
97
|
+
'- **Set up call with Kevin, Jonathan, Brian (CTO)** (Jonathan, Kevin)',
|
|
98
|
+
'',
|
|
99
|
+
' Align on iManage replacement requirements.',
|
|
100
|
+
].join('\n');
|
|
101
|
+
|
|
102
|
+
it('finds the outcome block whatever the summariser called it', () => {
|
|
103
|
+
expect(outcomeSections('## Next Steps\n- a')).toHaveLength(1);
|
|
104
|
+
expect(outcomeSections('## Action Items\n- a')).toHaveLength(1);
|
|
105
|
+
expect(outcomeSections('### Follow-ups\n- a')).toHaveLength(1);
|
|
106
|
+
expect(outcomeSections('## Tender Pipeline\n- a')).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reads the bullets as commitments with NO model involved', () => {
|
|
110
|
+
const drafts = rulesExtract(doc('d1', 'Rexford', NEXT_STEPS));
|
|
111
|
+
expect(drafts.map((draft) => draft.title)).toEqual([
|
|
112
|
+
'Complete the equity item today',
|
|
113
|
+
'Schedule call with Peggy for next week',
|
|
114
|
+
'Set up call with Kevin, Jonathan, Brian (CTO)',
|
|
115
|
+
]);
|
|
116
|
+
expect(drafts.every((draft) => draft.extractor === 'rules')).toBe(true);
|
|
117
|
+
// The detail paragraph under the bullet belongs to the bullet.
|
|
118
|
+
expect(drafts[0].detail).toContain('Needs to go out urgently');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reads the owner out of the parentheses the summariser wrote', () => {
|
|
122
|
+
const drafts = rulesExtract(doc('d1', 'Rexford', NEXT_STEPS));
|
|
123
|
+
expect(drafts[0].ownerName).toBe('Jonathan');
|
|
124
|
+
expect(drafts[1].ownerName).toBeUndefined(); // no owner stated, and none invented
|
|
125
|
+
// A trailing parenthetical that is part of the sentence, not an owner, must
|
|
126
|
+
// not become one — "(CTO)" is a job title inside the title itself.
|
|
127
|
+
expect(drafts[2].ownerName).toBe('Jonathan, Kevin');
|
|
128
|
+
expect(drafts[2].title).toContain('(CTO)');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('quotes the source verbatim, so every row can be checked against the record', () => {
|
|
132
|
+
const drafts = rulesExtract(doc('d1', 'Rexford', NEXT_STEPS));
|
|
133
|
+
for (const draft of drafts) {
|
|
134
|
+
expect(NEXT_STEPS).toContain(draft.quote);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('produces a byte-stable origin key — no model, so no drift between runs', () => {
|
|
139
|
+
const first = rulesExtract(doc('d1', 'Rexford', NEXT_STEPS));
|
|
140
|
+
const second = rulesExtract(doc('d1', 'Rexford', NEXT_STEPS));
|
|
141
|
+
expect(first.map((draft) => draft.originKey)).toEqual(second.map((draft) => draft.originKey));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('finds nothing, rather than guessing, in a document with no outcome block', () => {
|
|
145
|
+
expect(rulesExtract(doc('d2', 'Rambling', '## Notes\n- we talked about things'))).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// 1. IDENTITY AND IDEMPOTENCE
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe('identity: what makes two statements the same commitment', () => {
|
|
154
|
+
it('keys on the SUBJECT plus the action, not on the model’s wording', () => {
|
|
155
|
+
const a = commitmentIdentity({ title: 'Set a drafting deadline for matter 2040-01' });
|
|
156
|
+
const b = commitmentIdentity({ title: 'Deadline needs to be set on 2040-01', subject: 'matter 2040-01' });
|
|
157
|
+
expect(a.subjectKey).toBe('2040-01');
|
|
158
|
+
expect(b.subjectKey).toBe('2040-01');
|
|
159
|
+
// Not byte-equal keys — the wording differs — but near enough to merge, which
|
|
160
|
+
// is what `reconcile` uses. The exact key is the fast path, not the contract.
|
|
161
|
+
expect(jaccard(a.actionTokens, b.actionTokens)).toBeGreaterThanOrEqual(0.5);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('prefers a matter number over prose when the text contains one', () => {
|
|
165
|
+
expect(commitmentIdentity({ title: 'Chase Dr. Zhang for comments on 2148' }).subjectKey).toBe('2148');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('falls back to the stated subject when there is no identifier', () => {
|
|
169
|
+
const identity = commitmentIdentity({ title: 'Send the equity sheet', subject: 'Harry Holcomb' });
|
|
170
|
+
expect(identity.subjectKey).toBe('harri.holcomb');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('gives two genuinely different commitments different keys', () => {
|
|
174
|
+
const a = commitmentIdentity({ title: 'Send the equity sheet to Harry', subject: 'Pigoso' });
|
|
175
|
+
const b = commitmentIdentity({ title: 'Restore remote desktop access', subject: 'QuickBooks' });
|
|
176
|
+
expect(a.dedupeKey).not.toBe(b.dedupeKey);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('idempotence: running it twice changes nothing', () => {
|
|
181
|
+
it('re-extracting the same document does not duplicate or inflate', () => {
|
|
182
|
+
const drafts = rulesExtract(FIXTURE_TRACKED_DOCUMENTS[0]);
|
|
183
|
+
const first = reconcile([], mergeDrafts(drafts), { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
184
|
+
const second = reconcile(first.commitments, mergeDrafts(drafts), {
|
|
185
|
+
scope: TANDEM,
|
|
186
|
+
documents: FIXTURE_TRACKED_DOCUMENTS,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(second.commitments).toHaveLength(first.commitments.length);
|
|
190
|
+
expect(second.created).toBe(0);
|
|
191
|
+
for (const commitment of second.commitments) {
|
|
192
|
+
// The crux: a mention is keyed by (document, originKey), so a second pass
|
|
193
|
+
// over the same document REPLACES its mention rather than appending one.
|
|
194
|
+
expect(commitment.mentions).toHaveLength(1);
|
|
195
|
+
}
|
|
196
|
+
expect(second.commitments.map((item) => item.externalId).sort()).toEqual(
|
|
197
|
+
first.commitments.map((item) => item.externalId).sort(),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('keeps a human’s edit when the extractor runs again over the same source', () => {
|
|
202
|
+
const drafts = rulesExtract(FIXTURE_TRACKED_DOCUMENTS[0]);
|
|
203
|
+
const first = reconcile([], mergeDrafts(drafts), { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
204
|
+
// Somebody ticked it off on the screen.
|
|
205
|
+
const edited = first.commitments.map((item, index) =>
|
|
206
|
+
index === 0 ? { ...item, status: 'done' as const, statusReason: 'Closed by Jonathan' } : item,
|
|
207
|
+
);
|
|
208
|
+
const second = reconcile(edited, mergeDrafts(drafts), { scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
209
|
+
|
|
210
|
+
const same = second.commitments.find((item) => item.externalId === edited[0].externalId);
|
|
211
|
+
expect(same?.status).toBe('done');
|
|
212
|
+
expect(same?.statusReason).toBe('Closed by Jonathan');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// 2. DEDUPE ACROSS MEETINGS
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe('dedupe: one commitment, three meetings, one row', () => {
|
|
221
|
+
const mentionsOf = (items: Commitment[], subject: string) =>
|
|
222
|
+
items.find((item) => item.subjectKey === subject)?.mentions ?? [];
|
|
223
|
+
|
|
224
|
+
it('collapses the same commitment stated three different ways', () => {
|
|
225
|
+
const drafts: CommitmentDraft[] = [
|
|
226
|
+
draft('m1', 'Set a drafting deadline for matter 2040-01', '2026-08-01'),
|
|
227
|
+
draft('m2', 'Deadline still needs setting on 2040-01', '2026-08-04'),
|
|
228
|
+
draft('m3', 'Agree the drafting deadline for 2040-01', '2026-08-07'),
|
|
229
|
+
];
|
|
230
|
+
const merged = mergeDrafts(drafts);
|
|
231
|
+
const { commitments } = reconcile([], merged, {
|
|
232
|
+
scope: TANDEM,
|
|
233
|
+
documents: [documentFor('m1', '2026-08-01'), documentFor('m2', '2026-08-04'), documentFor('m3', '2026-08-07')],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(commitments).toHaveLength(1);
|
|
237
|
+
expect(mentionsOf(commitments, '2040-01')).toHaveLength(3);
|
|
238
|
+
// The FIRST time it was raised is the useful date on the screen — "this has
|
|
239
|
+
// been outstanding since the 1st" is the sentence the product exists to say.
|
|
240
|
+
expect(commitments[0].firstSeenAt).toBe('2026-08-01T00:00:00.000Z');
|
|
241
|
+
expect(commitments[0].lastSeenAt).toBe('2026-08-07T00:00:00.000Z');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does NOT merge two different commitments about the same subject', () => {
|
|
245
|
+
const merged = mergeDrafts([
|
|
246
|
+
draft('m1', 'Set a drafting deadline for 2040-01', '2026-08-01'),
|
|
247
|
+
draft('m2', 'Send the filing receipt for 2040-01 to the client', '2026-08-02'),
|
|
248
|
+
]);
|
|
249
|
+
const { commitments } = reconcile([], merged, {
|
|
250
|
+
scope: TANDEM,
|
|
251
|
+
documents: [documentFor('m1', '2026-08-01'), documentFor('m2', '2026-08-02')],
|
|
252
|
+
});
|
|
253
|
+
expect(commitments).toHaveLength(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('does NOT merge the same words about different subjects', () => {
|
|
257
|
+
// The failure that makes the list actively dangerous: "chase for comments"
|
|
258
|
+
// on two different matters is two different obligations.
|
|
259
|
+
const merged = mergeDrafts([
|
|
260
|
+
draft('m1', 'Chase for comments on 2148', '2026-08-01'),
|
|
261
|
+
draft('m2', 'Chase for comments on 2198', '2026-08-01'),
|
|
262
|
+
]);
|
|
263
|
+
const { commitments } = reconcile([], merged, {
|
|
264
|
+
scope: TANDEM,
|
|
265
|
+
documents: [documentFor('m1', '2026-08-01'), documentFor('m2', '2026-08-01')],
|
|
266
|
+
});
|
|
267
|
+
expect(commitments).toHaveLength(2);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('keeps the EARLIEST row’s identity when a later mention merges into it', () => {
|
|
271
|
+
// The id is what a citation, a link and a human's edit hang off. A merge
|
|
272
|
+
// that re-keys the row breaks all three.
|
|
273
|
+
const first = reconcile([], mergeDrafts([draft('m1', 'Set a deadline for 2040-01', '2026-08-01')]), {
|
|
274
|
+
scope: TANDEM,
|
|
275
|
+
documents: [documentFor('m1', '2026-08-01')],
|
|
276
|
+
});
|
|
277
|
+
const originalId = first.commitments[0].externalId;
|
|
278
|
+
|
|
279
|
+
const second = reconcile(
|
|
280
|
+
first.commitments,
|
|
281
|
+
mergeDrafts([draft('m2', 'Deadline needs to be set for matter 2040-01', '2026-08-05')]),
|
|
282
|
+
{ scope: TANDEM, documents: [documentFor('m2', '2026-08-05')] },
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(second.commitments).toHaveLength(1);
|
|
286
|
+
expect(second.commitments[0].externalId).toBe(originalId);
|
|
287
|
+
expect(second.merged).toBe(1);
|
|
288
|
+
expect(second.created).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// 3. RESOLUTION DETECTION
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
describe('resolution: a later meeting closes an earlier item', () => {
|
|
297
|
+
function openItem(): Commitment[] {
|
|
298
|
+
return reconcile([], mergeDrafts([draft('m1', 'Chase Dr. Zhang for comments on 2148', '2026-08-01')]), {
|
|
299
|
+
scope: TANDEM,
|
|
300
|
+
documents: [documentFor('m1', '2026-08-01')],
|
|
301
|
+
}).commitments;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it('closes the item and records WHAT closed it', () => {
|
|
305
|
+
const { commitments, resolved } = applyResolutions(
|
|
306
|
+
openItem(),
|
|
307
|
+
[
|
|
308
|
+
{
|
|
309
|
+
documentId: 'm2',
|
|
310
|
+
subject: '2148',
|
|
311
|
+
statement: 'Comments came back from Dr. Zhang on 2148',
|
|
312
|
+
quote: '2148: comments received from Dr. Zhang, ready to proceed',
|
|
313
|
+
outcome: 'done',
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
{ documents: [documentFor('m2', '2026-08-06')] },
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
expect(resolved).toBe(1);
|
|
320
|
+
expect(commitments[0].status).toBe('done');
|
|
321
|
+
expect(commitments[0].resolvedBy).toBe('m2');
|
|
322
|
+
expect(commitments[0].resolvedAt).toBe('2026-08-06T00:00:00.000Z');
|
|
323
|
+
// The quote is the whole point: "why does it say this is done" has to be
|
|
324
|
+
// answerable without opening the meeting.
|
|
325
|
+
expect(commitments[0].statusReason).toContain('Dr. Zhang');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('refuses to close an item raised AFTER the meeting that supposedly closed it', () => {
|
|
329
|
+
const later = reconcile([], mergeDrafts([draft('m9', 'Chase Dr. Zhang for comments on 2148', '2026-09-01')]), {
|
|
330
|
+
scope: TANDEM,
|
|
331
|
+
documents: [documentFor('m9', '2026-09-01')],
|
|
332
|
+
}).commitments;
|
|
333
|
+
|
|
334
|
+
const { commitments, resolved } = applyResolutions(
|
|
335
|
+
later,
|
|
336
|
+
[{ documentId: 'm2', subject: '2148', statement: 'Comments came back from Dr. Zhang', quote: 'q', outcome: 'done' }],
|
|
337
|
+
{ documents: [documentFor('m2', '2026-08-06')] },
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect(resolved).toBe(0);
|
|
341
|
+
expect(commitments[0].status).toBe('open');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does NOT close a different item that happens to share the subject', () => {
|
|
345
|
+
const items = reconcile(
|
|
346
|
+
[],
|
|
347
|
+
mergeDrafts([draft('m1', 'Send the filing receipt for 2148 to the client', '2026-08-01')]),
|
|
348
|
+
{ scope: TANDEM, documents: [documentFor('m1', '2026-08-01')] },
|
|
349
|
+
).commitments;
|
|
350
|
+
|
|
351
|
+
const { commitments, needsReview, resolved } = applyResolutions(
|
|
352
|
+
items,
|
|
353
|
+
[{ documentId: 'm2', subject: '2148', statement: 'Comments came back from Dr. Zhang', quote: 'q', outcome: 'done' }],
|
|
354
|
+
{ documents: [documentFor('m2', '2026-08-06')] },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(resolved).toBe(0);
|
|
358
|
+
expect(commitments[0].status).toBe('open');
|
|
359
|
+
// Weak matches are SURFACED, not applied and not thrown away. A to-do list
|
|
360
|
+
// that quietly empties itself is the one failure mode with no symptom.
|
|
361
|
+
expect(needsReview.length).toBeGreaterThan(0);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('never closes an item a human already moved', () => {
|
|
365
|
+
const items = openItem().map((item) => ({ ...item, status: 'dropped' as const, statusReason: 'Not doing it' }));
|
|
366
|
+
const { commitments, resolved } = applyResolutions(
|
|
367
|
+
items,
|
|
368
|
+
[{ documentId: 'm2', subject: '2148', statement: 'Chase Dr. Zhang for comments on 2148', quote: 'q', outcome: 'done' }],
|
|
369
|
+
{ documents: [documentFor('m2', '2026-08-06')] },
|
|
370
|
+
);
|
|
371
|
+
expect(resolved).toBe(0);
|
|
372
|
+
expect(commitments[0].status).toBe('dropped');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// the pipeline, end to end
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe('extractCommitments', () => {
|
|
381
|
+
it('sends every document in ONE call, with ids the model must echo', async () => {
|
|
382
|
+
const extractor = createFixtureExtractor();
|
|
383
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
384
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
385
|
+
|
|
386
|
+
await extractCommitments({ scope: TANDEM }, { corpus, extractor });
|
|
387
|
+
|
|
388
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
389
|
+
expect(spy.mock.calls[0][0].documents.length).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('tells the model what the rules already found, so it adds rather than restates', async () => {
|
|
393
|
+
const extractor = createFixtureExtractor();
|
|
394
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
395
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
396
|
+
|
|
397
|
+
await extractCommitments({ scope: TANDEM }, { corpus, extractor });
|
|
398
|
+
|
|
399
|
+
const sent = spy.mock.calls[0][0].documents;
|
|
400
|
+
expect(sent.some((document) => document.alreadyFound.length > 0)).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('reports rules and model contributions separately, so the model’s value is measurable', async () => {
|
|
404
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
405
|
+
const result = await extractCommitments(
|
|
406
|
+
{ scope: TANDEM },
|
|
407
|
+
{ corpus, extractor: createFixtureExtractor() },
|
|
408
|
+
);
|
|
409
|
+
expect(result.fromRules).toBeGreaterThan(0);
|
|
410
|
+
expect(result.documentsProcessed).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
411
|
+
expect(result.created).toBeGreaterThan(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('still produces the vendor’s own items when the model is unreachable', async () => {
|
|
415
|
+
// The deterministic half must not depend on the probabilistic half. A
|
|
416
|
+
// provider outage should cost the extras, not the whole list.
|
|
417
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
418
|
+
const extractor = createFixtureExtractor();
|
|
419
|
+
const result = await extractCommitments(
|
|
420
|
+
{ scope: TANDEM },
|
|
421
|
+
{
|
|
422
|
+
corpus,
|
|
423
|
+
extractor: {
|
|
424
|
+
describe: extractor.describe,
|
|
425
|
+
extract: async () => { throw new Error('provider is down'); },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
expect(result.fromRules).toBeGreaterThan(0);
|
|
430
|
+
expect(result.fromModel).toBe(0);
|
|
431
|
+
expect(result.created).toBeGreaterThan(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('refuses outright when the session carries no workspace claim', async () => {
|
|
435
|
+
const corpus = createFixtureCorpusApi({ leakScope: true, documents: FIXTURE_TRACKED_DOCUMENTS });
|
|
436
|
+
const listDocuments = vi.spyOn(corpus, 'listDocuments');
|
|
437
|
+
const result = await extractCommitments(
|
|
438
|
+
{ scope: { orgId: '' } },
|
|
439
|
+
{ corpus, extractor: createFixtureExtractor() },
|
|
440
|
+
);
|
|
441
|
+
expect(result.created).toBe(0);
|
|
442
|
+
expect(listDocuments).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// 4. ISOLATION — against an API configured to leak
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
describe('extraction holds the workspace boundary', () => {
|
|
451
|
+
const foreign = (document: SourceDocument): SourceDocument => ({
|
|
452
|
+
...document,
|
|
453
|
+
id: `nw_${document.id}`,
|
|
454
|
+
scope: { orgId: FIXTURE_OTHER_ORG },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('never sends another workspace’s document to the extractor', async () => {
|
|
458
|
+
const documents = [...FIXTURE_TRACKED_DOCUMENTS, ...FIXTURE_TRACKED_DOCUMENTS.map(foreign)];
|
|
459
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true, documents });
|
|
460
|
+
const extractor = createFixtureExtractor();
|
|
461
|
+
const spy = vi.spyOn(extractor, 'extract');
|
|
462
|
+
|
|
463
|
+
const result = await extractCommitments({ scope: TANDEM }, { corpus, extractor });
|
|
464
|
+
|
|
465
|
+
const sentIds = spy.mock.calls[0][0].documents.map((document) => document.id);
|
|
466
|
+
expect(sentIds.every((id) => !id.startsWith('nw_'))).toBe(true);
|
|
467
|
+
expect(result.droppedOutOfScope).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('never writes a commitment carrying another workspace’s scope', async () => {
|
|
471
|
+
const documents = [...FIXTURE_TRACKED_DOCUMENTS, ...FIXTURE_TRACKED_DOCUMENTS.map(foreign)];
|
|
472
|
+
const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true, documents });
|
|
473
|
+
const saved: Commitment[] = [];
|
|
474
|
+
(corpus as { saveCommitments?: unknown }).saveCommitments = async (items: Commitment[]) => {
|
|
475
|
+
saved.push(...items);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
await extractCommitments({ scope: TANDEM }, { corpus, extractor: createFixtureExtractor() });
|
|
479
|
+
|
|
480
|
+
expect(saved.length).toBeGreaterThan(0);
|
|
481
|
+
expect(saved.every((item) => item.scope.orgId === FIXTURE_ORG)).toBe(true);
|
|
482
|
+
expect(saved.every((item) => item.mentions.every((mention) => !mention.documentId.startsWith('nw_')))).toBe(true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('a resolution from another workspace cannot close this workspace’s item', () => {
|
|
486
|
+
const items = reconcile([], mergeDrafts([draft('m1', 'Chase Dr. Zhang for comments on 2148', '2026-08-01')]), {
|
|
487
|
+
scope: TANDEM,
|
|
488
|
+
documents: [documentFor('m1', '2026-08-01')],
|
|
489
|
+
}).commitments;
|
|
490
|
+
|
|
491
|
+
// The resolving document is not in the in-scope set handed to `applyResolutions`,
|
|
492
|
+
// which is the only place a document id is trusted.
|
|
493
|
+
const { commitments, resolved } = applyResolutions(
|
|
494
|
+
items,
|
|
495
|
+
[{ documentId: 'nw_m2', subject: '2148', statement: 'Comments came back from Dr. Zhang on 2148', quote: 'q', outcome: 'done' }],
|
|
496
|
+
{ documents: [documentFor('m1', '2026-08-01')] },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
expect(resolved).toBe(0);
|
|
500
|
+
expect(commitments[0].status).toBe('open');
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
function doc(id: string, title: string, body: string, occurredAt = '2026-08-01T00:00:00Z'): SourceDocument {
|
|
507
|
+
return {
|
|
508
|
+
id,
|
|
509
|
+
title,
|
|
510
|
+
body,
|
|
511
|
+
sections: [],
|
|
512
|
+
occurredAt,
|
|
513
|
+
source: 'granola',
|
|
514
|
+
sourceLabel: 'Granola',
|
|
515
|
+
sourceRef: `granola:${id}`,
|
|
516
|
+
participants: [],
|
|
517
|
+
scope: { orgId: FIXTURE_ORG },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function documentFor(id: string, date: string): SourceDocument {
|
|
522
|
+
return doc(id, `Meeting ${id}`, '', `${date}T00:00:00Z`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function draft(documentId: string, title: string, date: string): CommitmentDraft {
|
|
526
|
+
return {
|
|
527
|
+
documentId,
|
|
528
|
+
originKey: `${documentId}:${title.slice(0, 24)}`,
|
|
529
|
+
subtype: 'action',
|
|
530
|
+
title,
|
|
531
|
+
detail: '',
|
|
532
|
+
status: 'open',
|
|
533
|
+
quote: title,
|
|
534
|
+
extractor: 'rules',
|
|
535
|
+
...(date ? {} : {}),
|
|
536
|
+
};
|
|
537
|
+
}
|