@startsimpli/integrations 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The ONE file that knows how a corpus record is STORED.
3
+ *
4
+ * bd startsim-768w.38.2 is still open and its attribute names are expected to
5
+ * shift. Everything else in this package — retrieval, the ask pipeline, both
6
+ * composers, every test — speaks `SourceDocument`/`Party`/`MoneyEvent` and never
7
+ * touches an `Entity`. So a schema tweak is an edit here and nowhere else.
8
+ *
9
+ * THE STORAGE SHAPE it maps from is the tenant data plane's generic envelope
10
+ * (tenant-starter `apps/core/models.Entity`):
11
+ *
12
+ * { id, entity_type, external_id, name, data: {...}, created_at }
13
+ *
14
+ * `entity_type` is a free-form string and `data` is a JSON blob of the
15
+ * attributes the tenant declared through the schema API. That genericness is the
16
+ * whole point — a future Drive connector fills `source_document` with
17
+ * `source: "gdrive"` and no type, no mapper and no screen changes.
18
+ *
19
+ * TOLERANT ON READ. Every accessor below accepts more than one spelling of a
20
+ * field and falls back rather than throwing. A record written by a connector
21
+ * that is one deploy ahead of this bundle must still render; a corpus that
22
+ * blanks itself because an attribute was renamed is worse than one that shows a
23
+ * row with a missing subtitle.
24
+ */
25
+ import type {
26
+ CorpusScope,
27
+ DocumentSection,
28
+ MoneyEvent,
29
+ Party,
30
+ PartyRef,
31
+ SourceDocument,
32
+ } from './types';
33
+
34
+ /** The tenant data plane's generic record envelope, as the API returns it. */
35
+ export interface TenantEntity {
36
+ id: string | number;
37
+ entity_type?: string;
38
+ entityType?: string;
39
+ external_id?: string | null;
40
+ externalId?: string | null;
41
+ name?: string;
42
+ data?: Record<string, unknown> | null;
43
+ created_at?: string | null;
44
+ createdAt?: string | null;
45
+ /** Present when the API echoes the tenant boundary back. */
46
+ org_id?: string;
47
+ orgId?: string;
48
+ owner_sub?: string;
49
+ ownerSub?: string;
50
+ }
51
+
52
+ /**
53
+ * The entity_type keys the corpus expects. Defaults, not a closed set: a tenant
54
+ * that modeled its types under different keys passes its own via
55
+ * `CorpusTypeKeys` rather than editing this file.
56
+ */
57
+ export interface CorpusTypeKeys {
58
+ document: string;
59
+ party: string;
60
+ moneyEvent: string;
61
+ }
62
+
63
+ export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
64
+ document: 'source_document',
65
+ party: 'party',
66
+ moneyEvent: 'money_event',
67
+ };
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // tolerant accessors
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function bag(entity: TenantEntity): Record<string, unknown> {
74
+ const data = entity.data;
75
+ return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
76
+ }
77
+
78
+ /**
79
+ * First present value across candidate keys. The `@startsimpli/api` client
80
+ * snake_case↔camelCase transform recurses into `data`, so both spellings can
81
+ * legitimately arrive depending on which client fetched the row.
82
+ */
83
+ function pick(data: Record<string, unknown>, ...keys: string[]): unknown {
84
+ for (const key of keys) {
85
+ const value = data[key];
86
+ if (value !== undefined && value !== null && value !== '') return value;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function asText(value: unknown): string {
92
+ if (typeof value === 'string') return value;
93
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
94
+ return '';
95
+ }
96
+
97
+ function asIso(value: unknown): string | null {
98
+ const text = asText(value);
99
+ if (!text) return null;
100
+ const parsed = Date.parse(text);
101
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
102
+ }
103
+
104
+ function asList(value: unknown): unknown[] {
105
+ if (Array.isArray(value)) return value;
106
+ if (value === undefined || value === null || value === '') return [];
107
+ return [value];
108
+ }
109
+
110
+ /**
111
+ * Money arrives in minor units from every payment vendor that matters, but a
112
+ * hand-entered record may carry a decimal. A `1200` that means £12.00 rendered
113
+ * as £1,200.00 is the kind of wrong that survives a demo, so the rule is stated
114
+ * once, here: an integer is minor units, a non-integer is major units.
115
+ */
116
+ function asMinorUnits(value: unknown): number {
117
+ if (typeof value === 'number') {
118
+ return Number.isInteger(value) ? value : Math.round(value * 100);
119
+ }
120
+ const text = asText(value);
121
+ if (!text) return 0;
122
+ const parsed = Number(text);
123
+ if (Number.isNaN(parsed)) return 0;
124
+ return Number.isInteger(parsed) ? parsed : Math.round(parsed * 100);
125
+ }
126
+
127
+ /**
128
+ * The scope carried BY the row. When the API does not echo it (older builds
129
+ * did not), fall back to the scope the caller is already in — the guard in
130
+ * `./retrieval` still runs, it just cannot catch a server that lied.
131
+ */
132
+ export function scopeOf(entity: TenantEntity, fallback: CorpusScope): CorpusScope {
133
+ const data = bag(entity);
134
+ const orgId =
135
+ asText(entity.org_id ?? entity.orgId) ||
136
+ asText(pick(data, 'org_id', 'orgId')) ||
137
+ fallback.orgId;
138
+ const ownerSub =
139
+ asText(entity.owner_sub ?? entity.ownerSub) ||
140
+ asText(pick(data, 'owner_sub', 'ownerSub')) ||
141
+ fallback.ownerSub ||
142
+ '';
143
+ return ownerSub ? { orgId, ownerSub } : { orgId };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Party
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * A participant as it is EMBEDDED on a document. Accepts a bare string (an
152
+ * email, a name), an object with an id, or an object with only a name — because
153
+ * a connector that has not resolved a Party yet still knows who was there, and
154
+ * dropping the name until a join exists loses the thing the user recognises.
155
+ */
156
+ export function toPartyRef(value: unknown): PartyRef | null {
157
+ if (typeof value === 'string') {
158
+ const text = value.trim();
159
+ if (!text) return null;
160
+ const isEmail = text.includes('@');
161
+ return { id: text, name: text, ...(isEmail ? { email: text } : {}) };
162
+ }
163
+ if (!value || typeof value !== 'object') return null;
164
+ const data = value as Record<string, unknown>;
165
+ const email = asText(pick(data, 'email', 'email_address', 'emailAddress'));
166
+ const name = asText(pick(data, 'name', 'label', 'full_name', 'fullName', 'title')) || email;
167
+ const id = asText(pick(data, 'id', 'party_id', 'partyId', 'external_id', 'externalId')) || email || name;
168
+ if (!id) return null;
169
+ const kind = asText(pick(data, 'kind', 'subtype', 'party_type', 'partyType'));
170
+ return { id, name: name || id, ...(kind ? { kind } : {}), ...(email ? { email } : {}) };
171
+ }
172
+
173
+ export function toParty(entity: TenantEntity, fallbackScope: CorpusScope): Party {
174
+ const data = bag(entity);
175
+ const emails = asList(pick(data, 'emails', 'email_addresses', 'emailAddresses'))
176
+ .map(asText)
177
+ .filter(Boolean);
178
+ const email = asText(pick(data, 'email', 'primary_email', 'primaryEmail')) || emails[0] || '';
179
+ return {
180
+ id: String(entity.id),
181
+ name: asText(pick(data, 'name', 'label', 'full_name', 'fullName')) || entity.name || String(entity.id),
182
+ kind: asText(pick(data, 'kind', 'subtype', 'party_type', 'partyType')) || undefined,
183
+ ...(email ? { email } : {}),
184
+ ...(emails.length ? { emails } : {}),
185
+ source: asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown',
186
+ sourceLabel: asText(pick(data, 'source_label', 'sourceLabel')) || undefined,
187
+ sourceRef:
188
+ asText(pick(data, 'source_ref', 'sourceRef')) ||
189
+ asText(entity.external_id ?? entity.externalId) ||
190
+ undefined,
191
+ scope: scopeOf(entity, fallbackScope),
192
+ };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // SourceDocument
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Long-form text a source attached to a document.
201
+ *
202
+ * Accepts three storage shapes, because the connectors do not agree and should
203
+ * not have to: an array of `{key,label,text}`, an object of `{key: text}`, or
204
+ * the legacy single-blob attributes some early ingests wrote. Nothing here names
205
+ * a vendor — `transcript` is just a key a source may have used, exactly like
206
+ * `extracted_text`, and its LABEL comes from the data or from a title-cased key.
207
+ */
208
+ export function toSections(value: unknown): DocumentSection[] {
209
+ const sections: DocumentSection[] = [];
210
+ const push = (key: string, label: string, text: unknown) => {
211
+ const body = asText(text).trim();
212
+ if (body) sections.push({ key, label, text: body });
213
+ };
214
+
215
+ if (Array.isArray(value)) {
216
+ for (const item of value) {
217
+ if (typeof item === 'string') {
218
+ push('section', 'Section', item);
219
+ continue;
220
+ }
221
+ if (!item || typeof item !== 'object') continue;
222
+ const data = item as Record<string, unknown>;
223
+ const key = asText(pick(data, 'key', 'name', 'kind')) || 'section';
224
+ const label = asText(pick(data, 'label', 'title')) || titleCase(key);
225
+ push(key, label, pick(data, 'text', 'body', 'value', 'content'));
226
+ }
227
+ return sections;
228
+ }
229
+
230
+ if (value && typeof value === 'object') {
231
+ for (const [key, text] of Object.entries(value as Record<string, unknown>)) {
232
+ push(key, titleCase(key), text);
233
+ }
234
+ }
235
+ return sections;
236
+ }
237
+
238
+ function titleCase(key: string): string {
239
+ const words = key.replace(/[_-]+/g, ' ').trim();
240
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
241
+ }
242
+
243
+ export function toSourceDocument(
244
+ entity: TenantEntity,
245
+ fallbackScope: CorpusScope,
246
+ sourceLabels: Record<string, string> = {},
247
+ ): SourceDocument {
248
+ const data = bag(entity);
249
+ const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
250
+
251
+ const sections = toSections(pick(data, 'sections'));
252
+ // A source that stored its long-form under a bare attribute rather than in
253
+ // `sections` still contributes one. Listed keys are storage spellings, not
254
+ // vendor knowledge: the same loop picks up a Drive connector's `extracted_text`.
255
+ for (const key of ['transcript', 'extracted_text', 'extractedText', 'full_text', 'fullText']) {
256
+ if (sections.some((section) => section.key === key)) continue;
257
+ const text = asText(data[key]).trim();
258
+ if (text) sections.push({ key, label: titleCase(key), text });
259
+ }
260
+
261
+ return {
262
+ id: String(entity.id),
263
+ title:
264
+ asText(pick(data, 'title', 'subject', 'headline')) ||
265
+ entity.name ||
266
+ `Untitled ${source} record`,
267
+ body: asText(pick(data, 'body', 'text', 'summary', 'description', 'content')),
268
+ sections,
269
+ occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt', 'happened_at', 'date')),
270
+ source,
271
+ sourceLabel:
272
+ asText(pick(data, 'source_label', 'sourceLabel')) ||
273
+ sourceLabels[source] ||
274
+ titleCase(source),
275
+ sourceRef:
276
+ asText(pick(data, 'source_ref', 'sourceRef')) ||
277
+ asText(entity.external_id ?? entity.externalId),
278
+ 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),
282
+ scope: scopeOf(entity, fallbackScope),
283
+ ingestedAt: asIso(entity.created_at ?? entity.createdAt),
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // MoneyEvent
289
+ // ---------------------------------------------------------------------------
290
+
291
+ export function toMoneyEvent(
292
+ entity: TenantEntity,
293
+ fallbackScope: CorpusScope,
294
+ sourceLabels: Record<string, string> = {},
295
+ ): MoneyEvent {
296
+ const data = bag(entity);
297
+ const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
298
+ const party = toPartyRef(pick(data, 'party', 'customer', 'counterparty'));
299
+ return {
300
+ id: String(entity.id),
301
+ subtype: asText(pick(data, 'subtype', 'kind', 'event_type', 'eventType')) || 'charge',
302
+ amount: asMinorUnits(pick(data, 'amount', 'amount_minor', 'amountMinor', 'total')),
303
+ currency: (asText(pick(data, 'currency')) || 'usd').toLowerCase(),
304
+ status: asText(pick(data, 'status', 'state')) || 'unknown',
305
+ occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt', 'created', 'date')),
306
+ source,
307
+ sourceLabel:
308
+ asText(pick(data, 'source_label', 'sourceLabel')) || sourceLabels[source] || titleCase(source),
309
+ sourceRef:
310
+ asText(pick(data, 'source_ref', 'sourceRef')) || asText(entity.external_id ?? entity.externalId),
311
+ url: asText(pick(data, 'url', 'hosted_invoice_url', 'hostedInvoiceUrl')) || undefined,
312
+ description: asText(pick(data, 'description', 'summary', 'memo')) || undefined,
313
+ party,
314
+ scope: scopeOf(entity, fallbackScope),
315
+ };
316
+ }
317
+
318
+ /**
319
+ * A `MoneyEvent` read as a document, so the Ask surface can retrieve over
320
+ * meetings and revenue with ONE ranking pass. The bead's motivating question —
321
+ * "what did we agree with this company" — spans a meeting note and an invoice,
322
+ * and it cannot span them if they are ranked in separate universes.
323
+ */
324
+ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
325
+ const major = (event.amount / 100).toFixed(2);
326
+ const amount = `${event.currency.toUpperCase()} ${major}`;
327
+ const who = event.party ? event.party.name : 'an unnamed party';
328
+ return {
329
+ id: event.id,
330
+ title: `${titleCase(event.subtype)} — ${amount}${event.party ? ` (${event.party.name})` : ''}`,
331
+ body: [
332
+ `${titleCase(event.subtype)} of ${amount} for ${who}, status ${event.status}.`,
333
+ event.description ?? '',
334
+ ]
335
+ .filter(Boolean)
336
+ .join(' '),
337
+ sections: [],
338
+ occurredAt: event.occurredAt,
339
+ source: event.source,
340
+ sourceLabel: event.sourceLabel,
341
+ sourceRef: event.sourceRef,
342
+ url: event.url,
343
+ participants: event.party ? [event.party] : [],
344
+ scope: event.scope,
345
+ };
346
+ }