@startsimpli/integrations 0.1.6 → 0.2.1

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * IDENTITY, DEDUPE AND RESOLUTION — the crux of bd startsim-768w.38.25.
3
+ *
4
+ * Extraction is the interesting problem; identity is the one that decides
5
+ * whether the product is usable. A pipeline that produces a perfect list on
6
+ * Monday and a doubled list on Tuesday is not a system of record, and a list
7
+ * that shows one commitment three times because three meetings mentioned it is
8
+ * the thing a user abandons on first contact.
9
+ *
10
+ * THE IDENTITY PROBLEM, STATED HONESTLY. A commitment's identity cannot be a
11
+ * string a model emitted, because the model will emit a different string next
12
+ * time. It cannot be the document it came from either, because the whole point
13
+ * is that one commitment spans several meetings. So identity is RESOLVED, not
14
+ * computed, and it is resolved in two tiers:
15
+ *
16
+ * TIER 1 — the exact key. `dedupeKey = hash(subjectKey | actionKey)`, where
17
+ * both halves are normalised stems and the subject prefers a matter number
18
+ * over prose. This is the fast path and it is what the tenant's `external_id`
19
+ * upsert keys on, so two concurrent runs converge on one row instead of
20
+ * racing.
21
+ *
22
+ * TIER 2 — the near match. Same non-empty `subjectKey` plus enough overlap in
23
+ * the action stems. This is what actually carries the cross-meeting dedupe,
24
+ * because "set a drafting deadline for 2040-01" and "deadline still needs
25
+ * setting on 2040-01" are the same obligation and will never hash the same.
26
+ *
27
+ * WHY NOT ASK A MODEL TO DEDUPE. Because the user asked for deterministic, and
28
+ * they were right to: a matcher whose answer changes between runs turns the
29
+ * item id — the thing a link, a citation and a human's tick-box all hang off —
30
+ * into a coin flip. Every rule below is arithmetic over stems, so a merge can be
31
+ * explained ("both are about 2040-01 and share set/deadline") and argued with.
32
+ *
33
+ * MENTIONS ARE THE UNIT OF IDEMPOTENCY, and this is the single most important
34
+ * line in the file. A mention is keyed by (documentId, originKey). Re-running
35
+ * extraction over a document REPLACES that document's mentions; it can never
36
+ * append a second copy. That is what makes a second pass over the same corpus a
37
+ * no-op rather than a doubling, and it is why `mention_count` is trustworthy
38
+ * enough to sort by.
39
+ */
40
+ import { jaccard, tokenize } from './text';
41
+ import type {
42
+ Commitment,
43
+ CommitmentDraft,
44
+ CommitmentMention,
45
+ CorpusScope,
46
+ ResolutionClaim,
47
+ SourceDocument,
48
+ } from './types';
49
+
50
+ /** Bumped when a change to this file or the prompt would re-derive items differently. */
51
+ export const EXTRACTOR_VERSION = 'rules+model@1';
52
+
53
+ /**
54
+ * How much of the action has to overlap before two statements are one
55
+ * commitment, GIVEN that they already agree on the subject.
56
+ *
57
+ * 0.5 is where "set a drafting deadline" meets "deadline still needs setting"
58
+ * and where "set a drafting deadline" does NOT meet "send the filing receipt".
59
+ * Both of those are real pairs from the live corpus, and the gap between them is
60
+ * wide, which is the only reason a single constant is defensible here.
61
+ */
62
+ const MERGE_THRESHOLD = 0.5;
63
+
64
+ /**
65
+ * The bar a resolution has to clear before it CLOSES something, which is higher
66
+ * than the bar for merging because the failure is worse. A wrong merge shows one
67
+ * item where there were two; a wrong close shows nothing where there was work,
68
+ * and an empty list has no symptom.
69
+ */
70
+ const RESOLVE_THRESHOLD = 0.45;
71
+
72
+ /** With no subject to anchor on, the wording has to carry the whole claim. */
73
+ const UNANCHORED_RESOLVE_THRESHOLD = 0.7;
74
+
75
+ /**
76
+ * Words that are about the FORM of a commitment rather than its content.
77
+ * Removing them stops "set a deadline on the matter" and "set a deadline on the
78
+ * item" from looking like different obligations.
79
+ */
80
+ const ACTION_NOISE = new Set(['matter', 'item', 'thing', 'still', 'also', 'just', 'again', 'per', 'via']);
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // identity
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * A matter/case number, which is by far the strongest identity signal this
88
+ * corpus has: the live tenant's records are full of `2040-01`, `2148`, `2198`,
89
+ * `2205`, `2217`, and two statements carrying the same one are almost always
90
+ * about the same thing.
91
+ *
92
+ * The hyphenated form is tried FIRST, because the bare-four-digit rule has to
93
+ * exclude 19xx/20xx to avoid reading a year as a matter number — and `2040-01`
94
+ * would otherwise be thrown away by that exclusion. A tenant whose matters are
95
+ * numbered in the 2000s loses this signal and falls back to prose; that is a
96
+ * stated limit, not a silent one.
97
+ */
98
+ const MATTER_HYPHENATED = /\b\d{4}-\d{2}\b/;
99
+ const MATTER_BARE = /\b(?!(?:19|20)\d{2}\b)\d{4}\b/;
100
+
101
+ export interface CommitmentIdentity {
102
+ subjectKey: string;
103
+ actionKey: string;
104
+ actionTokens: string[];
105
+ dedupeKey: string;
106
+ }
107
+
108
+ /**
109
+ * What a commitment is ABOUT and what is being DONE, normalised.
110
+ *
111
+ * The subject is taken from an identifier when the text contains one, then from
112
+ * the extractor's stated subject, and only then from nothing — an empty subject
113
+ * is honest and simply means Tier 2 has no anchor and will demand a much closer
114
+ * wording match.
115
+ */
116
+ export function commitmentIdentity(input: {
117
+ title: string;
118
+ subject?: string;
119
+ detail?: string;
120
+ }): CommitmentIdentity {
121
+ const title = input.title ?? '';
122
+ const subject = input.subject ?? '';
123
+ const haystack = `${subject} ${title} ${input.detail ?? ''}`;
124
+
125
+ const matter =
126
+ MATTER_HYPHENATED.exec(haystack)?.[0] ?? MATTER_BARE.exec(haystack)?.[0] ?? '';
127
+
128
+ const subjectTokens = matter ? [matter] : unique(tokenize(subject));
129
+ const subjectKey = matter ? matter : subjectTokens.slice().sort().join('.');
130
+
131
+ const drop = new Set(subjectTokens);
132
+ const actionTokens = unique(
133
+ tokenize(title).filter((token) => !drop.has(token) && !ACTION_NOISE.has(token)),
134
+ );
135
+ const actionKey = actionTokens.slice().sort().join('.');
136
+
137
+ return { subjectKey, actionKey, actionTokens, dedupeKey: hash(`${subjectKey}|${actionKey}`) };
138
+ }
139
+
140
+ /** Do two identities describe one commitment? Tier 1, then Tier 2. */
141
+ export function isSameCommitment(a: CommitmentIdentity, b: CommitmentIdentity): boolean {
142
+ 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;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // merging drafts within one run
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export interface MergedDraft {
152
+ identity: CommitmentIdentity;
153
+ /** Every statement that turned out to be this one commitment. */
154
+ drafts: CommitmentDraft[];
155
+ }
156
+
157
+ /**
158
+ * Collapse a run's drafts into one entry per commitment.
159
+ *
160
+ * Grouped against the FIRST draft in each group rather than against the group's
161
+ * accumulated union: a union grows every time it absorbs something and ends up
162
+ * matching everything, which is how a dedupe quietly turns into a single row
163
+ * called "do the thing".
164
+ */
165
+ export function mergeDrafts(drafts: CommitmentDraft[]): MergedDraft[] {
166
+ const groups: MergedDraft[] = [];
167
+ for (const draft of drafts) {
168
+ const identity = commitmentIdentity(draft);
169
+ const existing = groups.find((group) => isSameCommitment(group.identity, identity));
170
+ if (existing) existing.drafts.push(draft);
171
+ else groups.push({ identity, drafts: [draft] });
172
+ }
173
+ return groups;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // reconciling a run against what is already stored
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export interface ReconcileContext {
181
+ scope: CorpusScope;
182
+ /**
183
+ * The IN-SCOPE documents this run is allowed to cite. A draft naming anything
184
+ * else contributes nothing: a document id is the one field an extractor could
185
+ * hallucinate into a cross-workspace reference, and it is checked against the
186
+ * set the scope guard already approved rather than trusted.
187
+ */
188
+ documents: SourceDocument[];
189
+ now?: number;
190
+ }
191
+
192
+ export interface ReconcileResult {
193
+ commitments: Commitment[];
194
+ created: number;
195
+ merged: number;
196
+ }
197
+
198
+ export function reconcile(
199
+ existing: Commitment[],
200
+ groups: MergedDraft[],
201
+ context: ReconcileContext,
202
+ ): ReconcileResult {
203
+ const byId = new Map(context.documents.map((document) => [document.id, document]));
204
+ const out = existing.map(cloneCommitment);
205
+ let created = 0;
206
+ let merged = 0;
207
+
208
+ for (const group of groups) {
209
+ const mentions = group.drafts
210
+ .filter((draft) => byId.has(draft.documentId))
211
+ .map((draft) => toMention(draft, byId.get(draft.documentId)!));
212
+ if (mentions.length === 0) continue;
213
+
214
+ const lead = group.drafts.find((draft) => byId.has(draft.documentId))!;
215
+ const match = out.find((commitment) => isSameCommitment(identityOf(commitment), group.identity));
216
+
217
+ if (match) {
218
+ merged += 1;
219
+ applyMentions(match, mentions);
220
+ // The extractor NEVER overwrites a status, an owner or a due date that is
221
+ // already recorded. A person ticking something off on the screen must
222
+ // survive the next run, or the screen is a suggestion box.
223
+ if (!match.ownerName && lead.ownerName) match.ownerName = lead.ownerName;
224
+ if (!match.dueAt && lead.dueAt) match.dueAt = lead.dueAt;
225
+ if (!match.detail && lead.detail) match.detail = lead.detail;
226
+ continue;
227
+ }
228
+
229
+ created += 1;
230
+ const document = byId.get(lead.documentId)!;
231
+ const fresh: Commitment = {
232
+ id: `commit:${group.identity.dedupeKey}`,
233
+ externalId: `commit:${group.identity.dedupeKey}`,
234
+ dedupeKey: group.identity.dedupeKey,
235
+ subtype: lead.subtype || 'action',
236
+ title: lead.title,
237
+ detail: lead.detail ?? '',
238
+ status: lead.status || 'open',
239
+ ...(lead.ownerName ? { ownerName: lead.ownerName } : {}),
240
+ ...(lead.subject ? { subject: lead.subject } : {}),
241
+ subjectKey: group.identity.subjectKey,
242
+ actionKey: group.identity.actionKey,
243
+ ...(lead.dueAt ? { dueAt: lead.dueAt } : {}),
244
+ mentions: [],
245
+ firstSeenAt: null,
246
+ lastSeenAt: null,
247
+ extractorVersion: EXTRACTOR_VERSION,
248
+ source: document.source,
249
+ sourceLabel: document.sourceLabel,
250
+ // The commitment inherits the CALLER's scope, not the document's. They are
251
+ // the same by construction here — the documents were already filtered —
252
+ // but writing the caller's scope means a row can never be minted carrying
253
+ // a workspace the asking principal does not hold.
254
+ scope: context.scope,
255
+ };
256
+ applyMentions(fresh, mentions);
257
+ out.push(fresh);
258
+ }
259
+
260
+ return { commitments: out, created, merged };
261
+ }
262
+
263
+ /**
264
+ * Attach this run's mentions, REPLACING any the same document contributed before.
265
+ * See the note at the top of the file: this is the whole of idempotency.
266
+ */
267
+ function applyMentions(commitment: Commitment, mentions: CommitmentMention[]) {
268
+ const incoming = new Set(mentions.map(mentionKey));
269
+ const kept = commitment.mentions.filter((mention) => !incoming.has(mentionKey(mention)));
270
+ // Also drop any earlier mention from the same DOCUMENT whose origin key has
271
+ // changed — a re-worded model output must not leave its previous self behind.
272
+ const documents = new Set(mentions.map((mention) => mention.documentId));
273
+ commitment.mentions = [
274
+ ...kept.filter((mention) => !documents.has(mention.documentId)),
275
+ ...mentions,
276
+ ].sort((a, b) => (a.occurredAt ?? '').localeCompare(b.occurredAt ?? ''));
277
+
278
+ const dates = commitment.mentions.map((mention) => mention.occurredAt).filter(Boolean) as string[];
279
+ commitment.firstSeenAt = dates[0] ?? null;
280
+ commitment.lastSeenAt = dates[dates.length - 1] ?? null;
281
+ }
282
+
283
+ const mentionKey = (mention: CommitmentMention) => `${mention.documentId}::${mention.originKey}`;
284
+
285
+ function toMention(draft: CommitmentDraft, document: SourceDocument): CommitmentMention {
286
+ return {
287
+ documentId: draft.documentId,
288
+ originKey: draft.originKey,
289
+ occurredAt: iso(document.occurredAt),
290
+ quote: draft.quote ?? '',
291
+ extractor: draft.extractor,
292
+ };
293
+ }
294
+
295
+ function identityOf(commitment: Commitment): CommitmentIdentity {
296
+ return {
297
+ subjectKey: commitment.subjectKey,
298
+ actionKey: commitment.actionKey,
299
+ actionTokens: commitment.actionKey ? commitment.actionKey.split('.') : [],
300
+ dedupeKey: commitment.dedupeKey,
301
+ };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // resolution
306
+ // ---------------------------------------------------------------------------
307
+
308
+ export interface ResolutionResult {
309
+ commitments: Commitment[];
310
+ resolved: number;
311
+ needsReview: Array<{ commitmentId: string; documentId: string; quote: string }>;
312
+ }
313
+
314
+ /**
315
+ * Close items a LATER meeting says are done.
316
+ *
317
+ * This is the difference between a to-do list and a system of record, and it is
318
+ * also the most dangerous thing in the pipeline, so every guard below exists
319
+ * because of a specific way it could lie:
320
+ *
321
+ * • the resolving document must be IN SCOPE — a document id is the one field
322
+ * an extractor could invent into another workspace's record;
323
+ * • the item must have been raised at or before that meeting, or a note from
324
+ * August is closing work agreed in September;
325
+ * • only `open` and `blocked` items move, so a human's own `done`/`dropped`
326
+ * is never rewritten by a re-run;
327
+ * • a match too weak to act on is REPORTED rather than applied or discarded,
328
+ * because the failure mode of a self-emptying to-do list has no symptom.
329
+ */
330
+ export function applyResolutions(
331
+ commitments: Commitment[],
332
+ claims: ResolutionClaim[],
333
+ context: { documents: SourceDocument[] },
334
+ ): ResolutionResult {
335
+ const byId = new Map(context.documents.map((document) => [document.id, document]));
336
+ const out = commitments.map(cloneCommitment);
337
+ const needsReview: ResolutionResult['needsReview'] = [];
338
+ let resolved = 0;
339
+
340
+ for (const claim of claims) {
341
+ const document = byId.get(claim.documentId);
342
+ if (!document) continue; // out of scope, or invented
343
+ const at = iso(document.occurredAt);
344
+ const identity = commitmentIdentity({ title: claim.statement, subject: claim.subject });
345
+
346
+ let best: { commitment: Commitment; score: number; bar: number } | null = null;
347
+ for (const commitment of out) {
348
+ if (commitment.status !== 'open' && commitment.status !== 'blocked') continue;
349
+ if (at && commitment.firstSeenAt && commitment.firstSeenAt > at) continue;
350
+ 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
+ const candidate = {
355
+ commitment,
356
+ score: jaccard(identity.actionTokens, identityOf(commitment).actionTokens),
357
+ bar: anchored ? RESOLVE_THRESHOLD : UNANCHORED_RESOLVE_THRESHOLD,
358
+ };
359
+ if (!best || candidate.score > best.score) best = candidate;
360
+ }
361
+
362
+ if (!best) continue;
363
+ if (best.score >= best.bar) {
364
+ best.commitment.status = claim.outcome === 'dropped' ? 'dropped' : 'done';
365
+ best.commitment.statusReason = claim.quote || claim.statement;
366
+ best.commitment.resolvedBy = claim.documentId;
367
+ best.commitment.resolvedAt = at;
368
+ resolved += 1;
369
+ } else {
370
+ // ONE review row per claim — its single best candidate. Listing every open
371
+ // item on the matter would bury the one that matters.
372
+ needsReview.push({
373
+ commitmentId: best.commitment.externalId,
374
+ documentId: claim.documentId,
375
+ quote: claim.quote || claim.statement,
376
+ });
377
+ }
378
+ }
379
+
380
+ return { commitments: out, resolved, needsReview };
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+
385
+ function cloneCommitment(commitment: Commitment): Commitment {
386
+ return { ...commitment, mentions: commitment.mentions.map((mention) => ({ ...mention })) };
387
+ }
388
+
389
+ function unique(values: string[]): string[] {
390
+ return [...new Set(values.filter(Boolean))];
391
+ }
392
+
393
+ function iso(value: string | null | undefined): string | null {
394
+ if (!value) return null;
395
+ const parsed = Date.parse(value);
396
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
397
+ }
398
+
399
+ /**
400
+ * A stable, dependency-free content hash (FNV-1a, doubled to 64 bits).
401
+ *
402
+ * It is an IDENTITY, not a security primitive: the only property required is
403
+ * that the same normalised text produces the same key in every runtime, which a
404
+ * crypto import would also give at the cost of a Node/browser split in a module
405
+ * that runs in both.
406
+ */
407
+ export function hash(input: string): string {
408
+ let a = 0x811c9dc5;
409
+ let b = 0x01000193;
410
+ for (let index = 0; index < input.length; index += 1) {
411
+ const code = input.charCodeAt(index);
412
+ a = Math.imul(a ^ code, 0x01000193) >>> 0;
413
+ b = Math.imul(b ^ (code + index), 0x85ebca6b) >>> 0;
414
+ }
415
+ return `${a.toString(16).padStart(8, '0')}${b.toString(16).padStart(8, '0')}`;
416
+ }
@@ -23,6 +23,8 @@
23
23
  * row with a missing subtitle.
24
24
  */
25
25
  import type {
26
+ Commitment,
27
+ CommitmentMention,
26
28
  CorpusScope,
27
29
  DocumentSection,
28
30
  MoneyEvent,
@@ -94,12 +96,15 @@ export interface CorpusTypeKeys {
94
96
  document: string;
95
97
  party: string;
96
98
  moneyEvent: string;
99
+ /** The trackable-commitment type (bd startsim-768w.38.25). */
100
+ commitment: string;
97
101
  }
98
102
 
99
103
  export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
100
104
  document: 'source_document',
101
105
  party: 'party',
102
106
  moneyEvent: 'money_event',
107
+ commitment: 'commitment',
103
108
  };
104
109
 
105
110
  // ---------------------------------------------------------------------------
@@ -493,3 +498,132 @@ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
493
498
  scope: event.scope,
494
499
  };
495
500
  }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Commitment (bd startsim-768w.38.25)
504
+ // ---------------------------------------------------------------------------
505
+ //
506
+ // STORED IN THE SAME GENERIC ENVELOPE as everything else: `entity_type:
507
+ // "commitment"`, discriminated by `data.subtype`. No new table, no new model, no
508
+ // new app — a tenant that wants a sixth kind of trackable thing writes a
509
+ // different subtype string (feedback_brutally_generic).
510
+ //
511
+ // `external_id` IS THE IDEMPOTENCY. It carries the dedupe key, and the tenant's
512
+ // `/api/v1/entities/upsert/` is keyed on (entity_type, external_id), so two runs
513
+ // racing each other converge on one row instead of writing two. Verified on the
514
+ // live tandem tenant: a second upsert of the same external_id returns 200 and
515
+ // updates in place.
516
+ //
517
+ // MENTIONS LIVE IN `data`, NOT AS EDGES, and that is a stated compromise rather
518
+ // than an oversight. The canonical `derived_from` edge is declared
519
+ // source_document -> source_document, a relationship row carries only
520
+ // (rel_type, source, target), and a mention has to carry its own verbatim quote
521
+ // or the row on screen cannot be checked against the record. Putting the quote
522
+ // in `data` and the graph nowhere is the honest half; writing the edge as well
523
+ // needs a schema change on the tenant side and is filed rather than half-done.
524
+
525
+ /** How many mentions to keep on a row. A commitment discussed thirty times is
526
+ * interesting; the thirty-first quote is not, and the row has to stay small. */
527
+ const MAX_MENTIONS = 20;
528
+
529
+ export function toCommitment(
530
+ entity: TenantEntity,
531
+ fallbackScope: CorpusScope,
532
+ sourceLabels: Record<string, string> = {},
533
+ ): Commitment {
534
+ const data = bag(entity);
535
+ const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
536
+ const externalId = asText(entity.external_id ?? entity.externalId);
537
+ const mentions = asList(pick(data, 'mentions'))
538
+ .map(toMention)
539
+ .filter((mention): mention is CommitmentMention => mention !== null);
540
+ const status = asText(pick(data, 'status')) as Commitment['status'];
541
+
542
+ return {
543
+ id: String(entity.id),
544
+ externalId: externalId || `commit:${entity.id}`,
545
+ dedupeKey: asText(pick(data, 'dedupe_key', 'dedupeKey')) || externalId.replace(/^commit:/, ''),
546
+ subtype: asText(pick(data, 'subtype')) || 'action',
547
+ title: asText(pick(data, 'title')) || entity.name || 'Untitled item',
548
+ detail: asText(pick(data, 'detail', 'description')),
549
+ status: ['open', 'done', 'blocked', 'dropped'].includes(status) ? status : 'open',
550
+ ...(pick(data, 'status_reason', 'statusReason')
551
+ ? { statusReason: asText(pick(data, 'status_reason', 'statusReason')) } : {}),
552
+ ...(pick(data, 'resolved_by', 'resolvedBy')
553
+ ? { resolvedBy: asText(pick(data, 'resolved_by', 'resolvedBy')) } : {}),
554
+ resolvedAt: asIso(pick(data, 'resolved_at', 'resolvedAt')),
555
+ ...(pick(data, 'owner_name', 'ownerName') ? { ownerName: asText(pick(data, 'owner_name', 'ownerName')) } : {}),
556
+ ...(pick(data, 'subject') ? { subject: asText(pick(data, 'subject')) } : {}),
557
+ subjectKey: asText(pick(data, 'subject_key', 'subjectKey')),
558
+ actionKey: asText(pick(data, 'action_key', 'actionKey')),
559
+ dueAt: asIso(pick(data, 'due_at', 'dueAt')),
560
+ mentions,
561
+ firstSeenAt: asIso(pick(data, 'first_seen_at', 'firstSeenAt')),
562
+ lastSeenAt: asIso(pick(data, 'last_seen_at', 'lastSeenAt')),
563
+ extractorVersion: asText(pick(data, 'extractor_version', 'extractorVersion')),
564
+ source,
565
+ sourceLabel:
566
+ asText(pick(data, 'source_label', 'sourceLabel')) || sourceLabels[source] || titleCase(source),
567
+ scope: scopeOf(entity, fallbackScope),
568
+ };
569
+ }
570
+
571
+ function toMention(value: unknown): CommitmentMention | null {
572
+ if (!value || typeof value !== 'object') return null;
573
+ const data = value as Record<string, unknown>;
574
+ const documentId = asText(pick(data, 'document_id', 'documentId'));
575
+ if (!documentId) return null;
576
+ const extractor = asText(pick(data, 'extractor'));
577
+ return {
578
+ documentId,
579
+ originKey: asText(pick(data, 'origin_key', 'originKey')),
580
+ occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt')),
581
+ quote: asText(pick(data, 'quote')),
582
+ extractor: extractor === 'model' ? 'model' : 'rules',
583
+ };
584
+ }
585
+
586
+ /** The write side: a commitment as the tenant's upsert payload. */
587
+ export function commitmentToEntity(commitment: Commitment): {
588
+ entity_type: string;
589
+ external_id: string;
590
+ name: string;
591
+ data: Record<string, unknown>;
592
+ } {
593
+ return {
594
+ entity_type: DEFAULT_TYPE_KEYS.commitment,
595
+ external_id: commitment.externalId,
596
+ name: commitment.title.slice(0, 200),
597
+ data: {
598
+ subtype: commitment.subtype,
599
+ title: commitment.title,
600
+ detail: commitment.detail,
601
+ status: commitment.status,
602
+ ...(commitment.statusReason ? { status_reason: commitment.statusReason } : {}),
603
+ ...(commitment.resolvedBy ? { resolved_by: commitment.resolvedBy } : {}),
604
+ ...(commitment.resolvedAt ? { resolved_at: commitment.resolvedAt } : {}),
605
+ ...(commitment.ownerName ? { owner_name: commitment.ownerName } : {}),
606
+ ...(commitment.subject ? { subject: commitment.subject } : {}),
607
+ subject_key: commitment.subjectKey,
608
+ action_key: commitment.actionKey,
609
+ dedupe_key: commitment.dedupeKey,
610
+ ...(commitment.dueAt ? { due_at: commitment.dueAt } : {}),
611
+ first_seen_at: commitment.firstSeenAt,
612
+ last_seen_at: commitment.lastSeenAt,
613
+ mention_count: commitment.mentions.length,
614
+ mentions: commitment.mentions.slice(-MAX_MENTIONS).map((mention) => ({
615
+ document_id: mention.documentId,
616
+ origin_key: mention.originKey,
617
+ occurred_at: mention.occurredAt,
618
+ quote: mention.quote.slice(0, 400),
619
+ extractor: mention.extractor,
620
+ })),
621
+ extractor_version: commitment.extractorVersion,
622
+ source: commitment.source,
623
+ // Marks the row as pipeline-written, the same way the corpus delivery
624
+ // marks its own — so a human-created item is distinguishable from a
625
+ // derived one, and a re-derivation knows what it is allowed to rewrite.
626
+ _origin: 'tenant-extraction',
627
+ },
628
+ };
629
+ }