@startsimpli/integrations 0.1.4 → 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.
- package/package.json +11 -4
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/AskPage.tsx +384 -0
- package/src/components/pages/LibraryPage.tsx +612 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +196 -0
- package/src/corpus/entities.ts +346 -0
- package/src/corpus/fixtures.ts +486 -0
- package/src/corpus/index.ts +79 -0
- package/src/corpus/llm.ts +197 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
}
|