@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,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
|
+
}
|
package/src/corpus/entities.ts
CHANGED
|
@@ -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
|
+
}
|