@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.
- package/package.json +11 -4
- package/src/__tests__/connections.test.tsx +116 -1
- 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/ConnectionsPage.tsx +186 -2
- package/src/components/pages/LibraryPage.tsx +612 -0
- package/src/components/pages/types.ts +21 -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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The corpus contract — the typed layer between the tenant data API and the two
|
|
3
|
+
* product surfaces (bd startsim-768w.38.6 Library, startsim-768w.38.7 Ask).
|
|
4
|
+
*
|
|
5
|
+
* WHY A CONTRACT AND NOT A CLIENT: the composers take an INJECTED api the same
|
|
6
|
+
* way `ConnectionsPage` does, so they depend on a SHAPE. A test hands them an
|
|
7
|
+
* object literal; an app hands them an adapter over its own HTTP client. No
|
|
8
|
+
* React context, no app-local provider.
|
|
9
|
+
*
|
|
10
|
+
* WHY EVERY NOUN HERE IS SOURCE-AGNOSTIC (bd startsim-768w.38.2, and the rule
|
|
11
|
+
* ratified in feedback_foundry_builder_generic_not_usecase): the record a
|
|
12
|
+
* Granola meeting becomes must be the SAME record a Dropbox file becomes. There
|
|
13
|
+
* is no `GranolaNote`, no `transcript` field, and no `if (source === ...)`
|
|
14
|
+
* anywhere in this package. A vendor difference arrives as DATA — a source key,
|
|
15
|
+
* a source label, a named section — exactly the way the connector catalog
|
|
16
|
+
* already works.
|
|
17
|
+
*
|
|
18
|
+
* THE TEST APPLIED TO EVERY FIELD BELOW: would this still make sense if the only
|
|
19
|
+
* connected source were Dropbox? `sections` passes (a PDF has extracted text the
|
|
20
|
+
* way a meeting has a transcript). A `speakers` field would not have, which is
|
|
21
|
+
* why speakers are `participants` and why they are `PartyRef`s.
|
|
22
|
+
*
|
|
23
|
+
* SCHEMA COUPLING IS DELIBERATELY CONFINED: nothing outside `./entities` knows
|
|
24
|
+
* how a `SourceDocument` is stored. When bd startsim-768w.38.2 lands and the
|
|
25
|
+
* attribute names shift, `./entities.ts` is the one file that changes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Which connector filled a record. FREE-FORM on purpose — `granola`, `dropbox`,
|
|
30
|
+
* `gdrive`, `stripe`, `upload`, and whatever ships next week. A union type here
|
|
31
|
+
* would mean a fourth source could not be added without an app change, which is
|
|
32
|
+
* precisely the thing the epic forbids.
|
|
33
|
+
*/
|
|
34
|
+
export type SourceKey = string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The tenant boundary as the data plane actually enforces it.
|
|
38
|
+
*
|
|
39
|
+
* `orgId` is the JWT `org_id` claim — under customer workspaces (bd
|
|
40
|
+
* startsim-768w.34.4) that is the CUSTOMER's org, not the foundry's root
|
|
41
|
+
* company, and it is the line an answer must never cross.
|
|
42
|
+
*
|
|
43
|
+
* `ownerSub` is the central user_id stamped on every row as `owner_sub`. Today
|
|
44
|
+
* the tenant API scopes reads by org and by readable TYPE, not by owner — so a
|
|
45
|
+
* scope carrying only `orgId` is the honest default. A caller that wants the
|
|
46
|
+
* narrower posture passes `ownerSub` and gets it enforced here as well.
|
|
47
|
+
*/
|
|
48
|
+
export interface CorpusScope {
|
|
49
|
+
orgId: string;
|
|
50
|
+
ownerSub?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A person or company the corpus refers to, as referenced FROM a record.
|
|
55
|
+
* One noun with a subtype, never separate Contact/Customer types
|
|
56
|
+
* (feedback_brutally_generic).
|
|
57
|
+
*/
|
|
58
|
+
export interface PartyRef {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
/** `person` | `company` | anything a tenant models. Data, not an enum. */
|
|
62
|
+
kind?: string;
|
|
63
|
+
email?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A Party as a first-class record. */
|
|
67
|
+
export interface Party extends PartyRef {
|
|
68
|
+
source: SourceKey;
|
|
69
|
+
sourceLabel?: string;
|
|
70
|
+
sourceRef?: string;
|
|
71
|
+
emails?: string[];
|
|
72
|
+
scope: CorpusScope;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Long-form text a source carried alongside the document's own body.
|
|
77
|
+
*
|
|
78
|
+
* A Granola note contributes `{key:'transcript', label:'Transcript'}`; a Dropbox
|
|
79
|
+
* PDF contributes `{key:'extracted_text', label:'Extracted text'}`; a Stripe
|
|
80
|
+
* invoice contributes none. The composer renders whatever it is handed and names
|
|
81
|
+
* it with the source's own label, so no screen knows what a transcript is.
|
|
82
|
+
*/
|
|
83
|
+
export interface DocumentSection {
|
|
84
|
+
key: string;
|
|
85
|
+
label: string;
|
|
86
|
+
text: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The corpus unit. Everything an AI reads is one of these, whatever pulled it in.
|
|
91
|
+
*/
|
|
92
|
+
export interface SourceDocument {
|
|
93
|
+
id: string;
|
|
94
|
+
title: string;
|
|
95
|
+
/** The primary readable text — a summary, a description, a body. */
|
|
96
|
+
body: string;
|
|
97
|
+
/** Additional long-form the source provided. See `DocumentSection`. */
|
|
98
|
+
sections: DocumentSection[];
|
|
99
|
+
/** When the thing HAPPENED, which is not when it was ingested. */
|
|
100
|
+
occurredAt: string | null;
|
|
101
|
+
source: SourceKey;
|
|
102
|
+
/** Human name of the source, from the catalog — never hardcoded per vendor. */
|
|
103
|
+
sourceLabel: string;
|
|
104
|
+
/** The vendor's own id, so a row can be traced back out of the system. */
|
|
105
|
+
sourceRef: string;
|
|
106
|
+
/** Deep link into the vendor, when it has one. */
|
|
107
|
+
url?: string;
|
|
108
|
+
participants: PartyRef[];
|
|
109
|
+
scope: CorpusScope;
|
|
110
|
+
/** When the connector last wrote this row. Drives the freshness claim. */
|
|
111
|
+
ingestedAt?: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stripe's side of the corpus, as ONE type discriminated by subtype
|
|
116
|
+
* (`charge` | `invoice` | `subscription` | ...), pointing at a Party.
|
|
117
|
+
* A future billing connector fills the same type.
|
|
118
|
+
*/
|
|
119
|
+
export interface MoneyEvent {
|
|
120
|
+
id: string;
|
|
121
|
+
subtype: string;
|
|
122
|
+
/** Minor units, as every payment vendor reports them. */
|
|
123
|
+
amount: number;
|
|
124
|
+
currency: string;
|
|
125
|
+
status: string;
|
|
126
|
+
occurredAt: string | null;
|
|
127
|
+
source: SourceKey;
|
|
128
|
+
sourceLabel: string;
|
|
129
|
+
sourceRef: string;
|
|
130
|
+
url?: string;
|
|
131
|
+
description?: string;
|
|
132
|
+
party: PartyRef | null;
|
|
133
|
+
scope: CorpusScope;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Freshness is part of the SCREEN, not a settings page (bd startsim-768w.38.6).
|
|
138
|
+
* A corpus silently four days stale is what makes an AI answer confidently
|
|
139
|
+
* wrong, so the Library states it per source and the Ask surface repeats it.
|
|
140
|
+
*/
|
|
141
|
+
export interface SourceFreshness {
|
|
142
|
+
source: SourceKey;
|
|
143
|
+
label: string;
|
|
144
|
+
/** How many documents this source has contributed to the corpus. */
|
|
145
|
+
documentCount: number;
|
|
146
|
+
lastSyncedAt: string | null;
|
|
147
|
+
/**
|
|
148
|
+
* `ok` — synced recently and cleanly.
|
|
149
|
+
* `stale` — the last sync succeeded but was long enough ago to matter.
|
|
150
|
+
* `error` — the last sync failed; the corpus is missing whatever came after.
|
|
151
|
+
* `never` — connected but has never delivered a record.
|
|
152
|
+
*/
|
|
153
|
+
status: 'ok' | 'stale' | 'error' | 'never';
|
|
154
|
+
/** Why it is not `ok`, in the words the server used. */
|
|
155
|
+
detail?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** DRF PageNumberPagination, which is what the tenant data API speaks. */
|
|
159
|
+
export interface CorpusPage<T> {
|
|
160
|
+
count: number;
|
|
161
|
+
next: string | null;
|
|
162
|
+
previous: string | null;
|
|
163
|
+
results: T[];
|
|
164
|
+
/**
|
|
165
|
+
* Which of the requested filters the SERVER actually applied.
|
|
166
|
+
*
|
|
167
|
+
* A search box wired to a backend that ignores `search` renders an unfiltered
|
|
168
|
+
* list that looks filtered, which is a lie the user has no way to catch. When
|
|
169
|
+
* this is present, the Library states the gap on the screen instead. Omit it
|
|
170
|
+
* and the composer assumes everything requested was honoured.
|
|
171
|
+
*/
|
|
172
|
+
appliedFilters?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every filter here is a SERVER parameter (CLAUDE.md rule 8). The Library never
|
|
177
|
+
* loads the corpus and filters it in the browser — that breaks the count, breaks
|
|
178
|
+
* the page, and stops working at exactly the size where it starts to matter.
|
|
179
|
+
*/
|
|
180
|
+
export interface DocumentQuery {
|
|
181
|
+
search?: string;
|
|
182
|
+
source?: SourceKey;
|
|
183
|
+
partyId?: string;
|
|
184
|
+
/** ISO instants, filtering on `occurredAt`. */
|
|
185
|
+
occurredAfter?: string;
|
|
186
|
+
occurredBefore?: string;
|
|
187
|
+
page?: number;
|
|
188
|
+
pageSize?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What the composers are handed. An app implements it over its own HTTP client;
|
|
193
|
+
* `createCorpusApi` in `./client` does that for the tenant `/api/v1/*` endpoints.
|
|
194
|
+
*/
|
|
195
|
+
export interface CorpusApi {
|
|
196
|
+
listDocuments(query: DocumentQuery): Promise<CorpusPage<SourceDocument>>;
|
|
197
|
+
getDocument(id: string): Promise<SourceDocument>;
|
|
198
|
+
listParties(query?: { search?: string; page?: number; pageSize?: number }): Promise<CorpusPage<Party>>;
|
|
199
|
+
/** Per-source freshness, for the strip at the top of the Library. */
|
|
200
|
+
listSources(): Promise<SourceFreshness[]>;
|
|
201
|
+
/**
|
|
202
|
+
* Optional because a tenant with no payment connector has no money events, and
|
|
203
|
+
* a corpus surface that requires one would fail the Dropbox-only test.
|
|
204
|
+
*/
|
|
205
|
+
listMoneyEvents?(query?: {
|
|
206
|
+
partyId?: string;
|
|
207
|
+
search?: string;
|
|
208
|
+
page?: number;
|
|
209
|
+
pageSize?: number;
|
|
210
|
+
}): Promise<CorpusPage<MoneyEvent>>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Ask & Generate (bd startsim-768w.38.7)
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* ASK answers a question from the corpus. GENERATE produces a draft — a
|
|
219
|
+
* contract, a follow-up, a summary — from a prompt plus the corpus. Same
|
|
220
|
+
* retrieval, same citation rules, one surface; the only difference is what the
|
|
221
|
+
* model is told to emit.
|
|
222
|
+
*/
|
|
223
|
+
export type AskMode = 'ask' | 'generate';
|
|
224
|
+
|
|
225
|
+
/** A record the retriever selected, with the reason it selected it. */
|
|
226
|
+
export interface RetrievedDocument {
|
|
227
|
+
document: SourceDocument;
|
|
228
|
+
score: number;
|
|
229
|
+
/** Which query terms hit, so the ranking can be argued with rather than trusted. */
|
|
230
|
+
matchedTerms: string[];
|
|
231
|
+
/** The passage the term matched in, for the citation card. */
|
|
232
|
+
snippet: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A citation the model emitted, AFTER it has been resolved back to a record the
|
|
237
|
+
* retriever actually supplied. A citation that cannot be resolved is dropped —
|
|
238
|
+
* see `AskResult.unresolvedCitations`.
|
|
239
|
+
*/
|
|
240
|
+
export interface Citation {
|
|
241
|
+
documentId: string;
|
|
242
|
+
document: SourceDocument;
|
|
243
|
+
/** The model's own quote, for the user to check against the record. */
|
|
244
|
+
quote: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface AskRequest {
|
|
248
|
+
question: string;
|
|
249
|
+
mode: AskMode;
|
|
250
|
+
scope: CorpusScope;
|
|
251
|
+
/** Extra instructions for GENERATE — tone, format, the kind of document. */
|
|
252
|
+
instructions?: string;
|
|
253
|
+
/** Narrow the corpus before retrieval, exactly like the Library's filters. */
|
|
254
|
+
filters?: Pick<DocumentQuery, 'source' | 'partyId' | 'occurredAfter' | 'occurredBefore'>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `answered` — an answer with at least one resolved citation.
|
|
259
|
+
* `uncited` — the model produced text but nothing traceable. The UI must SAY so
|
|
260
|
+
* rather than render it: an answer you cannot trace is an answer you cannot
|
|
261
|
+
* trust with a contract.
|
|
262
|
+
* `no_evidence` — retrieval found nothing in scope. This is the correct outcome
|
|
263
|
+
* of a cross-scope question and is not an error.
|
|
264
|
+
*/
|
|
265
|
+
export type AskStatus = 'answered' | 'uncited' | 'no_evidence';
|
|
266
|
+
|
|
267
|
+
export interface AskResult {
|
|
268
|
+
status: AskStatus;
|
|
269
|
+
answer: string;
|
|
270
|
+
citations: Citation[];
|
|
271
|
+
/** Everything retrieval offered the model, so "why did it not use X" is answerable. */
|
|
272
|
+
considered: RetrievedDocument[];
|
|
273
|
+
/**
|
|
274
|
+
* Ids the model cited that retrieval never supplied. Non-empty means the model
|
|
275
|
+
* invented a source; it is surfaced, never silently swallowed.
|
|
276
|
+
*/
|
|
277
|
+
unresolvedCitations: string[];
|
|
278
|
+
/**
|
|
279
|
+
* How many candidate records were discarded by the scope guard before the
|
|
280
|
+
* prompt was built. A non-zero value on a correctly-scoped API means the API
|
|
281
|
+
* returned something it should not have, and is worth seeing.
|
|
282
|
+
*/
|
|
283
|
+
droppedOutOfScope: number;
|
|
284
|
+
/** Which provider/model actually answered, and whether it was a real one. */
|
|
285
|
+
provider: LlmDescription;
|
|
286
|
+
/** Freshness at answer time — an answer over a stale corpus says so. */
|
|
287
|
+
freshness?: SourceFreshness[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface LlmDescription {
|
|
291
|
+
provider: string;
|
|
292
|
+
model: string;
|
|
293
|
+
/** False when the answer came from a stub/fixture rather than a live model. */
|
|
294
|
+
live: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The document payload handed to the model, with the id it must echo back.
|
|
299
|
+
* ONE call carries all of them (feedback_batch_llm_calls) — never a loop of N.
|
|
300
|
+
*/
|
|
301
|
+
export interface PromptDocument {
|
|
302
|
+
id: string;
|
|
303
|
+
title: string;
|
|
304
|
+
source: string;
|
|
305
|
+
occurredAt: string | null;
|
|
306
|
+
participants: string[];
|
|
307
|
+
text: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface AnswerRequest {
|
|
311
|
+
question: string;
|
|
312
|
+
mode: AskMode;
|
|
313
|
+
instructions?: string;
|
|
314
|
+
documents: PromptDocument[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The structured shape the model must return. Validated before it is trusted. */
|
|
318
|
+
export interface AnswerDraft {
|
|
319
|
+
answer: string;
|
|
320
|
+
citations: Array<{ documentId: string; quote?: string }>;
|
|
321
|
+
/** The model's own admission that the documents do not answer the question. */
|
|
322
|
+
unanswerable?: boolean;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The LLM seam. Deliberately ONE method that takes N documents, so the batching
|
|
327
|
+
* rule is structural rather than a thing a caller has to remember.
|
|
328
|
+
*
|
|
329
|
+
* `@startsimpli/llm` is the only implementation that talks to a provider — see
|
|
330
|
+
* `./llm`. Nothing in this package constructs a provider client.
|
|
331
|
+
*/
|
|
332
|
+
export interface CorpusLlm {
|
|
333
|
+
answer(input: AnswerRequest): Promise<AnswerDraft>;
|
|
334
|
+
describe(): LlmDescription;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* What `AskPage` is injected with. An app points it at its own route handler;
|
|
339
|
+
* the handler calls `askCorpus` server-side, where the API key lives.
|
|
340
|
+
*/
|
|
341
|
+
export interface CorpusAskApi {
|
|
342
|
+
ask(request: AskRequest): Promise<AskResult>;
|
|
343
|
+
/**
|
|
344
|
+
* Persist a generated draft back INTO the corpus, so the draft is part of the
|
|
345
|
+
* corpus next time. Optional: a read-only deployment simply omits it and the
|
|
346
|
+
* Save control does not render.
|
|
347
|
+
*/
|
|
348
|
+
saveDraft?(input: {
|
|
349
|
+
title: string;
|
|
350
|
+
body: string;
|
|
351
|
+
citations: string[];
|
|
352
|
+
scope: CorpusScope;
|
|
353
|
+
}): Promise<SourceDocument>;
|
|
354
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,3 +12,12 @@ export * from './types/index';
|
|
|
12
12
|
export * from './api/index';
|
|
13
13
|
export * from './hooks/index';
|
|
14
14
|
export * from './components/index';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The corpus layer — the READ side of connections (bd startsim-768w.38.6 and
|
|
18
|
+
* startsim-768w.38.7). Browser-safe by construction: the only module that talks
|
|
19
|
+
* to an LLM provider is `@startsimpli/integrations/corpus-llm`, which is NOT
|
|
20
|
+
* re-exported here, so mounting a composer can never pull a provider key into a
|
|
21
|
+
* client bundle.
|
|
22
|
+
*/
|
|
23
|
+
export * from './corpus/index';
|