@startsimpli/integrations 0.2.34 → 0.2.36
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-folders.test.tsx +727 -0
- package/src/__tests__/corpus-pages.test.tsx +4 -4
- package/src/__tests__/ingest.test.ts +127 -0
- package/src/__tests__/safe-fetch-pinning.test.ts +198 -0
- package/src/__tests__/safe-fetch.test.ts +186 -0
- package/src/components/FolderTree.tsx +425 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/LibraryPage.tsx +333 -35
- package/src/corpus/client.ts +88 -4
- package/src/corpus/entities.ts +75 -0
- package/src/corpus/fixtures.ts +263 -1
- package/src/corpus/index.ts +8 -1
- package/src/corpus/types.ts +93 -0
- package/src/ingest/index.ts +126 -0
- package/src/safe-fetch/index.ts +387 -0
package/src/corpus/entities.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type {
|
|
26
26
|
Commitment,
|
|
27
27
|
CommitmentMention,
|
|
28
|
+
CorpusFolder,
|
|
28
29
|
CorpusScope,
|
|
29
30
|
DocumentSection,
|
|
30
31
|
MoneyEvent,
|
|
@@ -556,6 +557,14 @@ export function toSourceDocument(
|
|
|
556
557
|
),
|
|
557
558
|
scope: scopeOf(entity, fallbackScope),
|
|
558
559
|
ingestedAt: asIso(entity.created_at ?? entity.createdAt),
|
|
560
|
+
// WHERE IT LIVES. `path` is the source-agnostic CONTAINER attribute
|
|
561
|
+
// (start-simpli-api#369) — the folder, not the file — and it is the same
|
|
562
|
+
// attribute the folder tree enumerates, so a row's badge and the tree node
|
|
563
|
+
// it belongs under can never disagree. `folder`/`container` are alternate
|
|
564
|
+
// storage spellings, not vendor knowledge.
|
|
565
|
+
...(asText(pick(data, 'path', 'folder', 'container')).trim()
|
|
566
|
+
? { folder: asText(pick(data, 'path', 'folder', 'container')).trim() }
|
|
567
|
+
: {}),
|
|
559
568
|
...(withheldFieldsOf(entity).length
|
|
560
569
|
? { withheldFields: withheldFieldsOf(entity) }
|
|
561
570
|
: {}),
|
|
@@ -863,3 +872,69 @@ export function toExtractionRun(entity: TenantEntity): import('./types').Extract
|
|
|
863
872
|
return null;
|
|
864
873
|
}
|
|
865
874
|
}
|
|
875
|
+
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
// folders (bd startsim-ei8gm.5)
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* One row of `GET /api/v1/entities/facets/?attr=path&under=…`, as the tenant
|
|
882
|
+
* really spells it.
|
|
883
|
+
*/
|
|
884
|
+
export interface TenantFacetRow {
|
|
885
|
+
value?: unknown;
|
|
886
|
+
count?: unknown;
|
|
887
|
+
/** The wire spelling. */
|
|
888
|
+
has_children?: unknown;
|
|
889
|
+
/**
|
|
890
|
+
* …and the spelling the BROWSER sees. `@startsimpli/api`'s fetch wrapper
|
|
891
|
+
* converts every response key from snake_case to camelCase, so this key
|
|
892
|
+
* arrives as `hasChildren` in the app and as `has_children` from a plain
|
|
893
|
+
* server-side fetch. Reading only one is not a nicety: it is the bug that made
|
|
894
|
+
* the Tracker report "this workspace's data API does not filter by status"
|
|
895
|
+
* while the server verifiably was, and here it would render every folder in
|
|
896
|
+
* the corpus as a childless leaf with no disclosure triangle — a tree that
|
|
897
|
+
* silently stops at depth one. The tolerance belongs at the seam.
|
|
898
|
+
*/
|
|
899
|
+
hasChildren?: unknown;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The top of the tree, and the one spelling of it.
|
|
904
|
+
*
|
|
905
|
+
* `/`, never `''`: the tenant's `normalize_container()` says outright that a
|
|
906
|
+
* connector writes `"/"` for a top-level entry and that `""` is the spelling of
|
|
907
|
+
* "we do not know where this is". As `under=` it means the WHOLE corpus, because
|
|
908
|
+
* `subtree_bound()` makes the root's descendant prefix `/`, which every stored
|
|
909
|
+
* path begins with.
|
|
910
|
+
*/
|
|
911
|
+
export const FOLDER_ROOT = '/';
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The last segment of a path, for DISPLAY only.
|
|
915
|
+
*
|
|
916
|
+
* The full path is what gets sent back — see `CorpusFolder.path`. This exists so
|
|
917
|
+
* a node reads `2026` rather than `/Contracts/2026`, and it is deliberately the
|
|
918
|
+
* only place in the client that takes a path apart.
|
|
919
|
+
*/
|
|
920
|
+
export function folderName(path: string): string {
|
|
921
|
+
const trimmed = String(path ?? '').replace(/\/+$/, '');
|
|
922
|
+
const cut = trimmed.lastIndexOf('/');
|
|
923
|
+
return (cut >= 0 ? trimmed.slice(cut + 1) : trimmed) || '/';
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** A facet row -> a tree node. */
|
|
927
|
+
export function toFolder(row: TenantFacetRow): CorpusFolder {
|
|
928
|
+
const path = asText(row?.value);
|
|
929
|
+
const count = Number(row?.count);
|
|
930
|
+
// `has_children` is ABSENT on the flat (no `under=`) read, and absent must
|
|
931
|
+
// read as "no", not as "unknown": a node drawn with a triangle it cannot
|
|
932
|
+
// expand is worse than one drawn without.
|
|
933
|
+
const deeper = row?.has_children ?? row?.hasChildren;
|
|
934
|
+
return {
|
|
935
|
+
path,
|
|
936
|
+
name: folderName(path),
|
|
937
|
+
count: Number.isFinite(count) ? count : 0,
|
|
938
|
+
hasChildren: deeper === true || deeper === 'true' || deeper === 1,
|
|
939
|
+
};
|
|
940
|
+
}
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -275,6 +275,10 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
|
275
275
|
occurred_at: '2026-02-01T00:00:00Z',
|
|
276
276
|
url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
|
|
277
277
|
participant_count: 2,
|
|
278
|
+
// THE CONTAINER, not the file (start-simpli-api#369). A Dropbox file
|
|
279
|
+
// carries its parent's PATH and never its parent's id, which is why the
|
|
280
|
+
// tree is derived from this attribute rather than from folder rows.
|
|
281
|
+
path: '/Contracts/Acme',
|
|
278
282
|
extracted_text: [
|
|
279
283
|
'MASTER SERVICES AGREEMENT',
|
|
280
284
|
'1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
|
|
@@ -296,10 +300,111 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
|
296
300
|
occurred_at: '2026-01-05T00:00:00Z',
|
|
297
301
|
url: 'https://docs.google.example/document/d/1QpZv7t9kR',
|
|
298
302
|
participant_count: 1,
|
|
303
|
+
path: '/Pricing',
|
|
299
304
|
extracted_text:
|
|
300
305
|
'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
|
|
301
306
|
},
|
|
302
307
|
}),
|
|
308
|
+
// ---- A DRIVE WITH DEPTH, AND WITH THINGS NOBODY READS ----
|
|
309
|
+
//
|
|
310
|
+
// The corpus a file source really produces (bd startsim-h6xnu): folders
|
|
311
|
+
// several levels deep, and a large share of the rows recovered from old decks
|
|
312
|
+
// — fonts, licence files, binaries. They are NOT filtered out, here or on the
|
|
313
|
+
// screen: an extension denylist is wrong the first time somebody's actual work
|
|
314
|
+
// is a .ttf, and categorising by folder makes it unnecessary (bd
|
|
315
|
+
// startsim-nbhno). What they are is TEXTLESS, which is what the row's own
|
|
316
|
+
// control has to be honest about.
|
|
317
|
+
row({
|
|
318
|
+
type: 'source_document', id: 'dbx_c19a', source: 'dropbox', vendorId: 'id:c19a77',
|
|
319
|
+
name: 'Statement of work — phase 2.pdf',
|
|
320
|
+
ownerSub: OWNER_A, createdAt: '2026-02-04T06:00:00Z',
|
|
321
|
+
data: {
|
|
322
|
+
subtype: 'file',
|
|
323
|
+
title: 'Statement of work — phase 2.pdf',
|
|
324
|
+
summary: 'Phase 2 scope: migration, two integrations, eight weeks.',
|
|
325
|
+
occurred_at: '2026-02-03T00:00:00Z',
|
|
326
|
+
path: '/Contracts/Acme/2026',
|
|
327
|
+
participant_count: 0,
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
row({
|
|
331
|
+
type: 'source_document', id: 'dbx_44b0', source: 'dropbox', vendorId: 'id:44b0e2',
|
|
332
|
+
name: 'NDA (countersigned).pdf',
|
|
333
|
+
ownerSub: OWNER_A, createdAt: '2026-01-14T06:00:00Z',
|
|
334
|
+
data: {
|
|
335
|
+
subtype: 'file',
|
|
336
|
+
title: 'NDA (countersigned).pdf',
|
|
337
|
+
summary: 'Mutual NDA, two-year term.',
|
|
338
|
+
occurred_at: '2026-01-13T00:00:00Z',
|
|
339
|
+
path: '/Contracts',
|
|
340
|
+
participant_count: 0,
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
row({
|
|
344
|
+
// A FILE WITH NO TEXT AT ALL — the case the row's "Get text" control exists
|
|
345
|
+
// for, and the one the screen used to answer with a sentence.
|
|
346
|
+
type: 'source_document', id: 'dbx_ttf1', source: 'dropbox', vendorId: 'id:ttf001',
|
|
347
|
+
name: 'Poppins-BoldItalic.ttf',
|
|
348
|
+
ownerSub: OWNER_A, createdAt: '2026-01-02T06:00:00Z',
|
|
349
|
+
data: {
|
|
350
|
+
subtype: 'file',
|
|
351
|
+
title: 'Poppins-BoldItalic.ttf',
|
|
352
|
+
path: '/Decks/2022 pitch/fonts',
|
|
353
|
+
participant_count: 0,
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
row({
|
|
357
|
+
type: 'source_document', id: 'dbx_ttf2', source: 'dropbox', vendorId: 'id:ttf002',
|
|
358
|
+
name: 'Helvetica75.TTF',
|
|
359
|
+
ownerSub: OWNER_A, createdAt: '2026-01-02T06:01:00Z',
|
|
360
|
+
data: {
|
|
361
|
+
subtype: 'file',
|
|
362
|
+
title: 'Helvetica75.TTF',
|
|
363
|
+
path: '/Decks/2022 pitch/fonts',
|
|
364
|
+
participant_count: 0,
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
row({
|
|
368
|
+
type: 'source_document', id: 'dbx_deck1', source: 'dropbox', vendorId: 'id:deck01',
|
|
369
|
+
name: 'Lilo Deck V4.key',
|
|
370
|
+
ownerSub: OWNER_A, createdAt: '2026-01-03T06:00:00Z',
|
|
371
|
+
data: {
|
|
372
|
+
subtype: 'file',
|
|
373
|
+
title: 'Lilo Deck V4.key',
|
|
374
|
+
path: '/Decks/2022 pitch',
|
|
375
|
+
participant_count: 0,
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
row({
|
|
379
|
+
// THE SAME FILENAME IN A DIFFERENT FOLDER — bd startsim-maios's own example
|
|
380
|
+
// of why a flat list cannot be read: "'Lilo Deck V4.key' is five rows and a
|
|
381
|
+
// reader cannot tell them apart". The folder is what tells them apart.
|
|
382
|
+
type: 'source_document', id: 'dbx_deck2', source: 'dropbox', vendorId: 'id:deck02',
|
|
383
|
+
name: 'Lilo Deck V4.key',
|
|
384
|
+
ownerSub: OWNER_A, createdAt: '2026-01-04T06:00:00Z',
|
|
385
|
+
data: {
|
|
386
|
+
subtype: 'file',
|
|
387
|
+
title: 'Lilo Deck V4.key',
|
|
388
|
+
path: '/Decks/2023 pitch',
|
|
389
|
+
participant_count: 0,
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
row({
|
|
393
|
+
// A SIBLING WHOSE NAME IS A PREFIX OF ANOTHER FOLDER'S. `/Contracts-old`
|
|
394
|
+
// must never appear under `/Contracts`, which is the whole reason `__under`
|
|
395
|
+
// is one parameter rather than a `startswith` a client assembled.
|
|
396
|
+
type: 'source_document', id: 'dbx_old1', source: 'dropbox', vendorId: 'id:old001',
|
|
397
|
+
name: 'Superseded MSA 2024.pdf',
|
|
398
|
+
ownerSub: OWNER_A, createdAt: '2025-06-01T06:00:00Z',
|
|
399
|
+
data: {
|
|
400
|
+
subtype: 'file',
|
|
401
|
+
title: 'Superseded MSA 2024.pdf',
|
|
402
|
+
summary: 'Prior MSA, superseded February 2026.',
|
|
403
|
+
occurred_at: '2024-06-01T00:00:00Z',
|
|
404
|
+
path: '/Contracts-old',
|
|
405
|
+
participant_count: 0,
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
303
408
|
row({
|
|
304
409
|
// ---- A DIFFERENT CUSTOMER WORKSPACE ----
|
|
305
410
|
// Deliberately the BEST answer to "what did we agree with Acme on renewal":
|
|
@@ -896,6 +1001,137 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
|
|
|
896
1001
|
* Every request is recorded, so "did the browser filter it" and "how many round
|
|
897
1002
|
* trips did one screen cost" are both assertable.
|
|
898
1003
|
*/
|
|
1004
|
+
/**
|
|
1005
|
+
* `normalize_container()` from the tenant's `apps/api/filters.py`, to the letter.
|
|
1006
|
+
*
|
|
1007
|
+
* `/Contracts/` and `/Contracts` are the same folder — a client that built the
|
|
1008
|
+
* prefix by joining segments sends the first — and THE ROOT KEEPS ITS SLASH,
|
|
1009
|
+
* because `""` is the spelling of "we do not know where this is" and stripping
|
|
1010
|
+
* `/` down to it would answer with the wrong thing rather than nothing.
|
|
1011
|
+
*/
|
|
1012
|
+
function normalizeContainer(raw: unknown): string {
|
|
1013
|
+
const text = String(raw ?? '').trim();
|
|
1014
|
+
if (!text) return '/';
|
|
1015
|
+
const trimmed = text.replace(/\/+$/, '');
|
|
1016
|
+
return trimmed || '/';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* `subtree_bound()`: `/Contracts` -> `['/Contracts', '/Contracts/']`.
|
|
1021
|
+
*
|
|
1022
|
+
* The pair, not a prefix, and the fixture copies it exactly because the whole
|
|
1023
|
+
* point of the parameter is that neither half alone is right:
|
|
1024
|
+
* `startsWith('/Contracts')` also matches the sibling `/Contracts-old`, and
|
|
1025
|
+
* `startsWith('/Contracts/')` misses the documents sitting directly in
|
|
1026
|
+
* `/Contracts`. The root is the case naive code gets wrong — its descendant
|
|
1027
|
+
* prefix is `/`, which every stored path begins with, so `under=/` means
|
|
1028
|
+
* EVERYTHING rather than the handful of top-level files.
|
|
1029
|
+
*/
|
|
1030
|
+
function subtreeBound(raw: unknown): [string, string] {
|
|
1031
|
+
const prefix = normalizeContainer(raw);
|
|
1032
|
+
return prefix.endsWith('/') ? [prefix, prefix] : [prefix, `${prefix}/`];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** The container attribute as the delivery writes it. */
|
|
1036
|
+
function pathOf(entity: TenantEntity): string {
|
|
1037
|
+
const value = (entity.data as Record<string, unknown> | undefined)?.path;
|
|
1038
|
+
return typeof value === 'string' ? value : '';
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* `GET /api/v1/entities/facets/`, as the tenant really answers it.
|
|
1043
|
+
*
|
|
1044
|
+
* FAITHFUL TO THE PARTS A CLIENT CAN GET WRONG, which is the only reason a
|
|
1045
|
+
* fixture is worth having:
|
|
1046
|
+
*
|
|
1047
|
+
* - UNKNOWN PARAMETERS ARE REFUSED, not ignored. `_Scoped.initial()` screens
|
|
1048
|
+
* this action against `_FACET_FILTERS` before any handler runs. A fixture
|
|
1049
|
+
* that tolerated `occurred_after` here would let a client ship a facet call
|
|
1050
|
+
* built from the document query and 400 in production the first time somebody
|
|
1051
|
+
* set a date filter.
|
|
1052
|
+
* - `value` IS THE FULL CHILD PATH, so it goes straight back into the next
|
|
1053
|
+
* `under=`. Returning a bare segment would make every client re-join paths.
|
|
1054
|
+
* - `count` IS DESCENDANTS, not direct children.
|
|
1055
|
+
* - A DOCUMENT SITTING DIRECTLY IN THE FOLDER IS NOT A CHILD NODE. Its derived
|
|
1056
|
+
* value is the prefix itself and the server excludes it — a file is not a
|
|
1057
|
+
* folder with an empty name.
|
|
1058
|
+
*/
|
|
1059
|
+
function facetRead(rows: TenantEntity[], params: Record<string, unknown>) {
|
|
1060
|
+
// `_FACET_FILTERS` = the entity vocabulary + `attr` + `under`, plus the
|
|
1061
|
+
// `attr.` prefix and DRF's own reserved words.
|
|
1062
|
+
const vocabulary = new Set([
|
|
1063
|
+
'type', 'id', 'id__in', 'external_id', 'external_id__in', 'search', 'source',
|
|
1064
|
+
'party', 'occurred_after', 'occurred_before', 'related_to', 'related_via',
|
|
1065
|
+
'related_direction', 'related_depth', 'order', 'attr', 'under',
|
|
1066
|
+
'page', 'page_size', 'format',
|
|
1067
|
+
]);
|
|
1068
|
+
for (const key of Object.keys(params)) {
|
|
1069
|
+
if (vocabulary.has(key) || key.startsWith('attr.')) continue;
|
|
1070
|
+
throw Object.assign(new Error(`unknown parameter '${key}'`), { status: 400 });
|
|
1071
|
+
}
|
|
1072
|
+
const attribute = String(params.attr ?? '').trim();
|
|
1073
|
+
if (!attribute) {
|
|
1074
|
+
throw Object.assign(
|
|
1075
|
+
new Error('attr: name the attribute to enumerate, e.g. ?attr=path'),
|
|
1076
|
+
{ status: 400 },
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const type = params.type ? String(params.type) : '';
|
|
1080
|
+
const scoped = (type ? rows.filter((entity) => entity.entity_type === type) : rows)
|
|
1081
|
+
.filter((entity) => {
|
|
1082
|
+
const value = (entity.data as Record<string, unknown> | undefined)?.[attribute];
|
|
1083
|
+
return typeof value === 'string' && value !== '';
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
if (params.under === undefined) {
|
|
1087
|
+
const flat = new Map<string, number>();
|
|
1088
|
+
for (const entity of scoped) {
|
|
1089
|
+
const value = String((entity.data as Record<string, unknown>)[attribute]);
|
|
1090
|
+
flat.set(value, (flat.get(value) ?? 0) + 1);
|
|
1091
|
+
}
|
|
1092
|
+
const results = [...flat]
|
|
1093
|
+
.map(([value, count]) => ({ value, count }))
|
|
1094
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
1095
|
+
return { ...slicePage(results, params), applied_filters: ['attr'] };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const [self, descendants] = subtreeBound(params.under);
|
|
1099
|
+
const groups = new Map<string, { count: number; deeper: boolean }>();
|
|
1100
|
+
for (const entity of scoped) {
|
|
1101
|
+
const value = String((entity.data as Record<string, unknown>)[attribute]);
|
|
1102
|
+
if (value !== self && !value.startsWith(descendants)) continue;
|
|
1103
|
+
const rest = value.slice(descendants.length);
|
|
1104
|
+
const cut = rest.indexOf('/');
|
|
1105
|
+
const child = `${descendants}${cut < 0 ? rest : rest.slice(0, cut)}`;
|
|
1106
|
+
// The DIRECT FILE BUCKET. A document sitting in the named folder leaves
|
|
1107
|
+
// nothing after the prefix, so its derived value is the prefix — it is a
|
|
1108
|
+
// file, not a child folder with no name, and it is not a node.
|
|
1109
|
+
if (child === descendants) continue;
|
|
1110
|
+
const at = groups.get(child) ?? { count: 0, deeper: false };
|
|
1111
|
+
at.count += 1;
|
|
1112
|
+
at.deeper = at.deeper || cut >= 0;
|
|
1113
|
+
groups.set(child, at);
|
|
1114
|
+
}
|
|
1115
|
+
const results = [...groups]
|
|
1116
|
+
.map(([value, at]) => ({ value, count: at.count, has_children: at.deeper }))
|
|
1117
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
1118
|
+
return { ...slicePage(results, params), applied_filters: ['attr', 'under'] };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** The same DRF envelope `slice()` builds, outside the closure that owns it. */
|
|
1122
|
+
function slicePage<T>(rows: T[], params: Record<string, unknown>) {
|
|
1123
|
+
const page = Number(params.page ?? 1) || 1;
|
|
1124
|
+
const asked = Number(params.page_size);
|
|
1125
|
+
const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_ID_LIST) : 50;
|
|
1126
|
+
const start = (page - 1) * size;
|
|
1127
|
+
return {
|
|
1128
|
+
count: rows.length,
|
|
1129
|
+
next: start + size < rows.length ? `?page=${page + 1}` : null,
|
|
1130
|
+
previous: page > 1 ? `?page=${page - 1}` : null,
|
|
1131
|
+
results: rows.slice(start, start + size),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
899
1135
|
export function createFixtureCorpusHttp(options: {
|
|
900
1136
|
entities?: TenantEntity[];
|
|
901
1137
|
relationships?: TenantRelationship[];
|
|
@@ -1020,6 +1256,11 @@ export function createFixtureCorpusHttp(options: {
|
|
|
1020
1256
|
ignored_filters: query.ignored.slice().sort(),
|
|
1021
1257
|
} as T;
|
|
1022
1258
|
}
|
|
1259
|
+
// `/api/v1/entities/facets/` — BEFORE the detail branch below, which
|
|
1260
|
+
// would otherwise read "facets" as a record id and 404.
|
|
1261
|
+
if (/entities\/facets\/?$/.test(path)) {
|
|
1262
|
+
return facetRead(entities, params) as T;
|
|
1263
|
+
}
|
|
1023
1264
|
if (path.includes('entities')) {
|
|
1024
1265
|
const id = /entities\/(.+)$/.exec(path)?.[1];
|
|
1025
1266
|
if (id) {
|
|
@@ -1041,7 +1282,28 @@ export function createFixtureCorpusHttp(options: {
|
|
|
1041
1282
|
matchesTenantSearch(term, `${entity.name ?? ''} ${jsonStrings(entity.data)}`),
|
|
1042
1283
|
);
|
|
1043
1284
|
}
|
|
1044
|
-
|
|
1285
|
+
// THE FOLDER BOUNDS, as the tenant really applies them
|
|
1286
|
+
// (start-simpli-api#391). `attr.path` is the shelf; `attr.path__under`
|
|
1287
|
+
// is the shelf PLUS its descendants — a union, which is why it is one
|
|
1288
|
+
// parameter and not a prefix the client assembled. A fixture that
|
|
1289
|
+
// treated `__under` as a plain `startsWith` would let a client pass
|
|
1290
|
+
// here and match `/Contracts-old` in production.
|
|
1291
|
+
const applied: string[] = [];
|
|
1292
|
+
const exact = params['attr.path'];
|
|
1293
|
+
const under = params['attr.path__under'];
|
|
1294
|
+
if (exact !== undefined) {
|
|
1295
|
+
rows = rows.filter((entity) => pathOf(entity) === normalizeContainer(exact));
|
|
1296
|
+
applied.push('attr.path');
|
|
1297
|
+
} else if (under !== undefined) {
|
|
1298
|
+
const [self, descendants] = subtreeBound(under);
|
|
1299
|
+
rows = rows.filter((entity) => {
|
|
1300
|
+
const at = pathOf(entity);
|
|
1301
|
+
return at === self || at.startsWith(descendants);
|
|
1302
|
+
});
|
|
1303
|
+
applied.push('attr.path__under');
|
|
1304
|
+
}
|
|
1305
|
+
if (term && options.searchBackend === 'postgres_fulltext') applied.push('search');
|
|
1306
|
+
return { ...slice(rows, params), applied_filters: applied } as T;
|
|
1045
1307
|
}
|
|
1046
1308
|
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
1047
1309
|
},
|
package/src/corpus/index.ts
CHANGED
|
@@ -35,11 +35,13 @@ export type {
|
|
|
35
35
|
Citation,
|
|
36
36
|
CorpusApi,
|
|
37
37
|
CorpusAskApi,
|
|
38
|
+
CorpusFolder,
|
|
38
39
|
CorpusLlm,
|
|
39
40
|
CorpusPage,
|
|
40
41
|
CorpusScope,
|
|
41
42
|
DocumentQuery,
|
|
42
43
|
DocumentSection,
|
|
44
|
+
FolderQuery,
|
|
43
45
|
Focus,
|
|
44
46
|
FocusCategory,
|
|
45
47
|
FocusDraft,
|
|
@@ -74,10 +76,15 @@ export {
|
|
|
74
76
|
toMoneyEvent,
|
|
75
77
|
toParty,
|
|
76
78
|
toPartyRef,
|
|
79
|
+
FOLDER_ROOT,
|
|
80
|
+
toFolder,
|
|
81
|
+
folderName,
|
|
77
82
|
toSections,
|
|
78
83
|
toSourceDocument,
|
|
79
84
|
} from './entities';
|
|
80
|
-
export type {
|
|
85
|
+
export type {
|
|
86
|
+
CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
|
|
87
|
+
} from './entities';
|
|
81
88
|
|
|
82
89
|
export {
|
|
83
90
|
enforceScope,
|
package/src/corpus/types.ts
CHANGED
|
@@ -127,6 +127,16 @@ export interface SourceDocument {
|
|
|
127
127
|
scope: CorpusScope;
|
|
128
128
|
/** When the connector last wrote this row. Drives the freshness claim. */
|
|
129
129
|
ingestedAt?: string | null;
|
|
130
|
+
/**
|
|
131
|
+
* WHERE IT LIVES — the CONTAINER, `/Contracts`, never `/Contracts/SOW.pdf`
|
|
132
|
+
* (start-simpli-api#369 ships `path` with exactly that meaning).
|
|
133
|
+
*
|
|
134
|
+
* Absent on a record whose source has no notion of a folder — a meeting note,
|
|
135
|
+
* an email — and that absence is honest rather than a gap to be filled: those
|
|
136
|
+
* records live in no folder and are reachable from the top of the Library, not
|
|
137
|
+
* from a bucket invented for them.
|
|
138
|
+
*/
|
|
139
|
+
folder?: string;
|
|
130
140
|
/**
|
|
131
141
|
* Attribute names the SERVER withheld from this reader
|
|
132
142
|
* (bd startsim-768w.38.33). The tenant's per-field read gate strips them from
|
|
@@ -289,6 +299,72 @@ export interface DocumentQuery {
|
|
|
289
299
|
/** ISO instants, filtering on `occurredAt`. */
|
|
290
300
|
occurredAfter?: string;
|
|
291
301
|
occurredBefore?: string;
|
|
302
|
+
/**
|
|
303
|
+
* THE FILES ON ONE SHELF — the documents whose container is EXACTLY this
|
|
304
|
+
* folder, which is what clicking a folder in a file browser means. Goes out as
|
|
305
|
+
* `?attr.path=<folder>`.
|
|
306
|
+
*/
|
|
307
|
+
folder?: string;
|
|
308
|
+
/**
|
|
309
|
+
* THE WHOLE SUBTREE — this folder and everything beneath it. Goes out as
|
|
310
|
+
* `?attr.path__under=<folder>`.
|
|
311
|
+
*
|
|
312
|
+
* TWO FIELDS AND NOT ONE, AND `__startswith` IS NEITHER OF THEM. The tenant's
|
|
313
|
+
* `filters.subtree_bound()` spells out why: `startswith=/Contracts` also
|
|
314
|
+
* matches the sibling `/Contracts-old`, and `startswith=/Contracts/` misses
|
|
315
|
+
* the documents sitting directly IN `/Contracts`, which is most of what a
|
|
316
|
+
* person means by the folder. The correct bound is the UNION of the two, a
|
|
317
|
+
* list endpoint ANDs its filters so a caller cannot express a union, and that
|
|
318
|
+
* is why the server ships it as ONE parameter. Reach for this, never for a
|
|
319
|
+
* prefix a client assembled.
|
|
320
|
+
*/
|
|
321
|
+
underFolder?: string;
|
|
322
|
+
page?: number;
|
|
323
|
+
pageSize?: number;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A CONTAINER IN THE CORPUS — one node of the folder tree.
|
|
328
|
+
*
|
|
329
|
+
* NOTHING MINTS ONE (bd startsim-maios). A folder is DERIVED at read time from
|
|
330
|
+
* the `path` attribute every file-shaped source already carries, so it has no id
|
|
331
|
+
* and no row: a rename re-mints nothing and orphans nothing, which is exactly
|
|
332
|
+
* the failure a materialised folder table would have. That is also why `path`
|
|
333
|
+
* below is the address — there is no other.
|
|
334
|
+
*/
|
|
335
|
+
export interface CorpusFolder {
|
|
336
|
+
/**
|
|
337
|
+
* The FULL path, as the server returned it — `/Contracts/2026`, never `2026`.
|
|
338
|
+
*
|
|
339
|
+
* IT GOES STRAIGHT BACK OUT, unmodified, as the next `under=` and as
|
|
340
|
+
* `folder`. The client never joins segments: doing so is a small escaping bug
|
|
341
|
+
* in every client that tries, and the server returns the whole path precisely
|
|
342
|
+
* so nobody has to.
|
|
343
|
+
*/
|
|
344
|
+
path: string;
|
|
345
|
+
/** The last segment, for display only. Derived from `path`, never sent back. */
|
|
346
|
+
name: string;
|
|
347
|
+
/**
|
|
348
|
+
* DESCENDANT documents, not direct children — `/Contracts` counts the ones in
|
|
349
|
+
* `/Contracts/2026/Q1` too. That is the number a person reading a collapsed
|
|
350
|
+
* folder wants, and it is why a folder can show 12 and open onto no files of
|
|
351
|
+
* its own.
|
|
352
|
+
*/
|
|
353
|
+
count: number;
|
|
354
|
+
/**
|
|
355
|
+
* Whether anything sits BELOW this folder. The disclosure control, answered by
|
|
356
|
+
* the same response that listed the folder — so drawing a triangle costs no
|
|
357
|
+
* probe request per node.
|
|
358
|
+
*/
|
|
359
|
+
hasChildren: boolean;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* One level of the tree. `under` is a folder the server previously returned (or
|
|
364
|
+
* `/` for the root, which means the whole corpus).
|
|
365
|
+
*/
|
|
366
|
+
export interface FolderQuery {
|
|
367
|
+
under: string;
|
|
292
368
|
page?: number;
|
|
293
369
|
pageSize?: number;
|
|
294
370
|
}
|
|
@@ -303,6 +379,23 @@ export interface CorpusApi {
|
|
|
303
379
|
listParties(query?: { search?: string; page?: number; pageSize?: number }): Promise<CorpusPage<Party>>;
|
|
304
380
|
/** Per-source freshness, for the strip at the top of the Library. */
|
|
305
381
|
listSources(): Promise<SourceFreshness[]>;
|
|
382
|
+
/**
|
|
383
|
+
* THE CHILD FOLDERS of one container, paginated (bd startsim-ei8gm.5).
|
|
384
|
+
*
|
|
385
|
+
* The second of the two reads a navigable tree needs: `listDocuments({folder})`
|
|
386
|
+
* gives the files on a shelf, this gives the shelves below it. Server-side and
|
|
387
|
+
* ONE LEVEL AT A TIME (CLAUDE.md rule 8) — reading every document's path and
|
|
388
|
+
* building the tree in the browser is the thing this exists to remove; it
|
|
389
|
+
* works at 247 records and expires in the low thousands.
|
|
390
|
+
*
|
|
391
|
+
* OPTIONAL, and a deployment can also HAVE it and refuse it. The tenant route
|
|
392
|
+
* shipped in start-simpli-api #391, so a fork built before that answers 404
|
|
393
|
+
* and an older one that never learned the parameters answers 400. Both mean
|
|
394
|
+
* the same thing to a caller — this corpus cannot enumerate folders — so the
|
|
395
|
+
* composer treats a failure exactly like an absent method and renders the flat
|
|
396
|
+
* list it rendered before. Never a half-drawn tree.
|
|
397
|
+
*/
|
|
398
|
+
listFolders?(query: FolderQuery): Promise<CorpusPage<CorpusFolder>>;
|
|
306
399
|
/**
|
|
307
400
|
* Optional because a tenant with no payment connector has no money events, and
|
|
308
401
|
* a corpus surface that requires one would fail the Dropbox-only test.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document ingest helpers (crochet_patterns-llmj, design D1/PK2): turn a fetched
|
|
3
|
+
* web page or a stored PDF into plain text for a model.
|
|
4
|
+
*
|
|
5
|
+
* SERVER-ONLY. Exported from the `@startsimpli/integrations/ingest` subpath,
|
|
6
|
+
* never from the package root (linkedom and unpdf are node-side dependencies).
|
|
7
|
+
*/
|
|
8
|
+
import { Readability } from '@mozilla/readability';
|
|
9
|
+
import { parseHTML } from 'linkedom';
|
|
10
|
+
import { extractText, getDocumentProxy } from 'unpdf';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// HTML
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'HEAD']);
|
|
17
|
+
const BLOCK = new Set([
|
|
18
|
+
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'CAPTION', 'DD', 'DETAILS', 'DIV', 'DL', 'DT',
|
|
19
|
+
'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
|
|
20
|
+
'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TD', 'TFOOT',
|
|
21
|
+
'TH', 'THEAD', 'TR', 'UL',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
interface DomNode {
|
|
25
|
+
nodeType: number;
|
|
26
|
+
nodeName: string;
|
|
27
|
+
textContent: string | null;
|
|
28
|
+
childNodes: ArrayLike<DomNode>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Text of a DOM subtree with a line break around every block element and at `<br>`. */
|
|
32
|
+
function blockText(root: DomNode): string {
|
|
33
|
+
let out = '';
|
|
34
|
+
const walk = (node: DomNode) => {
|
|
35
|
+
if (node.nodeType === 3) {
|
|
36
|
+
out += (node.textContent ?? '').replace(/\s+/g, ' ');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
|
|
40
|
+
const tag = node.nodeName.toUpperCase();
|
|
41
|
+
if (SKIP.has(tag)) return;
|
|
42
|
+
if (tag === 'BR') {
|
|
43
|
+
out += '\n';
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const block = BLOCK.has(tag);
|
|
47
|
+
if (block) out += '\n';
|
|
48
|
+
for (let i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i]!);
|
|
49
|
+
if (block) out += '\n';
|
|
50
|
+
};
|
|
51
|
+
walk(root);
|
|
52
|
+
return out
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => line.replace(/[ \t ]+/g, ' ').trim())
|
|
55
|
+
.join('\n')
|
|
56
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The readable text of an HTML page: Readability's main article when it finds
|
|
62
|
+
* one, else the whole body (scripts and styles dropped). Each block element and
|
|
63
|
+
* `<br>` starts a new line, so "Rnd 1 … / Rnd 2 …" list items stay one per line.
|
|
64
|
+
* `baseUrl` lets Readability resolve relative links; it does not change the text.
|
|
65
|
+
*/
|
|
66
|
+
export function readableText(html: string, baseUrl?: string): { title?: string; text: string } {
|
|
67
|
+
// linkedom leaves a bare fragment out of <body>; give it a document to live in.
|
|
68
|
+
const whole = /<html[\s>]/i.test(html) ? html : `<!doctype html><html><head></head><body>${html}</body></html>`;
|
|
69
|
+
const { document } = parseHTML(whole);
|
|
70
|
+
if (baseUrl && !document.querySelector('base')) {
|
|
71
|
+
const base = document.createElement('base');
|
|
72
|
+
base.setAttribute('href', baseUrl);
|
|
73
|
+
document.head?.appendChild(base);
|
|
74
|
+
}
|
|
75
|
+
const docTitle = document.title?.trim() || undefined;
|
|
76
|
+
|
|
77
|
+
let text = '';
|
|
78
|
+
let title = docTitle;
|
|
79
|
+
try {
|
|
80
|
+
// Readability mutates the document it is given; parse a fresh copy.
|
|
81
|
+
const article = new Readability(parseHTML(document.toString()).document as unknown as Document).parse();
|
|
82
|
+
if (article?.content) {
|
|
83
|
+
text = blockText(parseHTML(`<html><body>${article.content}</body></html>`).document as unknown as DomNode);
|
|
84
|
+
title = article.title?.trim() || docTitle;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// fall through to the whole body
|
|
88
|
+
}
|
|
89
|
+
if (!text) text = blockText((document.body ?? document) as unknown as DomNode);
|
|
90
|
+
return title ? { title, text } : { text };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// PDF
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface PdfText {
|
|
98
|
+
/** One entry per page, trimmed. */
|
|
99
|
+
pages: string[];
|
|
100
|
+
/** Characters across all pages (trimmed). */
|
|
101
|
+
totalChars: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Text layer of a PDF, one string per page (unpdf / pdf.js). Throws on bytes
|
|
106
|
+
* that are not a readable PDF. The input is copied first, so the caller's
|
|
107
|
+
* buffer (the stored original) is never detached.
|
|
108
|
+
*/
|
|
109
|
+
export async function extractPdfText(bytes: Uint8Array): Promise<PdfText> {
|
|
110
|
+
const pdf = await getDocumentProxy(new Uint8Array(bytes), { verbosity: 0 });
|
|
111
|
+
try {
|
|
112
|
+
const { text } = await extractText(pdf, { mergePages: false });
|
|
113
|
+
const pages = text.map((t) => t.trim());
|
|
114
|
+
return { pages, totalChars: pages.reduce((n, p) => n + p.length, 0) };
|
|
115
|
+
} finally {
|
|
116
|
+
await pdf.loadingTask.destroy().catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Average under this many characters per page = no usable text layer (design R6). */
|
|
121
|
+
export const SCANNED_CHARS_PER_PAGE = 200;
|
|
122
|
+
|
|
123
|
+
/** True when a PDF has no usable text layer and should go to the model as a document. */
|
|
124
|
+
export function looksScanned(r: PdfText): boolean {
|
|
125
|
+
return r.pages.length === 0 || r.totalChars / r.pages.length < SCANNED_CHARS_PER_PAGE;
|
|
126
|
+
}
|