@startsimpli/integrations 0.1.5 → 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.
- package/package.json +2 -2
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +206 -18
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +288 -5
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +602 -186
- package/src/corpus/index.ts +57 -1
- package/src/corpus/llm.ts +134 -2
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
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,
|
|
@@ -31,6 +33,42 @@ import type {
|
|
|
31
33
|
SourceDocument,
|
|
32
34
|
} from './types';
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The tenant data plane's typed EDGE, as `/api/v1/relationships/` returns it.
|
|
38
|
+
*
|
|
39
|
+
* People are stored as edges, not as an inline array. The corpus delivery
|
|
40
|
+
* (bd startsim-768w.38.18) writes `source_document --involves--> party` and
|
|
41
|
+
* `source_document --authored_by--> party`; the document's own `data` carries
|
|
42
|
+
* only a `participant_count`. A reader that only looks at `data` sees a corpus
|
|
43
|
+
* in which nobody was ever in the room (bd startsim-768w.38.22).
|
|
44
|
+
*/
|
|
45
|
+
export interface TenantRelationship {
|
|
46
|
+
id?: string | number;
|
|
47
|
+
rel_type?: string;
|
|
48
|
+
relType?: string;
|
|
49
|
+
/** The record the edge starts at — the document, or the money event. */
|
|
50
|
+
source: string | number;
|
|
51
|
+
/** The record the edge points at — the party. */
|
|
52
|
+
target: string | number;
|
|
53
|
+
created_at?: string | null;
|
|
54
|
+
createdAt?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Edges that mean "this party is ON this record", in the order they should be
|
|
59
|
+
* listed. DATA, not a closed set: a tenant that models its own edge passes its
|
|
60
|
+
* own list rather than editing this file.
|
|
61
|
+
*
|
|
62
|
+
* `authored_by` first because the author is who a reader looks for first;
|
|
63
|
+
* `billed_to` is here because a money event's party is the same question asked
|
|
64
|
+
* of a payment.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_PARTICIPANT_EDGES: readonly string[] = [
|
|
67
|
+
'authored_by',
|
|
68
|
+
'involves',
|
|
69
|
+
'billed_to',
|
|
70
|
+
];
|
|
71
|
+
|
|
34
72
|
/** The tenant data plane's generic record envelope, as the API returns it. */
|
|
35
73
|
export interface TenantEntity {
|
|
36
74
|
id: string | number;
|
|
@@ -58,12 +96,15 @@ export interface CorpusTypeKeys {
|
|
|
58
96
|
document: string;
|
|
59
97
|
party: string;
|
|
60
98
|
moneyEvent: string;
|
|
99
|
+
/** The trackable-commitment type (bd startsim-768w.38.25). */
|
|
100
|
+
commitment: string;
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
|
|
64
104
|
document: 'source_document',
|
|
65
105
|
party: 'party',
|
|
66
106
|
moneyEvent: 'money_event',
|
|
107
|
+
commitment: 'commitment',
|
|
67
108
|
};
|
|
68
109
|
|
|
69
110
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +219,13 @@ export function toParty(entity: TenantEntity, fallbackScope: CorpusScope): Party
|
|
|
178
219
|
const email = asText(pick(data, 'email', 'primary_email', 'primaryEmail')) || emails[0] || '';
|
|
179
220
|
return {
|
|
180
221
|
id: String(entity.id),
|
|
181
|
-
|
|
222
|
+
// `display_name` is what the canonical party schema declares
|
|
223
|
+
// (startsimpli_connectors.canonical), so it is read FIRST among the
|
|
224
|
+
// spellings — a connector-written row has no `name` key inside `data`.
|
|
225
|
+
name:
|
|
226
|
+
asText(pick(data, 'display_name', 'displayName', 'name', 'label', 'full_name', 'fullName')) ||
|
|
227
|
+
entity.name ||
|
|
228
|
+
String(entity.id),
|
|
182
229
|
kind: asText(pick(data, 'kind', 'subtype', 'party_type', 'partyType')) || undefined,
|
|
183
230
|
...(email ? { email } : {}),
|
|
184
231
|
...(emails.length ? { emails } : {}),
|
|
@@ -192,6 +239,95 @@ export function toParty(entity: TenantEntity, fallbackScope: CorpusScope): Party
|
|
|
192
239
|
};
|
|
193
240
|
}
|
|
194
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Stable identity for de-duplication. A party that arrives once as an edge and
|
|
244
|
+
* once inline is ONE person, and listing them twice is the kind of small wrong
|
|
245
|
+
* that makes the whole panel look untrustworthy.
|
|
246
|
+
*/
|
|
247
|
+
function partyKey(party: PartyRef): string {
|
|
248
|
+
return (party.id || party.email || party.name || '').toLowerCase();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Union two participant lists, keeping the first spelling of each person.
|
|
253
|
+
*
|
|
254
|
+
* INLINE WINS over the edge-derived copy, because a connector that wrote the
|
|
255
|
+
* participant onto the record itself knew something about the role it played
|
|
256
|
+
* there. Order is preserved so `authored_by` stays in front.
|
|
257
|
+
*/
|
|
258
|
+
export function mergeParticipants(...lists: Array<PartyRef[] | undefined>): PartyRef[] {
|
|
259
|
+
const out: PartyRef[] = [];
|
|
260
|
+
const seen = new Set<string>();
|
|
261
|
+
for (const list of lists) {
|
|
262
|
+
for (const party of list ?? []) {
|
|
263
|
+
const key = partyKey(party);
|
|
264
|
+
if (!key || seen.has(key)) continue;
|
|
265
|
+
seen.add(key);
|
|
266
|
+
out.push(party);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The graph, read as "who is on which record".
|
|
274
|
+
*
|
|
275
|
+
* THIS IS THE JOIN THE PRODUCT ACTUALLY NEEDS, and the reason it lives here
|
|
276
|
+
* rather than in a composer: the tenant stores people as typed edges, and every
|
|
277
|
+
* surface — the Library's Parties block, the row subtitle, the retrieval field
|
|
278
|
+
* weights, the prompt — asks the same question of them. Doing the join once,
|
|
279
|
+
* next to the mapper that already knows the storage shape, keeps `entities.ts`
|
|
280
|
+
* the single file that knows how a corpus record is stored.
|
|
281
|
+
*
|
|
282
|
+
* Nothing here names a vendor and nothing here assumes an edge direction beyond
|
|
283
|
+
* the declared one (`source` is the record, `target` is the party), which is the
|
|
284
|
+
* contract `canonical_schema()` states for all four canonical edges.
|
|
285
|
+
*/
|
|
286
|
+
export function indexParticipants(
|
|
287
|
+
relationships: TenantRelationship[],
|
|
288
|
+
parties: Iterable<PartyRef>,
|
|
289
|
+
edgeTypes: readonly string[] = DEFAULT_PARTICIPANT_EDGES,
|
|
290
|
+
): Map<string, PartyRef[]> {
|
|
291
|
+
const byId = new Map<string, PartyRef>();
|
|
292
|
+
for (const party of parties) {
|
|
293
|
+
if (!party?.id) continue;
|
|
294
|
+
// NARROWED to a reference. A `Party` carries its own source and scope, and a
|
|
295
|
+
// document's participant list is a list of references — copying the whole
|
|
296
|
+
// record onto every document that mentions it is a second copy of the row.
|
|
297
|
+
byId.set(String(party.id), {
|
|
298
|
+
id: String(party.id),
|
|
299
|
+
name: party.name || String(party.id),
|
|
300
|
+
...(party.kind ? { kind: party.kind } : {}),
|
|
301
|
+
...(party.email ? { email: party.email } : {}),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Rank by the DECLARED order of `edgeTypes` rather than by arrival order, so
|
|
306
|
+
// the author is listed first however the API happened to page the edges.
|
|
307
|
+
const rank = new Map(edgeTypes.map((type, index) => [type, index]));
|
|
308
|
+
const buckets = new Map<string, Array<{ order: number; party: PartyRef }>>();
|
|
309
|
+
|
|
310
|
+
for (const edge of relationships ?? []) {
|
|
311
|
+
if (!edge) continue;
|
|
312
|
+
const relType = asText(edge.rel_type ?? edge.relType);
|
|
313
|
+
const order = rank.get(relType);
|
|
314
|
+
if (order === undefined) continue; // `derived_from` and anything else: not a person
|
|
315
|
+
const recordId = asText(edge.source);
|
|
316
|
+
const party = byId.get(asText(edge.target));
|
|
317
|
+
if (!recordId || !party) continue; // an edge whose far end we cannot name adds nothing
|
|
318
|
+
const bucket = buckets.get(recordId);
|
|
319
|
+
if (bucket) bucket.push({ order, party });
|
|
320
|
+
else buckets.set(recordId, [{ order, party }]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const out = new Map<string, PartyRef[]>();
|
|
324
|
+
for (const [recordId, entries] of buckets) {
|
|
325
|
+
entries.sort((a, b) => a.order - b.order);
|
|
326
|
+
out.set(recordId, mergeParticipants(entries.map((entry) => entry.party)));
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
195
331
|
// ---------------------------------------------------------------------------
|
|
196
332
|
// SourceDocument
|
|
197
333
|
// ---------------------------------------------------------------------------
|
|
@@ -244,6 +380,13 @@ export function toSourceDocument(
|
|
|
244
380
|
entity: TenantEntity,
|
|
245
381
|
fallbackScope: CorpusScope,
|
|
246
382
|
sourceLabels: Record<string, string> = {},
|
|
383
|
+
/**
|
|
384
|
+
* People resolved from the tenant's `authored_by` / `involves` EDGES — which
|
|
385
|
+
* is where the connector pipeline actually puts them (bd startsim-768w.38.18).
|
|
386
|
+
* Passed in rather than fetched here because this module is pure; the client
|
|
387
|
+
* adapter owns the round trip. See `indexParticipants`.
|
|
388
|
+
*/
|
|
389
|
+
edgeParticipants: PartyRef[] = [],
|
|
247
390
|
): SourceDocument {
|
|
248
391
|
const data = bag(entity);
|
|
249
392
|
const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
|
|
@@ -276,9 +419,17 @@ export function toSourceDocument(
|
|
|
276
419
|
asText(pick(data, 'source_ref', 'sourceRef')) ||
|
|
277
420
|
asText(entity.external_id ?? entity.externalId),
|
|
278
421
|
url: asText(pick(data, 'url', 'web_url', 'webUrl', 'link')) || undefined,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
422
|
+
// BOTH STORAGE SHAPES, because both are real. A connector that denormalizes
|
|
423
|
+
// its attendees onto the record writes an inline array; the shipped corpus
|
|
424
|
+
// delivery writes typed edges and a bare `participant_count`. Reading only
|
|
425
|
+
// the first is what made `participants` empty on every document the pipeline
|
|
426
|
+
// has ever delivered (bd startsim-768w.38.22).
|
|
427
|
+
participants: mergeParticipants(
|
|
428
|
+
asList(pick(data, 'participants', 'attendees', 'parties'))
|
|
429
|
+
.map(toPartyRef)
|
|
430
|
+
.filter((ref): ref is PartyRef => ref !== null),
|
|
431
|
+
edgeParticipants,
|
|
432
|
+
),
|
|
282
433
|
scope: scopeOf(entity, fallbackScope),
|
|
283
434
|
ingestedAt: asIso(entity.created_at ?? entity.createdAt),
|
|
284
435
|
};
|
|
@@ -292,10 +443,13 @@ export function toMoneyEvent(
|
|
|
292
443
|
entity: TenantEntity,
|
|
293
444
|
fallbackScope: CorpusScope,
|
|
294
445
|
sourceLabels: Record<string, string> = {},
|
|
446
|
+
/** Resolved from the `billed_to` edge — the money side of the same seam. */
|
|
447
|
+
edgeParticipants: PartyRef[] = [],
|
|
295
448
|
): MoneyEvent {
|
|
296
449
|
const data = bag(entity);
|
|
297
450
|
const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
|
|
298
|
-
const party =
|
|
451
|
+
const party =
|
|
452
|
+
toPartyRef(pick(data, 'party', 'customer', 'counterparty')) ?? edgeParticipants[0] ?? null;
|
|
299
453
|
return {
|
|
300
454
|
id: String(entity.id),
|
|
301
455
|
subtype: asText(pick(data, 'subtype', 'kind', 'event_type', 'eventType')) || 'charge',
|
|
@@ -344,3 +498,132 @@ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
|
|
|
344
498
|
scope: event.scope,
|
|
345
499
|
};
|
|
346
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
|
+
}
|