@startsimpli/integrations 0.1.5 → 0.2.1
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 +2 -2
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +206 -18
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +288 -5
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +602 -186
- package/src/corpus/index.ts +57 -1
- package/src/corpus/llm.ts +134 -2
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
package/src/corpus/client.ts
CHANGED
|
@@ -16,23 +16,51 @@
|
|
|
16
16
|
* `apps/api/views.py`). So `supportedFilters` defaults to `[]` and the composer
|
|
17
17
|
* says so on screen rather than pretending. Pass the real list once the tenant
|
|
18
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.
|
|
19
27
|
*/
|
|
20
|
-
import {
|
|
21
|
-
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_PARTICIPANT_EDGES,
|
|
30
|
+
DEFAULT_TYPE_KEYS,
|
|
31
|
+
commitmentToEntity,
|
|
32
|
+
indexParticipants,
|
|
33
|
+
toCommitment,
|
|
34
|
+
toMoneyEvent,
|
|
35
|
+
toParty,
|
|
36
|
+
toSourceDocument,
|
|
37
|
+
} from './entities';
|
|
38
|
+
import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
22
39
|
import type {
|
|
40
|
+
Commitment,
|
|
41
|
+
CommitmentApi,
|
|
42
|
+
CommitmentQuery,
|
|
23
43
|
CorpusApi,
|
|
24
44
|
CorpusPage,
|
|
25
45
|
CorpusScope,
|
|
26
46
|
DocumentQuery,
|
|
27
47
|
MoneyEvent,
|
|
28
48
|
Party,
|
|
29
|
-
|
|
49
|
+
PartyRef,
|
|
30
50
|
SourceFreshness,
|
|
31
51
|
} from './types';
|
|
32
52
|
|
|
33
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The `@startsimpli/api` client surface this adapter needs, and no more.
|
|
55
|
+
*
|
|
56
|
+
* `post` is OPTIONAL: reading the corpus is the common case and a deployment
|
|
57
|
+
* that only reads should not have to supply a write method it never calls. The
|
|
58
|
+
* commitment write path checks for it and reports honestly when it is absent,
|
|
59
|
+
* rather than failing at the moment somebody presses a button.
|
|
60
|
+
*/
|
|
34
61
|
export interface CorpusHttp {
|
|
35
62
|
get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
63
|
+
post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
export interface CreateCorpusApiOptions {
|
|
@@ -47,11 +75,36 @@ export interface CreateCorpusApiOptions {
|
|
|
47
75
|
* Which `DocumentQuery` keys this deployment's API really applies. Anything
|
|
48
76
|
* not listed is reported to the user as unfiltered rather than assumed.
|
|
49
77
|
*/
|
|
50
|
-
supportedFilters?: Array<keyof DocumentQuery>;
|
|
78
|
+
supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
|
|
51
79
|
/** Endpoint bases, without leading/trailing slashes (the client convention). */
|
|
52
|
-
paths?: { entities?: string; connections?: string };
|
|
80
|
+
paths?: { entities?: string; connections?: string; relationships?: string };
|
|
53
81
|
/** How old a source's last sync may be before the Library calls it stale. */
|
|
54
82
|
staleAfterHours?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Resolve people from the tenant's typed EDGES into `participants`.
|
|
85
|
+
*
|
|
86
|
+
* On by default because it is what the shipped pipeline writes: the corpus
|
|
87
|
+
* delivery lands `source_document --involves--> party` edges and a bare
|
|
88
|
+
* `participant_count` on the document (bd startsim-768w.38.18). A deployment
|
|
89
|
+
* whose connectors denormalize participants inline can turn this off and save
|
|
90
|
+
* the round trip; the inline array is read either way.
|
|
91
|
+
*/
|
|
92
|
+
resolveParticipants?: boolean;
|
|
93
|
+
/** Which edge types mean "this party is on this record". Data, not an enum. */
|
|
94
|
+
participantEdges?: readonly string[];
|
|
95
|
+
/**
|
|
96
|
+
* Bound on the graph read, in PAGES.
|
|
97
|
+
*
|
|
98
|
+
* The tenant relationships endpoint has no `?source=` filter today
|
|
99
|
+
* (`tenant-starter/apps/api/views.py` — see also bd startsim-768w.38.12 for
|
|
100
|
+
* the same gap on entities), so the edges for a page of documents cannot be
|
|
101
|
+
* requested directly and the graph is read in bulk and cached. This cap is
|
|
102
|
+
* what keeps that bounded rather than unbounded: past it, participants simply
|
|
103
|
+
* stop resolving for the older tail instead of the page hanging.
|
|
104
|
+
*/
|
|
105
|
+
maxGraphPages?: number;
|
|
106
|
+
/** How long a loaded graph is reused before it is read again. */
|
|
107
|
+
graphTtlMs?: number;
|
|
55
108
|
}
|
|
56
109
|
|
|
57
110
|
interface DrfPage<T> {
|
|
@@ -63,6 +116,9 @@ interface DrfPage<T> {
|
|
|
63
116
|
|
|
64
117
|
const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
|
|
65
118
|
|
|
119
|
+
/** Rows written in parallel. Small enough not to look like a burst to the tenant. */
|
|
120
|
+
const WRITE_CONCURRENCY = 6;
|
|
121
|
+
|
|
66
122
|
function params(query: DocumentQuery, type: string) {
|
|
67
123
|
const out: Record<string, unknown> = { type, page: query.page ?? 1 };
|
|
68
124
|
if (query.pageSize) out.page_size = query.pageSize;
|
|
@@ -77,7 +133,7 @@ function params(query: DocumentQuery, type: string) {
|
|
|
77
133
|
/** Which of the filters the caller asked for are actually honoured server-side. */
|
|
78
134
|
function appliedFilters(
|
|
79
135
|
query: DocumentQuery,
|
|
80
|
-
supported: Array<
|
|
136
|
+
supported: Array<string>,
|
|
81
137
|
): string[] {
|
|
82
138
|
const requested = (['search', 'source', 'partyId', 'occurredAfter', 'occurredBefore'] as const).filter(
|
|
83
139
|
(key) => Boolean(query[key]),
|
|
@@ -85,17 +141,22 @@ function appliedFilters(
|
|
|
85
141
|
return requested.filter((key) => supported.includes(key));
|
|
86
142
|
}
|
|
87
143
|
|
|
88
|
-
export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
144
|
+
export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & CommitmentApi {
|
|
89
145
|
const {
|
|
90
146
|
http,
|
|
91
147
|
scope,
|
|
92
148
|
sourceLabels = {},
|
|
93
149
|
supportedFilters = [],
|
|
94
150
|
staleAfterHours = 26,
|
|
151
|
+
resolveParticipants = true,
|
|
152
|
+
participantEdges = DEFAULT_PARTICIPANT_EDGES,
|
|
153
|
+
maxGraphPages = 20,
|
|
154
|
+
graphTtlMs = 30_000,
|
|
95
155
|
} = options;
|
|
96
156
|
const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
|
|
97
157
|
const entities = options.paths?.entities ?? 'api/v1/entities';
|
|
98
158
|
const connections = options.paths?.connections ?? 'api/v1/connections';
|
|
159
|
+
const relationships = options.paths?.relationships ?? 'api/v1/relationships';
|
|
99
160
|
|
|
100
161
|
async function page<T>(
|
|
101
162
|
type: string,
|
|
@@ -114,26 +175,88 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
|
114
175
|
};
|
|
115
176
|
}
|
|
116
177
|
|
|
178
|
+
/** Every page of a DRF list, up to the cap. Stops on the first failure. */
|
|
179
|
+
async function readAll<T>(path: string, extra: Record<string, unknown>): Promise<T[]> {
|
|
180
|
+
const rows: T[] = [];
|
|
181
|
+
for (let index = 1; index <= maxGraphPages; index += 1) {
|
|
182
|
+
const response = await http.get<DrfPage<T>>(path, { params: { ...extra, page: index } });
|
|
183
|
+
const results = response?.results ?? [];
|
|
184
|
+
rows.push(...results);
|
|
185
|
+
if (!response?.next || results.length === 0) break;
|
|
186
|
+
}
|
|
187
|
+
return rows;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------
|
|
191
|
+
// the participant graph
|
|
192
|
+
// ---------------------------------------------------------------------
|
|
193
|
+
//
|
|
194
|
+
// ONE read for a page of documents, not one per document — and cached, so a
|
|
195
|
+
// screen that lists, opens a record and then asks a question spends it once.
|
|
196
|
+
// It FAILS SOFT: a tenant whose relationships endpoint is missing or refused
|
|
197
|
+
// renders exactly what it rendered before this existed, rather than an error
|
|
198
|
+
// where a Library used to be.
|
|
199
|
+
let graph: { at: number; value: Promise<Map<string, PartyRef[]>> } | null = null;
|
|
200
|
+
|
|
201
|
+
async function loadGraph(): Promise<Map<string, PartyRef[]>> {
|
|
202
|
+
const empty = new Map<string, PartyRef[]>();
|
|
203
|
+
if (!resolveParticipants) return empty;
|
|
204
|
+
try {
|
|
205
|
+
const partyRows = await readAll<TenantEntity>(entities, { type: types.party });
|
|
206
|
+
// No parties means no edge can resolve to a name, so the second read is
|
|
207
|
+
// pure cost — which is the common case on a freshly connected workspace.
|
|
208
|
+
if (partyRows.length === 0) return empty;
|
|
209
|
+
const parties = partyRows.map((entity) => toParty(entity, scope));
|
|
210
|
+
const edges = await readAll<TenantRelationship>(relationships, {});
|
|
211
|
+
return indexParticipants(edges, parties, participantEdges);
|
|
212
|
+
} catch {
|
|
213
|
+
return empty;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function participantGraph(): Promise<Map<string, PartyRef[]>> {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (!graph || now - graph.at > graphTtlMs) {
|
|
220
|
+
graph = { at: now, value: loadGraph() };
|
|
221
|
+
}
|
|
222
|
+
return graph.value;
|
|
223
|
+
}
|
|
224
|
+
|
|
117
225
|
return {
|
|
118
|
-
listDocuments(query) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
226
|
+
async listDocuments(query) {
|
|
227
|
+
const documents = await page<TenantEntity>(types.document, query, (entity) => entity);
|
|
228
|
+
// Skip the graph entirely for an empty page: an empty corpus should cost
|
|
229
|
+
// exactly the one request it costs today.
|
|
230
|
+
const byRecord = documents.results.length ? await participantGraph() : new Map();
|
|
231
|
+
return {
|
|
232
|
+
...documents,
|
|
233
|
+
results: documents.results.map((entity) =>
|
|
234
|
+
toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
|
|
235
|
+
),
|
|
236
|
+
};
|
|
122
237
|
},
|
|
123
238
|
|
|
124
239
|
async getDocument(id) {
|
|
125
|
-
const entity = await
|
|
126
|
-
|
|
240
|
+
const [entity, byRecord] = await Promise.all([
|
|
241
|
+
http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
|
|
242
|
+
participantGraph(),
|
|
243
|
+
]);
|
|
244
|
+
return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
|
|
127
245
|
},
|
|
128
246
|
|
|
129
247
|
listParties(query = {}) {
|
|
130
248
|
return page<Party>(types.party, query, (entity) => toParty(entity, scope));
|
|
131
249
|
},
|
|
132
250
|
|
|
133
|
-
listMoneyEvents(query = {}) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
251
|
+
async listMoneyEvents(query = {}) {
|
|
252
|
+
const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
|
|
253
|
+
const byRecord = events.results.length ? await participantGraph() : new Map();
|
|
254
|
+
return {
|
|
255
|
+
...events,
|
|
256
|
+
results: events.results.map((entity) =>
|
|
257
|
+
toMoneyEvent(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []),
|
|
258
|
+
),
|
|
259
|
+
};
|
|
137
260
|
},
|
|
138
261
|
|
|
139
262
|
/**
|
|
@@ -144,6 +267,71 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
|
144
267
|
* Dropbox has contributed zero documents — and a count derived from the
|
|
145
268
|
* corpus alone can never say it.
|
|
146
269
|
*/
|
|
270
|
+
/**
|
|
271
|
+
* The tracking surface's read (bd startsim-768w.38.25).
|
|
272
|
+
*
|
|
273
|
+
* SERVER-SIDE FILTERING, ALWAYS (CLAUDE.md rule 8) — status, owner and due
|
|
274
|
+
* window all go out as query parameters. The tenant does not honour them
|
|
275
|
+
* yet (bd startsim-768w.38.12 is the bead for that, and the filters that
|
|
276
|
+
* matter most for THIS screen are status/owner/dueBefore, not full text), so
|
|
277
|
+
* `appliedFilters` reports the gap and the composer says so on screen rather
|
|
278
|
+
* than rendering an unfiltered list under a filled-in control.
|
|
279
|
+
*/
|
|
280
|
+
async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
|
|
281
|
+
const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
|
|
282
|
+
const response = await http.get<DrfPage<TenantEntity>>(entities, {
|
|
283
|
+
params: {
|
|
284
|
+
type: types.commitment,
|
|
285
|
+
page: query.page ?? 1,
|
|
286
|
+
...(query.pageSize ? { page_size: query.pageSize } : {}),
|
|
287
|
+
...(statuses.length ? { status: statuses.join(',') } : {}),
|
|
288
|
+
...(query.subtype ? { subtype: query.subtype } : {}),
|
|
289
|
+
...(query.owner ? { owner: query.owner } : {}),
|
|
290
|
+
...(query.dueBefore ? { due_before: query.dueBefore } : {}),
|
|
291
|
+
...(query.changedAfter ? { changed_after: query.changedAfter } : {}),
|
|
292
|
+
...(query.search ? { search: query.search } : {}),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
count: response.count ?? response.results?.length ?? 0,
|
|
297
|
+
next: response.next ?? null,
|
|
298
|
+
previous: response.previous ?? null,
|
|
299
|
+
results: (response.results ?? []).map((entity) => toCommitment(entity, scope, sourceLabels)),
|
|
300
|
+
appliedFilters: (['status', 'subtype', 'owner', 'dueBefore', 'changedAfter', 'search'] as const)
|
|
301
|
+
.filter((key) => Boolean(query[key]))
|
|
302
|
+
.filter((key) => (supportedFilters as string[]).includes(key)),
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Write a run's commitments back.
|
|
308
|
+
*
|
|
309
|
+
* ONE REQUEST PER ROW, because that is what the tenant offers: its
|
|
310
|
+
* `/api/v1/entities/upsert/` takes a single record (verified on the wire —
|
|
311
|
+
* a list body is rejected with `external_id: required for upsert`). The
|
|
312
|
+
* writes are therefore fanned out with a bounded concurrency rather than
|
|
313
|
+
* looped serially, and the missing bulk endpoint is filed rather than
|
|
314
|
+
* pretended away.
|
|
315
|
+
*
|
|
316
|
+
* Idempotency is the SERVER's, not this function's: the upsert is keyed on
|
|
317
|
+
* (entity_type, external_id), so a second run rewrites rows in place and two
|
|
318
|
+
* concurrent runs converge instead of duplicating.
|
|
319
|
+
*/
|
|
320
|
+
async saveCommitments(commitments: Commitment[]): Promise<void> {
|
|
321
|
+
if (!http.post) {
|
|
322
|
+
throw new Error('This corpus client is read-only — no write method was supplied.');
|
|
323
|
+
}
|
|
324
|
+
const post = http.post.bind(http);
|
|
325
|
+
const payloads = commitments.map(commitmentToEntity);
|
|
326
|
+
for (let index = 0; index < payloads.length; index += WRITE_CONCURRENCY) {
|
|
327
|
+
await Promise.all(
|
|
328
|
+
payloads
|
|
329
|
+
.slice(index, index + WRITE_CONCURRENCY)
|
|
330
|
+
.map((payload) => post(`${entities}/upsert`, payload)),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
147
335
|
async listSources(): Promise<SourceFreshness[]> {
|
|
148
336
|
const [connectionPage, documentPage] = await Promise.all([
|
|
149
337
|
http
|