@startsimpli/integrations 0.1.4 → 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 +11 -4
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/AskPage.tsx +409 -0
- package/src/components/pages/LibraryPage.tsx +620 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +303 -0
- package/src/corpus/entities.ts +495 -0
- package/src/corpus/fixtures.ts +726 -0
- package/src/corpus/index.ts +89 -0
- package/src/corpus/llm.ts +223 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A multi-source fixture corpus, shipped as a TESTING UTILITY.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT IS IN THE PACKAGE and not in a test file or a harness app: the vitest
|
|
5
|
+
* suite and the browser harness must exercise the same corpus, or "it renders"
|
|
6
|
+
* and "it passes" are claims about two different things. Same reasoning as
|
|
7
|
+
* `startsimpli_connectors.testing.load_fixture` on the backend.
|
|
8
|
+
*
|
|
9
|
+
* PROVENANCE. The Granola rows carry the note ids, titles, timestamps,
|
|
10
|
+
* attendees and transcript lines from
|
|
11
|
+
* `start-simpli-api/packages/startsimpli-connectors/tests/fixtures/granola/`,
|
|
12
|
+
* and the money rows carry the ids, amounts and currencies from the `stripe/`
|
|
13
|
+
* set next to it. Those upstream fixtures are themselves CONSTRUCTED, not
|
|
14
|
+
* captured — Granola publishes no sandbox — and their README says so; nothing
|
|
15
|
+
* here is more real than they are.
|
|
16
|
+
*
|
|
17
|
+
* THE DROPBOX AND DRIVE ROWS ARE NOT DECORATION. bd startsim-768w.38.6's
|
|
18
|
+
* standing test is "would this row, badge, filter or empty state still make
|
|
19
|
+
* sense if the only connected source were Dropbox?". A fixture set containing
|
|
20
|
+
* only meeting notes cannot answer that question, so this one contains a signed
|
|
21
|
+
* contract from Dropbox and a pricing sheet from Drive, with their long-form
|
|
22
|
+
* text in `sections` exactly the way a transcript arrives. Any screen that
|
|
23
|
+
* renders the Granola rows well and these badly has failed the test visibly.
|
|
24
|
+
*
|
|
25
|
+
* THE SECOND ORG IS THE SCOPE TEST. `northwind` holds a document that answers a
|
|
26
|
+
* `tandem` question BETTER than anything `tandem` owns. That is deliberate: it
|
|
27
|
+
* is the shape of the leak that matters — not a random foreign row, but the one
|
|
28
|
+
* a ranker would happily pick if nothing stopped it.
|
|
29
|
+
*
|
|
30
|
+
* THE FIXTURES START AT THE STORAGE, NOT AT THE DOMAIN OBJECT. `FIXTURE_TENANT_*`
|
|
31
|
+
* are raw tenant `Entity` and `Relationship` rows in the shape the corpus
|
|
32
|
+
* delivery really writes; `FIXTURE_DOCUMENTS` and friends are DERIVED from them
|
|
33
|
+
* by the real mapper in `./entities`. That inversion is the whole lesson of bd
|
|
34
|
+
* startsim-768w.38.22: hand-written domain fixtures carried an inline
|
|
35
|
+
* `participants` array the pipeline never produces, so a green suite proved
|
|
36
|
+
* nothing about the corpus the product actually renders. If the mapper stops
|
|
37
|
+
* agreeing with the pipeline, everything downstream of here goes red.
|
|
38
|
+
*/
|
|
39
|
+
import {
|
|
40
|
+
indexParticipants,
|
|
41
|
+
toMoneyEvent,
|
|
42
|
+
toParty,
|
|
43
|
+
toSourceDocument,
|
|
44
|
+
} from './entities';
|
|
45
|
+
import type { TenantEntity, TenantRelationship } from './entities';
|
|
46
|
+
import type {
|
|
47
|
+
CorpusApi,
|
|
48
|
+
CorpusPage,
|
|
49
|
+
CorpusScope,
|
|
50
|
+
DocumentQuery,
|
|
51
|
+
MoneyEvent,
|
|
52
|
+
Party,
|
|
53
|
+
PartyRef,
|
|
54
|
+
SourceDocument,
|
|
55
|
+
SourceFreshness,
|
|
56
|
+
} from './types';
|
|
57
|
+
|
|
58
|
+
/** The tenant every fixture document belongs to unless stated otherwise. */
|
|
59
|
+
export const FIXTURE_ORG = 'tandem';
|
|
60
|
+
/** A DIFFERENT customer workspace under the same foundry (bd startsim-768w.34.4). */
|
|
61
|
+
export const FIXTURE_OTHER_ORG = 'northwind';
|
|
62
|
+
|
|
63
|
+
const OWNER_A = 'usr_dana';
|
|
64
|
+
const OWNER_B = 'usr_sam';
|
|
65
|
+
|
|
66
|
+
const scope = (orgId: string, ownerSub: string): CorpusScope => ({ orgId, ownerSub });
|
|
67
|
+
|
|
68
|
+
/** Human names per source key, as an app reads them off the connector catalog. */
|
|
69
|
+
export const FIXTURE_SOURCE_LABELS: Record<string, string> = {
|
|
70
|
+
granola: 'Granola',
|
|
71
|
+
dropbox: 'Dropbox',
|
|
72
|
+
gdrive: 'Google Drive',
|
|
73
|
+
stripe: 'Stripe',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// THE TENANT'S OWN STORAGE — the shape the pipeline really writes
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
//
|
|
80
|
+
// EVERYTHING BELOW THIS LINE IS RAW, and everything above the derivation at the
|
|
81
|
+
// bottom is produced from it by the REAL mapper (`./entities`). That is the
|
|
82
|
+
// whole point of this section, and it was added because the previous fixtures
|
|
83
|
+
// were domain objects handed straight to the composers: they carried an inline
|
|
84
|
+
// `participants` array that `declare_canonical()` never writes, so every test
|
|
85
|
+
// passed against a corpus shape production does not produce, and the Library
|
|
86
|
+
// shipped with an empty Parties block on every document the pipeline had ever
|
|
87
|
+
// delivered (bd startsim-768w.38.22).
|
|
88
|
+
//
|
|
89
|
+
// WHAT "REAL" MEANS HERE, checked against the live rehearsal
|
|
90
|
+
// (bd startsim-768w.38.10) and against `startsimpli_connectors.canonical`:
|
|
91
|
+
//
|
|
92
|
+
// • `data` holds the CANONICAL ATTRIBUTES and nothing else, plus `_origin`.
|
|
93
|
+
// A document carries `participant_count` — a NUMBER — and no names.
|
|
94
|
+
// • people are typed EDGES: `authored_by` and `involves` from the document to
|
|
95
|
+
// a `party`, `billed_to` from a money event to a `party`.
|
|
96
|
+
// • `external_id` is always `<connector_key>:<vendor id>`.
|
|
97
|
+
// • a `derived_from` edge exists and points at another DOCUMENT — anything
|
|
98
|
+
// that treats every edge as a person will put a document in the Parties row.
|
|
99
|
+
//
|
|
100
|
+
// TWO KNOWN DEPARTURES, stated rather than hidden. Entity ids here are readable
|
|
101
|
+
// (`not_1d3tmYTlCICgjy`) where a tenant mints UUIDs — the id FORMAT is opaque to
|
|
102
|
+
// every consumer and readable ids make a failing assertion legible. And these
|
|
103
|
+
// rows carry `org_id`/`owner_sub`, which `EntitySerializer` does not echo today
|
|
104
|
+
// (`tenant-starter/apps/api/views.py`), so on a real tenant `scopeOf` falls back
|
|
105
|
+
// to the caller's scope; keeping them here is what lets the scope-guard suite
|
|
106
|
+
// model a server that lies.
|
|
107
|
+
|
|
108
|
+
/** The marker the corpus delivery stamps on every pipeline-written row. */
|
|
109
|
+
const ORIGIN = 'central-connector';
|
|
110
|
+
|
|
111
|
+
/** `<connector_key>:<vendor id>`, exactly as `canonical_external_id()` builds it. */
|
|
112
|
+
const ref = (source: string, vendorId: string) => `${source}:${vendorId}`;
|
|
113
|
+
|
|
114
|
+
function row(options: {
|
|
115
|
+
type: string;
|
|
116
|
+
id: string;
|
|
117
|
+
source: string;
|
|
118
|
+
vendorId: string;
|
|
119
|
+
name?: string;
|
|
120
|
+
data: Record<string, unknown>;
|
|
121
|
+
ownerSub: string;
|
|
122
|
+
createdAt?: string;
|
|
123
|
+
orgId?: string;
|
|
124
|
+
}): TenantEntity {
|
|
125
|
+
return {
|
|
126
|
+
id: options.id,
|
|
127
|
+
entity_type: options.type,
|
|
128
|
+
external_id: ref(options.source, options.vendorId),
|
|
129
|
+
name: options.name ?? '',
|
|
130
|
+
data: { ...options.data, source: options.source, source_ref: options.vendorId, _origin: ORIGIN },
|
|
131
|
+
created_at: options.createdAt ?? null,
|
|
132
|
+
org_id: options.orgId ?? FIXTURE_ORG,
|
|
133
|
+
owner_sub: options.ownerSub,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const edge = (relType: string, source: string, target: string): TenantRelationship => ({
|
|
138
|
+
id: `rel_${relType}_${source}_${target}`,
|
|
139
|
+
rel_type: relType,
|
|
140
|
+
source,
|
|
141
|
+
target,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const GRANOLA_TRANSCRIPT = [
|
|
145
|
+
'Dana Reed: Morning — let’s start with the connector.',
|
|
146
|
+
'Sam Ortiz: Sounds good. I pulled the OpenAPI shapes yesterday.',
|
|
147
|
+
'Dana Reed: The transcript is going on its own entity.',
|
|
148
|
+
'Sam Ortiz: Agreed — fifty transcripts a page is not a page.',
|
|
149
|
+
'Dana Reed: On renewal, Acme want the same rate for another twelve months.',
|
|
150
|
+
'Sam Ortiz: Then we hold at four thousand a month and cap the seats at forty.',
|
|
151
|
+
].join('\n');
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `party` rows. `display_name` rather than `name` inside `data`, because that is
|
|
155
|
+
* the attribute the canonical party schema declares.
|
|
156
|
+
*/
|
|
157
|
+
export const FIXTURE_TENANT_PARTIES: TenantEntity[] = [
|
|
158
|
+
row({
|
|
159
|
+
type: 'party', id: 'party_dana', source: 'granola', vendorId: 'dana.reed@example.com',
|
|
160
|
+
name: 'Dana Reed', ownerSub: OWNER_A,
|
|
161
|
+
data: { subtype: 'person', display_name: 'Dana Reed', email: 'dana.reed@example.com', domain: 'example.com' },
|
|
162
|
+
}),
|
|
163
|
+
row({
|
|
164
|
+
type: 'party', id: 'party_sam', source: 'granola', vendorId: 'sam.ortiz@example.com',
|
|
165
|
+
name: 'Sam Ortiz', ownerSub: OWNER_A,
|
|
166
|
+
data: { subtype: 'person', display_name: 'Sam Ortiz', email: 'sam.ortiz@example.com', domain: 'example.com' },
|
|
167
|
+
}),
|
|
168
|
+
row({
|
|
169
|
+
type: 'party', id: 'party_kim', source: 'granola', vendorId: 'kim.li@example.com',
|
|
170
|
+
name: 'Kim Li', ownerSub: OWNER_A,
|
|
171
|
+
data: { subtype: 'person', display_name: 'Kim Li', email: 'kim.li@example.com', domain: 'example.com' },
|
|
172
|
+
}),
|
|
173
|
+
row({
|
|
174
|
+
type: 'party', id: 'party_acme', source: 'stripe', vendorId: 'cus_RedSuite01Acme',
|
|
175
|
+
name: 'Acme Robotics', ownerSub: OWNER_A,
|
|
176
|
+
data: { subtype: 'company', display_name: 'Acme Robotics', email: 'ada@example.com', domain: 'example.com' },
|
|
177
|
+
}),
|
|
178
|
+
row({
|
|
179
|
+
type: 'party', id: 'party_beta', source: 'stripe', vendorId: 'cus_RedSuite02Beta',
|
|
180
|
+
name: 'Beta Foods', ownerSub: OWNER_A,
|
|
181
|
+
data: { subtype: 'company', display_name: 'Beta Foods', email: 'bo@example.com', domain: 'example.com' },
|
|
182
|
+
}),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `source_document` rows.
|
|
187
|
+
*
|
|
188
|
+
* The Dropbox and Drive rows are not decoration. bd startsim-768w.38.6's
|
|
189
|
+
* standing test is "would this row, badge, filter or empty state still make
|
|
190
|
+
* sense if the only connected source were Dropbox?", and a fixture set of
|
|
191
|
+
* meeting notes cannot answer it — so a signed contract and a pricing sheet are
|
|
192
|
+
* here, with their long-form text under the key their own connector would use.
|
|
193
|
+
*
|
|
194
|
+
* The `northwind` row is the SCOPE test: it answers a `tandem` question BETTER
|
|
195
|
+
* than anything `tandem` owns, which is the shape of the leak that matters.
|
|
196
|
+
*/
|
|
197
|
+
export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
198
|
+
row({
|
|
199
|
+
type: 'source_document', id: 'not_1d3tmYTlCICgjy', source: 'granola',
|
|
200
|
+
vendorId: 'not_1d3tmYTlCICgjy', name: 'Acme <> StartSimpli weekly sync',
|
|
201
|
+
ownerSub: OWNER_A, createdAt: '2026-01-27T16:10:00Z',
|
|
202
|
+
data: {
|
|
203
|
+
subtype: 'meeting',
|
|
204
|
+
title: 'Acme <> StartSimpli weekly sync',
|
|
205
|
+
summary:
|
|
206
|
+
'Agreed to ship the connector RED suite first. Acme asked to renew at the same rate for another twelve months.',
|
|
207
|
+
occurred_at: '2026-01-27T15:30:00Z',
|
|
208
|
+
url: 'https://notes.granola.ai/d/9f1c2b7e-2a44-4f3e-9a0d-6c1b8e5d4a21',
|
|
209
|
+
// A COUNT, and no names. This is the whole of what the document knows
|
|
210
|
+
// about who was there; everyone else is an edge.
|
|
211
|
+
participant_count: 4,
|
|
212
|
+
transcript: GRANOLA_TRANSCRIPT,
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
row({
|
|
216
|
+
type: 'source_document', id: 'not_7Kq2ZbVnRt4aXm', source: 'granola',
|
|
217
|
+
vendorId: 'not_7Kq2ZbVnRt4aXm', name: 'Design review',
|
|
218
|
+
ownerSub: OWNER_A, createdAt: '2026-01-26T11:52:00Z',
|
|
219
|
+
data: {
|
|
220
|
+
subtype: 'meeting',
|
|
221
|
+
title: 'Design review',
|
|
222
|
+
summary:
|
|
223
|
+
'Reviewed the two-entity split. Note and transcript become separate records so a page of fifty stays a page.',
|
|
224
|
+
occurred_at: '2026-01-26T11:00:00Z',
|
|
225
|
+
url: 'https://notes.granola.ai/d/2b7d41ce-8f05-4a19-b3d2-77e4c0a9f118',
|
|
226
|
+
participant_count: 2,
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
row({
|
|
230
|
+
// The upstream fixture has `title: null` and no calendar event, so the
|
|
231
|
+
// canonical row carries NEITHER a title attribute nor a name. A row with no
|
|
232
|
+
// title is a real row and must still render.
|
|
233
|
+
type: 'source_document', id: 'not_Pd9wLs3EhY6cQz', source: 'granola',
|
|
234
|
+
vendorId: 'not_Pd9wLs3EhY6cQz', ownerSub: OWNER_B, createdAt: '2026-01-25T09:40:00Z',
|
|
235
|
+
data: {
|
|
236
|
+
subtype: 'meeting',
|
|
237
|
+
summary: 'Ad-hoc call, no calendar invite.',
|
|
238
|
+
occurred_at: '2026-01-25T09:15:00Z',
|
|
239
|
+
participant_count: 1,
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
row({
|
|
243
|
+
// The transcript was auto-deleted upstream and the summary is empty. A
|
|
244
|
+
// document with no readable text is the emptiest real row the Library will
|
|
245
|
+
// ever be handed.
|
|
246
|
+
type: 'source_document', id: 'not_Xa4mB8vTuN2kRe', source: 'granola',
|
|
247
|
+
vendorId: 'not_Xa4mB8vTuN2kRe', name: 'Board prep (transcript auto-deleted upstream)',
|
|
248
|
+
ownerSub: OWNER_A, createdAt: '2026-01-24T08:05:00Z',
|
|
249
|
+
data: {
|
|
250
|
+
subtype: 'meeting',
|
|
251
|
+
title: 'Board prep (transcript auto-deleted upstream)',
|
|
252
|
+
occurred_at: '2026-01-20T13:00:00Z',
|
|
253
|
+
participant_count: 1,
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
row({
|
|
257
|
+
type: 'source_document', id: 'dbx_9812f0', source: 'dropbox', vendorId: 'id:9812f0a4b1',
|
|
258
|
+
name: 'Acme Robotics — Master Services Agreement (signed)',
|
|
259
|
+
ownerSub: OWNER_A, createdAt: '2026-02-02T06:00:00Z',
|
|
260
|
+
data: {
|
|
261
|
+
subtype: 'file',
|
|
262
|
+
title: 'Acme Robotics — Master Services Agreement (signed)',
|
|
263
|
+
summary:
|
|
264
|
+
'Signed MSA between StartSimpli and Acme Robotics, effective 1 February 2026, initial term twelve months.',
|
|
265
|
+
occurred_at: '2026-02-01T00:00:00Z',
|
|
266
|
+
url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
|
|
267
|
+
participant_count: 2,
|
|
268
|
+
extracted_text: [
|
|
269
|
+
'MASTER SERVICES AGREEMENT',
|
|
270
|
+
'1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
|
|
271
|
+
'2. Fees. Customer shall pay USD 4,000 per month, invoiced monthly in advance.',
|
|
272
|
+
'3. Seats. The subscription covers up to forty (40) named seats.',
|
|
273
|
+
'4. Renewal. This Agreement renews for successive twelve-month terms unless either party gives sixty (60) days notice.',
|
|
274
|
+
'5. Termination for convenience requires ninety (90) days written notice.',
|
|
275
|
+
].join('\n'),
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
row({
|
|
279
|
+
type: 'source_document', id: 'gdrive_1QpZ', source: 'gdrive', vendorId: '1QpZv7t9kR',
|
|
280
|
+
name: 'Rate card 2026', ownerSub: OWNER_A, createdAt: '2026-01-06T07:30:00Z',
|
|
281
|
+
data: {
|
|
282
|
+
subtype: 'file',
|
|
283
|
+
title: 'Rate card 2026',
|
|
284
|
+
summary:
|
|
285
|
+
'Standard pricing for 2026. Platform tier USD 4,000 per month up to forty seats; overage USD 90 per seat per month.',
|
|
286
|
+
occurred_at: '2026-01-05T00:00:00Z',
|
|
287
|
+
url: 'https://docs.google.example/document/d/1QpZv7t9kR',
|
|
288
|
+
participant_count: 1,
|
|
289
|
+
extracted_text:
|
|
290
|
+
'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
row({
|
|
294
|
+
// ---- A DIFFERENT CUSTOMER WORKSPACE ----
|
|
295
|
+
// Deliberately the BEST answer to "what did we agree with Acme on renewal":
|
|
296
|
+
// more specific, more recent, and full of exactly the terms a ranker loves.
|
|
297
|
+
// Nothing but the scope guard stands between it and a convincing leak.
|
|
298
|
+
type: 'source_document', id: 'nw_contract_44', source: 'dropbox', vendorId: 'id:nw44a2',
|
|
299
|
+
name: 'Acme Robotics renewal — Northwind terms (CONFIDENTIAL)',
|
|
300
|
+
ownerSub: 'usr_northwind', orgId: FIXTURE_OTHER_ORG, createdAt: '2026-02-10T09:00:00Z',
|
|
301
|
+
data: {
|
|
302
|
+
subtype: 'file',
|
|
303
|
+
title: 'Acme Robotics renewal — Northwind terms (CONFIDENTIAL)',
|
|
304
|
+
summary:
|
|
305
|
+
'Northwind renewed Acme Robotics at USD 2,750 per month with unlimited seats and a ninety-day out. Do not share outside Northwind.',
|
|
306
|
+
occurred_at: '2026-02-10T00:00:00Z',
|
|
307
|
+
participant_count: 1,
|
|
308
|
+
extracted_text:
|
|
309
|
+
'Renewal rate USD 2,750/mo. Unlimited seats. Ninety (90) day termination for convenience. Acme renewal signed 2026-02-10.',
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
/** `money_event` rows — amounts in MINOR units, under the declared attribute. */
|
|
315
|
+
export const FIXTURE_TENANT_MONEY_EVENTS: TenantEntity[] = [
|
|
316
|
+
row({
|
|
317
|
+
type: 'money_event', id: 'in_RedSuiteUsd0001', source: 'stripe', vendorId: 'in_RedSuiteUsd0001',
|
|
318
|
+
name: 'Invoice in_RedSuiteUsd0001', ownerSub: OWNER_A,
|
|
319
|
+
data: {
|
|
320
|
+
subtype: 'invoice', amount_minor: 500, currency: 'usd', status: 'paid',
|
|
321
|
+
occurred_at: '2026-08-01T00:00:00Z',
|
|
322
|
+
url: 'https://invoice.stripe.example/i/in_RedSuiteUsd0001',
|
|
323
|
+
description: 'Subscription update proration',
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
row({
|
|
327
|
+
type: 'money_event', id: 'ch_RedSuiteJpy0001', source: 'stripe', vendorId: 'ch_RedSuiteJpy0001',
|
|
328
|
+
name: 'Charge ch_RedSuiteJpy0001', ownerSub: OWNER_A,
|
|
329
|
+
data: {
|
|
330
|
+
subtype: 'charge', amount_minor: 1500, currency: 'jpy', status: 'refunded',
|
|
331
|
+
occurred_at: '2026-07-31T00:01:00Z',
|
|
332
|
+
description: 'Invoice REDSUITE-0001 — refunded in full',
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
row({
|
|
336
|
+
type: 'money_event', id: 'sub_RedSuite01Acme', source: 'stripe', vendorId: 'sub_RedSuite01Acme',
|
|
337
|
+
name: 'Subscription sub_RedSuite01Acme', ownerSub: OWNER_A,
|
|
338
|
+
data: {
|
|
339
|
+
subtype: 'subscription', amount_minor: 400000, currency: 'usd', status: 'active',
|
|
340
|
+
occurred_at: '2026-07-31T00:00:00Z',
|
|
341
|
+
description: 'Platform tier, monthly, forty seats',
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The graph. WHERE THE PEOPLE ACTUALLY LIVE.
|
|
348
|
+
*
|
|
349
|
+
* The `derived_from` row is deliberate: it is a real canonical edge whose target
|
|
350
|
+
* is a DOCUMENT, so anything that treats every edge as a person puts a meeting
|
|
351
|
+
* note in the Parties block and fails here rather than in a browser.
|
|
352
|
+
*/
|
|
353
|
+
export const FIXTURE_TENANT_RELATIONSHIPS: TenantRelationship[] = [
|
|
354
|
+
edge('authored_by', 'not_1d3tmYTlCICgjy', 'party_dana'),
|
|
355
|
+
edge('involves', 'not_1d3tmYTlCICgjy', 'party_sam'),
|
|
356
|
+
edge('involves', 'not_1d3tmYTlCICgjy', 'party_kim'),
|
|
357
|
+
edge('involves', 'not_1d3tmYTlCICgjy', 'party_acme'),
|
|
358
|
+
edge('authored_by', 'not_7Kq2ZbVnRt4aXm', 'party_dana'),
|
|
359
|
+
edge('involves', 'not_7Kq2ZbVnRt4aXm', 'party_sam'),
|
|
360
|
+
edge('authored_by', 'not_Pd9wLs3EhY6cQz', 'party_dana'),
|
|
361
|
+
edge('authored_by', 'not_Xa4mB8vTuN2kRe', 'party_dana'),
|
|
362
|
+
edge('involves', 'dbx_9812f0', 'party_acme'),
|
|
363
|
+
edge('involves', 'dbx_9812f0', 'party_dana'),
|
|
364
|
+
edge('involves', 'gdrive_1QpZ', 'party_dana'),
|
|
365
|
+
edge('involves', 'nw_contract_44', 'party_acme'),
|
|
366
|
+
edge('derived_from', 'not_1d3tmYTlCICgjy', 'not_7Kq2ZbVnRt4aXm'),
|
|
367
|
+
edge('billed_to', 'in_RedSuiteUsd0001', 'party_beta'),
|
|
368
|
+
edge('billed_to', 'ch_RedSuiteJpy0001', 'party_acme'),
|
|
369
|
+
edge('billed_to', 'sub_RedSuite01Acme', 'party_acme'),
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// DERIVED THROUGH THE REAL MAPPER
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
//
|
|
376
|
+
// Not hand-written domain objects. If `./entities` stops reading the storage the
|
|
377
|
+
// pipeline writes, these go wrong and every suite that consumes them goes red —
|
|
378
|
+
// which is the property that was missing when the Parties block shipped empty.
|
|
379
|
+
|
|
380
|
+
const FALLBACK_SCOPE = scope(FIXTURE_ORG, OWNER_A);
|
|
381
|
+
|
|
382
|
+
export const FIXTURE_PARTIES: Party[] = FIXTURE_TENANT_PARTIES.map((entity) =>
|
|
383
|
+
toParty(entity, FALLBACK_SCOPE),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
/** documentId / moneyEventId -> the people on it, resolved from the edges. */
|
|
387
|
+
export const FIXTURE_PARTICIPANTS: Map<string, PartyRef[]> = indexParticipants(
|
|
388
|
+
FIXTURE_TENANT_RELATIONSHIPS,
|
|
389
|
+
FIXTURE_PARTIES,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
export const FIXTURE_DOCUMENTS: SourceDocument[] = FIXTURE_TENANT_DOCUMENTS.map((entity) =>
|
|
393
|
+
toSourceDocument(
|
|
394
|
+
entity,
|
|
395
|
+
FALLBACK_SCOPE,
|
|
396
|
+
FIXTURE_SOURCE_LABELS,
|
|
397
|
+
FIXTURE_PARTICIPANTS.get(String(entity.id)) ?? [],
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
export const FIXTURE_MONEY_EVENTS: MoneyEvent[] = FIXTURE_TENANT_MONEY_EVENTS.map((entity) =>
|
|
402
|
+
toMoneyEvent(
|
|
403
|
+
entity,
|
|
404
|
+
FALLBACK_SCOPE,
|
|
405
|
+
FIXTURE_SOURCE_LABELS,
|
|
406
|
+
FIXTURE_PARTICIPANTS.get(String(entity.id)) ?? [],
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* One row per connected source, including one that is broken and one that has
|
|
412
|
+
* never delivered — because a freshness strip that only ever says "ok" has never
|
|
413
|
+
* been looked at.
|
|
414
|
+
*/
|
|
415
|
+
export const FIXTURE_SOURCES: SourceFreshness[] = [
|
|
416
|
+
{
|
|
417
|
+
source: 'granola',
|
|
418
|
+
label: 'Granola',
|
|
419
|
+
documentCount: 4,
|
|
420
|
+
lastSyncedAt: '2026-08-07T06:00:00Z',
|
|
421
|
+
status: 'ok',
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
source: 'dropbox',
|
|
425
|
+
label: 'Dropbox',
|
|
426
|
+
documentCount: 1,
|
|
427
|
+
lastSyncedAt: '2026-08-02T06:00:00Z',
|
|
428
|
+
status: 'stale',
|
|
429
|
+
detail: 'Last sync was more than 26 hours ago.',
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
source: 'gdrive',
|
|
433
|
+
label: 'Google Drive',
|
|
434
|
+
documentCount: 1,
|
|
435
|
+
lastSyncedAt: '2026-08-06T07:30:00Z',
|
|
436
|
+
status: 'error',
|
|
437
|
+
detail: 'The vendor rejected this key (401). Reconnect Google Drive.',
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
source: 'stripe',
|
|
441
|
+
label: 'Stripe',
|
|
442
|
+
documentCount: 0,
|
|
443
|
+
lastSyncedAt: null,
|
|
444
|
+
status: 'never',
|
|
445
|
+
detail: 'Connected, but nothing has arrived yet.',
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// a CorpusApi over the fixtures
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
export interface FixtureCorpusOptions {
|
|
454
|
+
documents?: SourceDocument[];
|
|
455
|
+
moneyEvents?: MoneyEvent[];
|
|
456
|
+
parties?: Party[];
|
|
457
|
+
sources?: SourceFreshness[];
|
|
458
|
+
/** Page size when the caller does not ask for one. Small, to exercise paging. */
|
|
459
|
+
defaultPageSize?: number;
|
|
460
|
+
/**
|
|
461
|
+
* The scope this "server" enforces. Omit to model a CORRECT server that
|
|
462
|
+
* scopes every read; set `leakScope` to model a broken one and prove the
|
|
463
|
+
* client-side guard catches what the server should have.
|
|
464
|
+
*/
|
|
465
|
+
scope?: CorpusScope;
|
|
466
|
+
/** Return rows from every org, as an over-broad server would. */
|
|
467
|
+
leakScope?: boolean;
|
|
468
|
+
/** Record every query the composers made, for assertions about server-side filtering. */
|
|
469
|
+
onQuery?: (endpoint: string, query: unknown) => void;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function haystack(document: SourceDocument): string {
|
|
473
|
+
return [
|
|
474
|
+
document.title,
|
|
475
|
+
document.body,
|
|
476
|
+
...document.sections.map((section) => section.text),
|
|
477
|
+
...document.participants.map((party) => `${party.name} ${party.email ?? ''}`),
|
|
478
|
+
document.sourceLabel,
|
|
479
|
+
]
|
|
480
|
+
.join(' ')
|
|
481
|
+
.toLowerCase();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function paginate<T>(rows: T[], page: number, pageSize: number, applied: string[]): CorpusPage<T> {
|
|
485
|
+
const start = (page - 1) * pageSize;
|
|
486
|
+
return {
|
|
487
|
+
count: rows.length,
|
|
488
|
+
next: start + pageSize < rows.length ? String(page + 1) : null,
|
|
489
|
+
previous: page > 1 ? String(page - 1) : null,
|
|
490
|
+
results: rows.slice(start, start + pageSize),
|
|
491
|
+
appliedFilters: applied,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A `CorpusApi` that behaves the way the tenant API is SUPPOSED to: it filters
|
|
497
|
+
* and pages server-side and reports which filters it applied. It is the "server"
|
|
498
|
+
* in the harness and in the tests, which is what makes both of them a fair test
|
|
499
|
+
* of a composer that must never filter in the browser.
|
|
500
|
+
*/
|
|
501
|
+
export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): CorpusApi {
|
|
502
|
+
const {
|
|
503
|
+
documents = FIXTURE_DOCUMENTS,
|
|
504
|
+
moneyEvents = FIXTURE_MONEY_EVENTS,
|
|
505
|
+
parties = FIXTURE_PARTIES,
|
|
506
|
+
sources = FIXTURE_SOURCES,
|
|
507
|
+
defaultPageSize = 5,
|
|
508
|
+
scope: serverScope,
|
|
509
|
+
leakScope = false,
|
|
510
|
+
onQuery,
|
|
511
|
+
} = options;
|
|
512
|
+
|
|
513
|
+
const inScope = <T extends { scope: CorpusScope }>(rows: T[]): T[] => {
|
|
514
|
+
if (leakScope || !serverScope) return rows;
|
|
515
|
+
return rows.filter(
|
|
516
|
+
(row) =>
|
|
517
|
+
row.scope.orgId === serverScope.orgId &&
|
|
518
|
+
(!serverScope.ownerSub || row.scope.ownerSub === serverScope.ownerSub),
|
|
519
|
+
);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
async listDocuments(query: DocumentQuery) {
|
|
524
|
+
onQuery?.('documents', query);
|
|
525
|
+
const applied: string[] = [];
|
|
526
|
+
let rows = inScope(documents);
|
|
527
|
+
|
|
528
|
+
if (query.source) {
|
|
529
|
+
applied.push('source');
|
|
530
|
+
rows = rows.filter((document) => document.source === query.source);
|
|
531
|
+
}
|
|
532
|
+
if (query.partyId) {
|
|
533
|
+
applied.push('partyId');
|
|
534
|
+
rows = rows.filter((document) =>
|
|
535
|
+
document.participants.some((party) => party.id === query.partyId),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (query.occurredAfter) {
|
|
539
|
+
applied.push('occurredAfter');
|
|
540
|
+
rows = rows.filter((d) => (d.occurredAt ?? '') >= query.occurredAfter!);
|
|
541
|
+
}
|
|
542
|
+
if (query.occurredBefore) {
|
|
543
|
+
applied.push('occurredBefore');
|
|
544
|
+
rows = rows.filter((d) => (d.occurredAt ?? '') <= query.occurredBefore!);
|
|
545
|
+
}
|
|
546
|
+
if (query.search) {
|
|
547
|
+
applied.push('search');
|
|
548
|
+
const needles = query.search.toLowerCase().split(/\s+/).filter(Boolean);
|
|
549
|
+
rows = rows.filter((document) => {
|
|
550
|
+
const text = haystack(document);
|
|
551
|
+
return needles.some((needle) => text.includes(needle));
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
rows = [...rows].sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? ''));
|
|
556
|
+
return paginate(rows, query.page ?? 1, query.pageSize ?? defaultPageSize, applied);
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
async getDocument(id: string) {
|
|
560
|
+
const found = inScope(documents).find((document) => document.id === id);
|
|
561
|
+
if (!found) {
|
|
562
|
+
// A row outside the caller's scope is ABSENT, not forbidden — same
|
|
563
|
+
// convention as the tenant API, where a cross-scope id is a 404.
|
|
564
|
+
const error = new Error('No such document.') as Error & { status?: number };
|
|
565
|
+
error.status = 404;
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
return found;
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
async listParties(query = {}) {
|
|
572
|
+
onQuery?.('parties', query);
|
|
573
|
+
let rows = inScope(parties);
|
|
574
|
+
const applied: string[] = [];
|
|
575
|
+
if (query.search) {
|
|
576
|
+
applied.push('search');
|
|
577
|
+
const needle = query.search.toLowerCase();
|
|
578
|
+
rows = rows.filter((party) => `${party.name} ${party.email ?? ''}`.toLowerCase().includes(needle));
|
|
579
|
+
}
|
|
580
|
+
return paginate(rows, query.page ?? 1, query.pageSize ?? 50, applied);
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
async listMoneyEvents(query = {}) {
|
|
584
|
+
onQuery?.('money', query);
|
|
585
|
+
let rows = inScope(moneyEvents);
|
|
586
|
+
const applied: string[] = [];
|
|
587
|
+
if (query.partyId) {
|
|
588
|
+
applied.push('partyId');
|
|
589
|
+
rows = rows.filter((event) => event.party?.id === query.partyId);
|
|
590
|
+
}
|
|
591
|
+
if (query.search) {
|
|
592
|
+
applied.push('search');
|
|
593
|
+
const needles = query.search.toLowerCase().split(/\s+/).filter(Boolean);
|
|
594
|
+
rows = rows.filter((event) => {
|
|
595
|
+
const text = `${event.subtype} ${event.status} ${event.description ?? ''} ${event.party?.name ?? ''}`.toLowerCase();
|
|
596
|
+
return needles.some((needle) => text.includes(needle));
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return paginate(rows, query.page ?? 1, query.pageSize ?? 20, applied);
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
async listSources() {
|
|
603
|
+
return sources;
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// a fake TENANT, one level lower
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A `CorpusHttp` that answers like the tenant data plane itself.
|
|
614
|
+
*
|
|
615
|
+
* `createFixtureCorpusApi` above fakes the CONTRACT, which is the right thing to
|
|
616
|
+
* hand a composer. This fakes the WIRE, which is the only way to test the
|
|
617
|
+
* adapter that turns storage into the contract — the seam where the Parties
|
|
618
|
+
* block was lost (bd startsim-768w.38.22). It reproduces the tenant's real
|
|
619
|
+
* behaviour, including the parts that are inconvenient:
|
|
620
|
+
*
|
|
621
|
+
* • `?type=` is the ONLY filter honoured — `search`, `source` and `party` are
|
|
622
|
+
* accepted and ignored, exactly as `EntityViewSet` does today;
|
|
623
|
+
* • `page_size` is IGNORED (`PageNumberPagination` with no
|
|
624
|
+
* `page_size_query_param`), so a caller asking for 200 gets `pageSize`;
|
|
625
|
+
* • `/api/v1/relationships/` returns every edge in the org, unfiltered,
|
|
626
|
+
* because the tenant has no `?source=` on it either.
|
|
627
|
+
*
|
|
628
|
+
* Every request is recorded, so "did the browser filter it" and "how many round
|
|
629
|
+
* trips did one screen cost" are both assertable.
|
|
630
|
+
*/
|
|
631
|
+
export function createFixtureCorpusHttp(options: {
|
|
632
|
+
entities?: TenantEntity[];
|
|
633
|
+
relationships?: TenantRelationship[];
|
|
634
|
+
/** Rows per page, matching the tenant's own `PAGE_SIZE`. */
|
|
635
|
+
pageSize?: number;
|
|
636
|
+
/** Endpoints to fail, so the fail-soft paths are exercised. */
|
|
637
|
+
missing?: string[];
|
|
638
|
+
onRequest?: (path: string, params: Record<string, unknown>) => void;
|
|
639
|
+
} = {}) {
|
|
640
|
+
const entities = options.entities ?? [
|
|
641
|
+
...FIXTURE_TENANT_DOCUMENTS,
|
|
642
|
+
...FIXTURE_TENANT_PARTIES,
|
|
643
|
+
...FIXTURE_TENANT_MONEY_EVENTS,
|
|
644
|
+
];
|
|
645
|
+
const relationships = options.relationships ?? FIXTURE_TENANT_RELATIONSHIPS;
|
|
646
|
+
const pageSize = options.pageSize ?? 50;
|
|
647
|
+
const missing = options.missing ?? [];
|
|
648
|
+
const requests: Array<{ path: string; params: Record<string, unknown> }> = [];
|
|
649
|
+
|
|
650
|
+
function slice<T>(rows: T[], params: Record<string, unknown>) {
|
|
651
|
+
const page = Number(params.page ?? 1) || 1;
|
|
652
|
+
const start = (page - 1) * pageSize;
|
|
653
|
+
return {
|
|
654
|
+
count: rows.length,
|
|
655
|
+
next: start + pageSize < rows.length ? `?page=${page + 1}` : null,
|
|
656
|
+
previous: page > 1 ? `?page=${page - 1}` : null,
|
|
657
|
+
results: rows.slice(start, start + pageSize),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const http = {
|
|
662
|
+
async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
|
|
663
|
+
const params = init?.params ?? {};
|
|
664
|
+
requests.push({ path, params });
|
|
665
|
+
options.onRequest?.(path, params);
|
|
666
|
+
|
|
667
|
+
if (missing.some((fragment) => path.includes(fragment))) {
|
|
668
|
+
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
669
|
+
}
|
|
670
|
+
if (path.includes('relationships')) {
|
|
671
|
+
return slice(relationships, params) as T;
|
|
672
|
+
}
|
|
673
|
+
if (path.includes('entities')) {
|
|
674
|
+
const id = /entities\/(.+)$/.exec(path)?.[1];
|
|
675
|
+
if (id) {
|
|
676
|
+
const found = entities.find((entity) => String(entity.id) === decodeURIComponent(id));
|
|
677
|
+
if (!found) throw Object.assign(new Error('not found'), { status: 404 });
|
|
678
|
+
return found as T;
|
|
679
|
+
}
|
|
680
|
+
const type = params.type ? String(params.type) : '';
|
|
681
|
+
// `search`/`source`/`party` are deliberately NOT applied.
|
|
682
|
+
const rows = type ? entities.filter((entity) => entity.entity_type === type) : entities;
|
|
683
|
+
return slice(rows, params) as T;
|
|
684
|
+
}
|
|
685
|
+
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
return { http, requests };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* A `CorpusLlm` that answers WITHOUT a provider, by quoting the documents it was
|
|
694
|
+
* handed and citing every one it quoted.
|
|
695
|
+
*
|
|
696
|
+
* It exists so the retrieval, citation-resolution and scope machinery can be
|
|
697
|
+
* driven end to end — in vitest and in a browser — with no API key. It reports
|
|
698
|
+
* `live: false` from `describe()`, and every surface renders that, so a run
|
|
699
|
+
* powered by this can never be mistaken on screen for a run powered by a model.
|
|
700
|
+
*/
|
|
701
|
+
export function createFixtureLlm(): import('./types').CorpusLlm {
|
|
702
|
+
return {
|
|
703
|
+
describe: () => ({ provider: 'fixture', model: 'fixture-echo', live: false }),
|
|
704
|
+
async answer(input) {
|
|
705
|
+
if (input.documents.length === 0) {
|
|
706
|
+
return { answer: '', citations: [], unanswerable: true };
|
|
707
|
+
}
|
|
708
|
+
const lines = input.documents.map((document) => {
|
|
709
|
+
const first = document.text.split('\n').find((line) => line.trim()) ?? document.title;
|
|
710
|
+
return `- ${document.title} (${document.source}): ${first.trim().slice(0, 180)}`;
|
|
711
|
+
});
|
|
712
|
+
const heading =
|
|
713
|
+
input.mode === 'generate'
|
|
714
|
+
? `Draft assembled from ${input.documents.length} record(s):`
|
|
715
|
+
: `From ${input.documents.length} record(s) in your corpus:`;
|
|
716
|
+
return {
|
|
717
|
+
answer: [heading, ...lines].join('\n'),
|
|
718
|
+
citations: input.documents.map((document) => ({
|
|
719
|
+
documentId: document.id,
|
|
720
|
+
quote: document.text.slice(0, 120).trim(),
|
|
721
|
+
})),
|
|
722
|
+
unanswerable: false,
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|