@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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask & Generate over the corpus (bd startsim-768w.38.7).
|
|
3
|
+
*
|
|
4
|
+
* This is the whole pipeline, server-side, in one function:
|
|
5
|
+
*
|
|
6
|
+
* 1. pull CANDIDATES from the tenant data API, already org-scoped by the
|
|
7
|
+
* server and narrowed by the same filters the Library uses;
|
|
8
|
+
* 2. run the SCOPE GUARD over them — a third check, immediately before the
|
|
9
|
+
* prompt is built (see ./retrieval for why);
|
|
10
|
+
* 3. RANK them, keyword + recency;
|
|
11
|
+
* 4. ONE LLM call carrying all N documents with ids the model must echo
|
|
12
|
+
* (feedback_batch_llm_calls — never a loop of N);
|
|
13
|
+
* 5. RESOLVE every citation back to a record retrieval actually supplied,
|
|
14
|
+
* drop the ones that do not resolve, and report that it happened;
|
|
15
|
+
* 6. refuse to present an UNCITED answer as an answer.
|
|
16
|
+
*
|
|
17
|
+
* STEP 5 AND 6 ARE THE POINT. An answer the user cannot trace is an answer they
|
|
18
|
+
* cannot trust with a contract. A model that cites a document id it was never
|
|
19
|
+
* given has invented a source, and the only safe rendering of that is to say so.
|
|
20
|
+
*/
|
|
21
|
+
import { moneyEventAsDocument } from './entities';
|
|
22
|
+
import { retrieveForQuestion } from './retrieval';
|
|
23
|
+
import type {
|
|
24
|
+
AnswerRequest,
|
|
25
|
+
AskRequest,
|
|
26
|
+
AskResult,
|
|
27
|
+
Citation,
|
|
28
|
+
CorpusApi,
|
|
29
|
+
CorpusLlm,
|
|
30
|
+
PromptDocument,
|
|
31
|
+
RetrievedDocument,
|
|
32
|
+
SourceDocument,
|
|
33
|
+
SourceFreshness,
|
|
34
|
+
} from './types';
|
|
35
|
+
|
|
36
|
+
export interface AskCorpusOptions {
|
|
37
|
+
/** How many candidate records to pull from the API before ranking. */
|
|
38
|
+
candidateLimit?: number;
|
|
39
|
+
/** How many ranked records the model is allowed to see. */
|
|
40
|
+
contextLimit?: number;
|
|
41
|
+
/** Characters of each document's text included in the prompt. */
|
|
42
|
+
maxCharsPerDocument?: number;
|
|
43
|
+
/** Injectable clock for the recency prior. */
|
|
44
|
+
now?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULTS: Required<Omit<AskCorpusOptions, 'now'>> = {
|
|
48
|
+
candidateLimit: 200,
|
|
49
|
+
contextLimit: 8,
|
|
50
|
+
maxCharsPerDocument: 6000,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The system prompt. It is short on purpose: every rule in it is enforced in
|
|
55
|
+
* code below as well, because a prompt is a request and a check is a guarantee.
|
|
56
|
+
*/
|
|
57
|
+
function systemPrompt(mode: AskRequest['mode']): string {
|
|
58
|
+
const shared = [
|
|
59
|
+
'You answer only from the DOCUMENTS supplied in this message.',
|
|
60
|
+
'Every claim you make must be supported by at least one document, and you must cite it by its exact id.',
|
|
61
|
+
'Never cite an id that is not in the list. If the documents do not support an answer, set "unanswerable": true and say what is missing.',
|
|
62
|
+
'Respond with JSON only: {"answer": string, "citations": [{"documentId": string, "quote": string}], "unanswerable": boolean}.',
|
|
63
|
+
'Each "quote" must be a short verbatim span copied from the cited document.',
|
|
64
|
+
];
|
|
65
|
+
if (mode === 'generate') {
|
|
66
|
+
return [
|
|
67
|
+
'You draft business documents for a company using only that company\'s own records.',
|
|
68
|
+
...shared,
|
|
69
|
+
'Put the finished draft in "answer", ready to send or sign. Do not add commentary around it.',
|
|
70
|
+
'Do not invent names, dates, amounts or terms that are not in the documents. Leave an explicit [TO CONFIRM: ...] placeholder instead.',
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
'You answer questions about a company using only that company\'s own records.',
|
|
75
|
+
...shared,
|
|
76
|
+
'Be specific and short. Prefer naming the record and the date over hedging.',
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ONE payload carrying every retrieved document, each tagged with the id to echo. */
|
|
81
|
+
export function buildAnswerRequest(
|
|
82
|
+
request: AskRequest,
|
|
83
|
+
retrieved: RetrievedDocument[],
|
|
84
|
+
maxChars: number,
|
|
85
|
+
): AnswerRequest {
|
|
86
|
+
const documents: PromptDocument[] = retrieved.map(({ document }) => ({
|
|
87
|
+
id: document.id,
|
|
88
|
+
title: document.title,
|
|
89
|
+
// The source travels INTO the prompt because "we learned this from a
|
|
90
|
+
// meeting" and "we learned this from a contract in Drive" are different
|
|
91
|
+
// claims, and the model should be able to say which it used.
|
|
92
|
+
source: document.sourceLabel || document.source,
|
|
93
|
+
occurredAt: document.occurredAt,
|
|
94
|
+
participants: document.participants.map((party) => party.name),
|
|
95
|
+
text: documentText(document, maxChars),
|
|
96
|
+
}));
|
|
97
|
+
return {
|
|
98
|
+
question: request.question,
|
|
99
|
+
mode: request.mode,
|
|
100
|
+
instructions: request.instructions,
|
|
101
|
+
documents,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A document's text for the prompt: body first, then each named section, cut at
|
|
107
|
+
* a budget. Sections are labelled with the source's own words, so the model sees
|
|
108
|
+
* "Transcript" or "Extracted text" without this package knowing what either is.
|
|
109
|
+
*/
|
|
110
|
+
export function documentText(document: SourceDocument, maxChars: number): string {
|
|
111
|
+
const parts = [document.body, ...document.sections.map((s) => `## ${s.label}\n${s.text}`)];
|
|
112
|
+
const joined = parts.filter(Boolean).join('\n\n');
|
|
113
|
+
return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render the batched request as the single user message. */
|
|
117
|
+
export function renderAnswerPrompt(input: AnswerRequest): string {
|
|
118
|
+
const blocks = input.documents.map((document) =>
|
|
119
|
+
[
|
|
120
|
+
`<document id="${document.id}">`,
|
|
121
|
+
`title: ${document.title}`,
|
|
122
|
+
`source: ${document.source}`,
|
|
123
|
+
`occurred_at: ${document.occurredAt ?? 'unknown'}`,
|
|
124
|
+
document.participants.length ? `participants: ${document.participants.join(', ')}` : '',
|
|
125
|
+
'',
|
|
126
|
+
document.text,
|
|
127
|
+
'</document>',
|
|
128
|
+
]
|
|
129
|
+
.filter((line) => line !== '')
|
|
130
|
+
.join('\n'),
|
|
131
|
+
);
|
|
132
|
+
const task =
|
|
133
|
+
input.mode === 'generate'
|
|
134
|
+
? `DRAFT REQUEST:\n${input.question}`
|
|
135
|
+
: `QUESTION:\n${input.question}`;
|
|
136
|
+
return [
|
|
137
|
+
`DOCUMENTS (${input.documents.length}):`,
|
|
138
|
+
blocks.join('\n\n'),
|
|
139
|
+
'',
|
|
140
|
+
task,
|
|
141
|
+
input.instructions ? `\nADDITIONAL INSTRUCTIONS:\n${input.instructions}` : '',
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { systemPrompt as answerSystemPrompt };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the model's citations against what retrieval actually supplied.
|
|
151
|
+
*
|
|
152
|
+
* An id the model invented resolves to nothing and lands in `unresolved`. It is
|
|
153
|
+
* NOT quietly discarded: `AskResult.unresolvedCitations` carries it to the
|
|
154
|
+
* screen, because a model inventing sources is a fact about the answer.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveCitations(
|
|
157
|
+
draft: { citations: Array<{ documentId: string; quote?: string }> },
|
|
158
|
+
retrieved: RetrievedDocument[],
|
|
159
|
+
): { citations: Citation[]; unresolved: string[] } {
|
|
160
|
+
const byId = new Map(retrieved.map((item) => [item.document.id, item.document]));
|
|
161
|
+
const citations: Citation[] = [];
|
|
162
|
+
const unresolved: string[] = [];
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
|
|
165
|
+
for (const raw of draft.citations ?? []) {
|
|
166
|
+
// Both spellings. A model asked for `documentId` returns `document_id` often
|
|
167
|
+
// enough, and the cost of being strict here is EVERY citation silently
|
|
168
|
+
// dropped — which the surface would then correctly, uselessly, report as an
|
|
169
|
+
// uncited answer. The tolerance belongs at the seam, not only in the one
|
|
170
|
+
// adapter that happens to parse the JSON.
|
|
171
|
+
const record = (raw ?? {}) as { documentId?: unknown; document_id?: unknown; quote?: unknown };
|
|
172
|
+
const id = String(record.documentId ?? record.document_id ?? '').trim();
|
|
173
|
+
if (!id) continue;
|
|
174
|
+
const document = byId.get(id);
|
|
175
|
+
if (!document) {
|
|
176
|
+
if (!unresolved.includes(id)) unresolved.push(id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (seen.has(id)) continue;
|
|
180
|
+
seen.add(id);
|
|
181
|
+
citations.push({
|
|
182
|
+
documentId: id,
|
|
183
|
+
document,
|
|
184
|
+
quote: String(record.quote ?? '').trim() || snippetOf(document),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { citations, unresolved };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function snippetOf(document: SourceDocument): string {
|
|
191
|
+
const text = document.body || document.sections[0]?.text || '';
|
|
192
|
+
return text.length > 200 ? `${text.slice(0, 200).trim()}…` : text;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Pull the candidate set. Two reads, in PARALLEL, not one read per document —
|
|
197
|
+
* the server pages and filters, this only decides how wide a net to cast.
|
|
198
|
+
*
|
|
199
|
+
* Money events are folded in as documents so one ranking pass spans meetings and
|
|
200
|
+
* revenue; "what did we agree with this company" is a question about both.
|
|
201
|
+
*/
|
|
202
|
+
async function loadCandidates(
|
|
203
|
+
corpus: CorpusApi,
|
|
204
|
+
request: AskRequest,
|
|
205
|
+
candidateLimit: number,
|
|
206
|
+
): Promise<SourceDocument[]> {
|
|
207
|
+
const documentQuery = {
|
|
208
|
+
search: request.question,
|
|
209
|
+
...request.filters,
|
|
210
|
+
page: 1,
|
|
211
|
+
pageSize: candidateLimit,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const [documents, money] = await Promise.all([
|
|
215
|
+
corpus.listDocuments(documentQuery),
|
|
216
|
+
corpus.listMoneyEvents
|
|
217
|
+
? corpus
|
|
218
|
+
.listMoneyEvents({
|
|
219
|
+
search: request.question,
|
|
220
|
+
partyId: request.filters?.partyId,
|
|
221
|
+
page: 1,
|
|
222
|
+
pageSize: Math.ceil(candidateLimit / 4),
|
|
223
|
+
})
|
|
224
|
+
// A tenant with no payment connector, or a money endpoint that is down,
|
|
225
|
+
// must not take the whole answer with it — the meetings still answer.
|
|
226
|
+
.catch(() => ({ count: 0, next: null, previous: null, results: [] }))
|
|
227
|
+
: Promise.resolve({ count: 0, next: null, previous: null, results: [] }),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
return [...documents.results, ...money.results.map(moneyEventAsDocument)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Ask (or generate) over the corpus.
|
|
235
|
+
*
|
|
236
|
+
* Runs SERVER-SIDE — it holds a `CorpusLlm`, which holds a provider key. The
|
|
237
|
+
* browser composer talks to a route that calls this, never to a provider.
|
|
238
|
+
*/
|
|
239
|
+
export async function askCorpus(
|
|
240
|
+
request: AskRequest,
|
|
241
|
+
deps: { corpus: CorpusApi; llm: CorpusLlm },
|
|
242
|
+
options: AskCorpusOptions = {},
|
|
243
|
+
): Promise<AskResult> {
|
|
244
|
+
const { candidateLimit, contextLimit, maxCharsPerDocument } = { ...DEFAULTS, ...options };
|
|
245
|
+
const { corpus, llm } = deps;
|
|
246
|
+
|
|
247
|
+
// An empty scope grants nothing (see `isInScope`). Refusing here rather than
|
|
248
|
+
// relying on the guard downstream keeps a missing claim from ever reaching a
|
|
249
|
+
// prompt builder in the first place.
|
|
250
|
+
if (!request.scope?.orgId) {
|
|
251
|
+
return emptyResult(request, llm, 'This session has no tenant scope, so there is nothing to search.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const candidates = await loadCandidates(corpus, request, candidateLimit);
|
|
255
|
+
const { retrieved, droppedOutOfScope } = retrieveForQuestion(
|
|
256
|
+
candidates,
|
|
257
|
+
request.question,
|
|
258
|
+
request.scope,
|
|
259
|
+
{ limit: contextLimit, now: options.now },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const freshness = await corpus.listSources().catch(() => [] as SourceFreshness[]);
|
|
263
|
+
|
|
264
|
+
if (retrieved.length === 0) {
|
|
265
|
+
return {
|
|
266
|
+
status: 'no_evidence',
|
|
267
|
+
answer:
|
|
268
|
+
'Nothing in your corpus matches that. Either it has not been pulled in yet, or it belongs to a different workspace.',
|
|
269
|
+
citations: [],
|
|
270
|
+
considered: [],
|
|
271
|
+
unresolvedCitations: [],
|
|
272
|
+
droppedOutOfScope,
|
|
273
|
+
provider: llm.describe(),
|
|
274
|
+
freshness,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const draft = await llm.answer(
|
|
279
|
+
buildAnswerRequest(request, retrieved, maxCharsPerDocument),
|
|
280
|
+
);
|
|
281
|
+
const { citations, unresolved } = resolveCitations(draft, retrieved);
|
|
282
|
+
|
|
283
|
+
const answer = String(draft.answer ?? '').trim();
|
|
284
|
+
const status = deriveStatus(draft.unanswerable === true, answer, citations.length);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
status,
|
|
288
|
+
answer,
|
|
289
|
+
citations,
|
|
290
|
+
considered: retrieved,
|
|
291
|
+
unresolvedCitations: unresolved,
|
|
292
|
+
droppedOutOfScope,
|
|
293
|
+
provider: llm.describe(),
|
|
294
|
+
freshness,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `uncited` is a real outcome, not a failure to report. The model produced text
|
|
300
|
+
* and nothing traceable; the surface renders that as a warning over the text
|
|
301
|
+
* rather than as an answer, which is the difference between a demo that impresses
|
|
302
|
+
* on Monday and one that survives Tuesday.
|
|
303
|
+
*/
|
|
304
|
+
function deriveStatus(unanswerable: boolean, answer: string, citationCount: number): AskResult['status'] {
|
|
305
|
+
if (unanswerable && citationCount === 0) return 'no_evidence';
|
|
306
|
+
if (!answer) return 'no_evidence';
|
|
307
|
+
return citationCount > 0 ? 'answered' : 'uncited';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function emptyResult(request: AskRequest, llm: CorpusLlm, answer: string): AskResult {
|
|
311
|
+
void request;
|
|
312
|
+
return {
|
|
313
|
+
status: 'no_evidence',
|
|
314
|
+
answer,
|
|
315
|
+
citations: [],
|
|
316
|
+
considered: [],
|
|
317
|
+
unresolvedCitations: [],
|
|
318
|
+
droppedOutOfScope: 0,
|
|
319
|
+
provider: llm.describe(),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `CorpusApi` over the tenant data plane's generic `/api/v1/*` endpoints.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY place that knows the corpus is stored as generic `Entity`
|
|
5
|
+
* rows behind `?type=`. Swap this adapter and every other file in the package —
|
|
6
|
+
* both composers, retrieval, the ask pipeline, all the tests — is unaffected.
|
|
7
|
+
*
|
|
8
|
+
* SERVER-SIDE FILTERING AND PAGING, ALWAYS (CLAUDE.md rule 8). Every filter goes
|
|
9
|
+
* out as a query parameter and the DRF page envelope comes back untouched. There
|
|
10
|
+
* is no client-side `.filter()` on a page of results anywhere in this file, and
|
|
11
|
+
* there must not be: it produces a list whose count disagrees with its rows and
|
|
12
|
+
* that stops working at exactly the corpus size where it starts to matter.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THE BACKEND HONOURS TODAY: `tenant-starter`'s `EntityViewSet` filters on
|
|
15
|
+
* `?type=` and nothing else — no `search`, no `source`, no date window (see
|
|
16
|
+
* `apps/api/views.py`). So `supportedFilters` defaults to `[]` and the composer
|
|
17
|
+
* says so on screen rather than pretending. Pass the real list once the tenant
|
|
18
|
+
* API gains them; the request already sends every parameter.
|
|
19
|
+
*
|
|
20
|
+
* PEOPLE ARE A SECOND READ, NOT A SECOND COPY (bd startsim-768w.38.22). The
|
|
21
|
+
* corpus delivery writes participants as typed `authored_by` / `involves` edges
|
|
22
|
+
* and leaves only a `participant_count` on the document, so this adapter reads
|
|
23
|
+
* `/api/v1/relationships/` and joins. The alternative — denormalizing names back
|
|
24
|
+
* onto `data.participants` at delivery time — is one fewer round trip and a
|
|
25
|
+
* second copy of the graph that will drift; the edge is the declared contract,
|
|
26
|
+
* so the edge is what is read. The join is cached, bounded and fails soft.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_PARTICIPANT_EDGES,
|
|
30
|
+
DEFAULT_TYPE_KEYS,
|
|
31
|
+
indexParticipants,
|
|
32
|
+
toMoneyEvent,
|
|
33
|
+
toParty,
|
|
34
|
+
toSourceDocument,
|
|
35
|
+
} from './entities';
|
|
36
|
+
import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
37
|
+
import type {
|
|
38
|
+
CorpusApi,
|
|
39
|
+
CorpusPage,
|
|
40
|
+
CorpusScope,
|
|
41
|
+
DocumentQuery,
|
|
42
|
+
MoneyEvent,
|
|
43
|
+
Party,
|
|
44
|
+
PartyRef,
|
|
45
|
+
SourceFreshness,
|
|
46
|
+
} from './types';
|
|
47
|
+
|
|
48
|
+
/** The `@startsimpli/api` client surface this adapter needs, and no more. */
|
|
49
|
+
export interface CorpusHttp {
|
|
50
|
+
get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateCorpusApiOptions {
|
|
54
|
+
http: CorpusHttp;
|
|
55
|
+
/** The asking principal's scope, used as the fallback when a row omits its own. */
|
|
56
|
+
scope: CorpusScope;
|
|
57
|
+
/** Override when a tenant modelled its types under different keys. */
|
|
58
|
+
typeKeys?: Partial<CorpusTypeKeys>;
|
|
59
|
+
/** Human names per source key, from the connector catalog. Data, not a mapping table. */
|
|
60
|
+
sourceLabels?: Record<string, string>;
|
|
61
|
+
/**
|
|
62
|
+
* Which `DocumentQuery` keys this deployment's API really applies. Anything
|
|
63
|
+
* not listed is reported to the user as unfiltered rather than assumed.
|
|
64
|
+
*/
|
|
65
|
+
supportedFilters?: Array<keyof DocumentQuery>;
|
|
66
|
+
/** Endpoint bases, without leading/trailing slashes (the client convention). */
|
|
67
|
+
paths?: { entities?: string; connections?: string; relationships?: string };
|
|
68
|
+
/** How old a source's last sync may be before the Library calls it stale. */
|
|
69
|
+
staleAfterHours?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Resolve people from the tenant's typed EDGES into `participants`.
|
|
72
|
+
*
|
|
73
|
+
* On by default because it is what the shipped pipeline writes: the corpus
|
|
74
|
+
* delivery lands `source_document --involves--> party` edges and a bare
|
|
75
|
+
* `participant_count` on the document (bd startsim-768w.38.18). A deployment
|
|
76
|
+
* whose connectors denormalize participants inline can turn this off and save
|
|
77
|
+
* the round trip; the inline array is read either way.
|
|
78
|
+
*/
|
|
79
|
+
resolveParticipants?: boolean;
|
|
80
|
+
/** Which edge types mean "this party is on this record". Data, not an enum. */
|
|
81
|
+
participantEdges?: readonly string[];
|
|
82
|
+
/**
|
|
83
|
+
* Bound on the graph read, in PAGES.
|
|
84
|
+
*
|
|
85
|
+
* The tenant relationships endpoint has no `?source=` filter today
|
|
86
|
+
* (`tenant-starter/apps/api/views.py` — see also bd startsim-768w.38.12 for
|
|
87
|
+
* the same gap on entities), so the edges for a page of documents cannot be
|
|
88
|
+
* requested directly and the graph is read in bulk and cached. This cap is
|
|
89
|
+
* what keeps that bounded rather than unbounded: past it, participants simply
|
|
90
|
+
* stop resolving for the older tail instead of the page hanging.
|
|
91
|
+
*/
|
|
92
|
+
maxGraphPages?: number;
|
|
93
|
+
/** How long a loaded graph is reused before it is read again. */
|
|
94
|
+
graphTtlMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface DrfPage<T> {
|
|
98
|
+
count: number;
|
|
99
|
+
next: string | null;
|
|
100
|
+
previous: string | null;
|
|
101
|
+
results: T[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
105
|
+
|
|
106
|
+
function params(query: DocumentQuery, type: string) {
|
|
107
|
+
const out: Record<string, unknown> = { type, page: query.page ?? 1 };
|
|
108
|
+
if (query.pageSize) out.page_size = query.pageSize;
|
|
109
|
+
if (query.search) out.search = query.search;
|
|
110
|
+
if (query.source) out.source = query.source;
|
|
111
|
+
if (query.partyId) out.party = query.partyId;
|
|
112
|
+
if (query.occurredAfter) out.occurred_after = query.occurredAfter;
|
|
113
|
+
if (query.occurredBefore) out.occurred_before = query.occurredBefore;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Which of the filters the caller asked for are actually honoured server-side. */
|
|
118
|
+
function appliedFilters(
|
|
119
|
+
query: DocumentQuery,
|
|
120
|
+
supported: Array<keyof DocumentQuery>,
|
|
121
|
+
): string[] {
|
|
122
|
+
const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
|
|
123
|
+
(key) => Boolean(query[key]),
|
|
124
|
+
);
|
|
125
|
+
return requested.filter((key) => supported.includes(key));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
129
|
+
const {
|
|
130
|
+
http,
|
|
131
|
+
scope,
|
|
132
|
+
sourceLabels = {},
|
|
133
|
+
supportedFilters = [],
|
|
134
|
+
staleAfterHours = 26,
|
|
135
|
+
resolveParticipants = true,
|
|
136
|
+
participantEdges = DEFAULT_PARTICIPANT_EDGES,
|
|
137
|
+
maxGraphPages = 20,
|
|
138
|
+
graphTtlMs = 30_000,
|
|
139
|
+
} = options;
|
|
140
|
+
const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
|
|
141
|
+
const entities = options.paths?.entities ?? 'api/v1/entities';
|
|
142
|
+
const connections = options.paths?.connections ?? 'api/v1/connections';
|
|
143
|
+
const relationships = options.paths?.relationships ?? 'api/v1/relationships';
|
|
144
|
+
|
|
145
|
+
async function page<T>(
|
|
146
|
+
type: string,
|
|
147
|
+
query: DocumentQuery,
|
|
148
|
+
map: (entity: TenantEntity) => T,
|
|
149
|
+
): Promise<CorpusPage<T>> {
|
|
150
|
+
const response = await http.get<DrfPage<TenantEntity>>(entities, {
|
|
151
|
+
params: params(query, type),
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
count: response.count ?? response.results?.length ?? 0,
|
|
155
|
+
next: response.next ?? null,
|
|
156
|
+
previous: response.previous ?? null,
|
|
157
|
+
results: (response.results ?? []).map(map),
|
|
158
|
+
appliedFilters: appliedFilters(query, supportedFilters),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every page of a DRF list, up to the cap. Stops on the first failure. */
|
|
163
|
+
async function readAll<T>(path: string, extra: Record<string, unknown>): Promise<T[]> {
|
|
164
|
+
const rows: T[] = [];
|
|
165
|
+
for (let index = 1; index <= maxGraphPages; index += 1) {
|
|
166
|
+
const response = await http.get<DrfPage<T>>(path, { params: { ...extra, page: index } });
|
|
167
|
+
const results = response?.results ?? [];
|
|
168
|
+
rows.push(...results);
|
|
169
|
+
if (!response?.next || results.length === 0) break;
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------
|
|
175
|
+
// the participant graph
|
|
176
|
+
// ---------------------------------------------------------------------
|
|
177
|
+
//
|
|
178
|
+
// ONE read for a page of documents, not one per document — and cached, so a
|
|
179
|
+
// screen that lists, opens a record and then asks a question spends it once.
|
|
180
|
+
// It FAILS SOFT: a tenant whose relationships endpoint is missing or refused
|
|
181
|
+
// renders exactly what it rendered before this existed, rather than an error
|
|
182
|
+
// where a Library used to be.
|
|
183
|
+
let graph: { at: number; value: Promise<Map<string, PartyRef[]>> } | null = null;
|
|
184
|
+
|
|
185
|
+
async function loadGraph(): Promise<Map<string, PartyRef[]>> {
|
|
186
|
+
const empty = new Map<string, PartyRef[]>();
|
|
187
|
+
if (!resolveParticipants) return empty;
|
|
188
|
+
try {
|
|
189
|
+
const partyRows = await readAll<TenantEntity>(entities, { type: types.party });
|
|
190
|
+
// No parties means no edge can resolve to a name, so the second read is
|
|
191
|
+
// pure cost — which is the common case on a freshly connected workspace.
|
|
192
|
+
if (partyRows.length === 0) return empty;
|
|
193
|
+
const parties = partyRows.map((entity) => toParty(entity, scope));
|
|
194
|
+
const edges = await readAll<TenantRelationship>(relationships, {});
|
|
195
|
+
return indexParticipants(edges, parties, participantEdges);
|
|
196
|
+
} catch {
|
|
197
|
+
return empty;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function participantGraph(): Promise<Map<string, PartyRef[]>> {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
if (!graph || now - graph.at > graphTtlMs) {
|
|
204
|
+
graph = { at: now, value: loadGraph() };
|
|
205
|
+
}
|
|
206
|
+
return graph.value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async listDocuments(query) {
|
|
211
|
+
const documents = await page<TenantEntity>(types.document, query, (entity) => entity);
|
|
212
|
+
// Skip the graph entirely for an empty page: an empty corpus should cost
|
|
213
|
+
// exactly the one request it costs today.
|
|
214
|
+
const byRecord = documents.results.length ? await participantGraph() : new Map();
|
|
215
|
+
return {
|
|
216
|
+
...documents,
|
|
217
|
+
results: documents.results.map((entity) =>
|
|
218
|
+
toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
|
|
219
|
+
),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async getDocument(id) {
|
|
224
|
+
const [entity, byRecord] = await Promise.all([
|
|
225
|
+
http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
|
|
226
|
+
participantGraph(),
|
|
227
|
+
]);
|
|
228
|
+
return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
listParties(query = {}) {
|
|
232
|
+
return page<Party>(types.party, query, (entity) => toParty(entity, scope));
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async listMoneyEvents(query = {}) {
|
|
236
|
+
const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
|
|
237
|
+
const byRecord = events.results.length ? await participantGraph() : new Map();
|
|
238
|
+
return {
|
|
239
|
+
...events,
|
|
240
|
+
results: events.results.map((entity) =>
|
|
241
|
+
toMoneyEvent(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
|
|
242
|
+
),
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Freshness per SOURCE, assembled from the connections the tenant has.
|
|
248
|
+
*
|
|
249
|
+
* It reads the connections API rather than the documents, because "Dropbox
|
|
250
|
+
* has not synced since Tuesday" is a true and useful statement even when
|
|
251
|
+
* Dropbox has contributed zero documents — and a count derived from the
|
|
252
|
+
* corpus alone can never say it.
|
|
253
|
+
*/
|
|
254
|
+
async listSources(): Promise<SourceFreshness[]> {
|
|
255
|
+
const [connectionPage, documentPage] = await Promise.all([
|
|
256
|
+
http
|
|
257
|
+
.get<DrfPage<Record<string, unknown>>>(connections)
|
|
258
|
+
.catch(() => EMPTY_PAGE as DrfPage<Record<string, unknown>>),
|
|
259
|
+
http
|
|
260
|
+
.get<DrfPage<TenantEntity>>(entities, { params: { type: types.document, page_size: 1 } })
|
|
261
|
+
.catch(() => EMPTY_PAGE as DrfPage<TenantEntity>),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
void documentPage; // total count is per-source below; kept for the empty-corpus case
|
|
265
|
+
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
return (connectionPage.results ?? []).map((connection) => {
|
|
268
|
+
const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
|
|
269
|
+
const lastSyncedAt =
|
|
270
|
+
(connection.last_synced_at as string) ??
|
|
271
|
+
(connection.lastSyncedAt as string) ??
|
|
272
|
+
(connection.last_checked_at as string) ??
|
|
273
|
+
(connection.lastCheckedAt as string) ??
|
|
274
|
+
null;
|
|
275
|
+
const documentCount = Number(connection.document_count ?? connection.documentCount ?? 0);
|
|
276
|
+
const error = String(connection.check_error ?? connection.checkError ?? '');
|
|
277
|
+
const status = connection.status ?? '';
|
|
278
|
+
|
|
279
|
+
let health: SourceFreshness['status'] = 'ok';
|
|
280
|
+
let detail = '';
|
|
281
|
+
if (error || status === 'error' || status === 'needs_reauth') {
|
|
282
|
+
health = 'error';
|
|
283
|
+
detail = error || `Connection status is ${String(status)}.`;
|
|
284
|
+
} else if (!lastSyncedAt) {
|
|
285
|
+
health = 'never';
|
|
286
|
+
detail = 'Connected, but nothing has arrived yet.';
|
|
287
|
+
} else if (now - Date.parse(lastSyncedAt) > staleAfterHours * 3_600_000) {
|
|
288
|
+
health = 'stale';
|
|
289
|
+
detail = `Last sync was more than ${staleAfterHours} hours ago.`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
source: key,
|
|
294
|
+
label: String(connection.connector_name ?? connection.connectorName ?? sourceLabels[key] ?? key),
|
|
295
|
+
documentCount,
|
|
296
|
+
lastSyncedAt,
|
|
297
|
+
status: health,
|
|
298
|
+
...(detail ? { detail } : {}),
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|