@startsimpli/integrations 0.2.3 → 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",
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 });
@@ -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
- const drop = new Set(subjectTokens);
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 { subjectKey, actionKey, actionTokens, dedupeKey: hash(`${subjectKey}|${actionKey}`) };
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 (!a.subjectKey || a.subjectKey !== b.subjectKey) return false;
144
- return jaccard(a.actionTokens, b.actionTokens) >= MERGE_THRESHOLD;
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, identityOf(commitment).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;
@@ -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
- for (const draft of ruleDrafts) {
296
- foundByDocument.set(draft.documentId, [...(foundByDocument.get(draft.documentId) ?? []), draft.title]);
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,18 +339,10 @@ export async function extractCommitments(
315
339
  draft = { items: [], resolutions: [] };
316
340
  }
317
341
 
318
- const allowed = new Set(documents.map((document) => document.id));
319
- const modelDrafts = normaliseItems(draft.items ?? [], allowed);
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