@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.
@@ -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
- name: asText(pick(data, 'name', 'label', 'full_name', 'fullName')) || entity.name || String(entity.id),
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
- participants: asList(pick(data, 'participants', 'attendees', 'parties'))
280
- .map(toPartyRef)
281
- .filter((ref): ref is PartyRef => ref !== null),
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 = toPartyRef(pick(data, 'party', 'customer', 'counterparty'));
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
+ }