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