@startsimpli/integrations 0.1.5 → 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 +1 -1
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/corpus/client.ts +121 -14
- package/src/corpus/entities.ts +154 -5
- package/src/corpus/fixtures.ts +426 -186
- package/src/corpus/index.ts +11 -1
- package/src/corpus/llm.ts +27 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small amount of Markdown that actually arrives in a corpus, rendered.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Sources write Markdown and the corpus stores it verbatim —
|
|
5
|
+
* Granola's summary is `## Decisions` and `**$4,800 per month**`, and a model
|
|
6
|
+
* asked for prose answers in the same dialect. Rendering that as plain text puts
|
|
7
|
+
* literal hashes and asterisks on the screen, which reads as a bug in the
|
|
8
|
+
* product rather than as punctuation from the source.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT IS ~100 LINES AND NOT A DEPENDENCY. A Markdown library is a peer
|
|
11
|
+
* dependency every consuming app has to install and a sanitiser decision every
|
|
12
|
+
* consuming app has to get right. This renders to REACT ELEMENTS — there is no
|
|
13
|
+
* `dangerouslySetInnerHTML` anywhere below, so untrusted document text cannot
|
|
14
|
+
* become markup, which is the property that actually matters for a surface whose
|
|
15
|
+
* whole job is displaying text somebody else wrote.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT DELIBERATELY DOES NOT DO. No tables, no block quotes, no images, no
|
|
18
|
+
* single-asterisk emphasis (`*` appears in real prose far more often than it
|
|
19
|
+
* means italics, and a false positive silently eats characters out of a
|
|
20
|
+
* contract). Anything unrecognised falls through as its own literal text, which
|
|
21
|
+
* is the same thing the screen showed before — never worse.
|
|
22
|
+
*/
|
|
23
|
+
import * as React from 'react';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Markdown reduced to the text a human would read aloud.
|
|
27
|
+
*
|
|
28
|
+
* For places that need a STRING rather than elements — a truncated one-line
|
|
29
|
+
* preview, a title, an `aria-label`. Same syntax coverage as the renderer.
|
|
30
|
+
*/
|
|
31
|
+
export function stripMarkdown(text: string): string {
|
|
32
|
+
if (!text) return '';
|
|
33
|
+
return text
|
|
34
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, ''))
|
|
35
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
36
|
+
.replace(/^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/gm, '')
|
|
37
|
+
.replace(/^\s{0,3}[-*+]\s+/gm, '')
|
|
38
|
+
.replace(/^\s{0,3}>\s?/gm, '')
|
|
39
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
40
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
41
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
42
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
43
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `**bold**`, `` `code` `` and `[text](url)` inside one line of text. */
|
|
48
|
+
function inline(text: string, keyPrefix: string): React.ReactNode[] {
|
|
49
|
+
const nodes: React.ReactNode[] = [];
|
|
50
|
+
// One pass, one regex: whichever construct starts first wins, so a link whose
|
|
51
|
+
// label contains asterisks is still a link.
|
|
52
|
+
const pattern = /(\*\*|__)([\s\S]+?)\1|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g;
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
let match: RegExpExecArray | null;
|
|
55
|
+
let index = 0;
|
|
56
|
+
|
|
57
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
58
|
+
if (match.index > cursor) nodes.push(text.slice(cursor, match.index));
|
|
59
|
+
index += 1;
|
|
60
|
+
const key = `${keyPrefix}-${index}`;
|
|
61
|
+
if (match[2] !== undefined) {
|
|
62
|
+
nodes.push(<strong key={key}>{match[2]}</strong>);
|
|
63
|
+
} else if (match[3] !== undefined) {
|
|
64
|
+
nodes.push(
|
|
65
|
+
<code key={key} className="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">
|
|
66
|
+
{match[3]}
|
|
67
|
+
</code>,
|
|
68
|
+
);
|
|
69
|
+
} else if (match[4] !== undefined) {
|
|
70
|
+
const href = match[5] ?? '';
|
|
71
|
+
const safe = /^(https?:|mailto:)/i.test(href);
|
|
72
|
+
nodes.push(
|
|
73
|
+
safe ? (
|
|
74
|
+
<a key={key} href={href} target="_blank" rel="noreferrer" className="underline">
|
|
75
|
+
{match[4]}
|
|
76
|
+
</a>
|
|
77
|
+
) : (
|
|
78
|
+
// A `javascript:` or otherwise unrecognised scheme keeps its LABEL and
|
|
79
|
+
// loses its link. Silently dropping the text would edit the document.
|
|
80
|
+
<React.Fragment key={key}>{match[4]}</React.Fragment>
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
cursor = pattern.lastIndex;
|
|
85
|
+
}
|
|
86
|
+
if (cursor < text.length) nodes.push(text.slice(cursor));
|
|
87
|
+
return nodes.length ? nodes : [text];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const HEADING_CLASS = ['font-semibold', 'font-semibold', 'font-medium'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Block-level Markdown as React elements.
|
|
94
|
+
*
|
|
95
|
+
* `className` lands on the wrapper so an app's theme slot still styles the whole
|
|
96
|
+
* block exactly as it did when this was a `<p>` of raw text.
|
|
97
|
+
*/
|
|
98
|
+
export function Markdown({ text, className }: { text: string; className?: string }) {
|
|
99
|
+
const blocks = React.useMemo(() => parseBlocks(text ?? ''), [text]);
|
|
100
|
+
if (!text) return null;
|
|
101
|
+
return (
|
|
102
|
+
<div className={className}>
|
|
103
|
+
{blocks.map((block, blockIndex) => {
|
|
104
|
+
const key = `b${blockIndex}`;
|
|
105
|
+
if (block.kind === 'heading') {
|
|
106
|
+
const level = Math.min(block.level ?? 3, 3);
|
|
107
|
+
return (
|
|
108
|
+
<p
|
|
109
|
+
key={key}
|
|
110
|
+
className={`${HEADING_CLASS[level - 1] ?? 'font-medium'} ${blockIndex ? 'mt-3' : ''}`}
|
|
111
|
+
>
|
|
112
|
+
{inline(block.lines[0] ?? '', key)}
|
|
113
|
+
</p>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (block.kind === 'list') {
|
|
117
|
+
return (
|
|
118
|
+
<ul key={key} className={`list-disc space-y-1 pl-5 ${blockIndex ? 'mt-2' : ''}`}>
|
|
119
|
+
{block.lines.map((line, lineIndex) => (
|
|
120
|
+
<li key={`${key}-${lineIndex}`}>{inline(line, `${key}-${lineIndex}`)}</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (block.kind === 'rule') {
|
|
126
|
+
return <hr key={key} className="my-3 border-black/10" />;
|
|
127
|
+
}
|
|
128
|
+
return (
|
|
129
|
+
<p key={key} className={`whitespace-pre-wrap ${blockIndex ? 'mt-2' : ''}`}>
|
|
130
|
+
{inline(block.lines.join('\n'), key)}
|
|
131
|
+
</p>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Block {
|
|
139
|
+
kind: 'paragraph' | 'heading' | 'list' | 'rule';
|
|
140
|
+
lines: string[];
|
|
141
|
+
level?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseBlocks(text: string): Block[] {
|
|
145
|
+
const blocks: Block[] = [];
|
|
146
|
+
let paragraph: string[] = [];
|
|
147
|
+
let list: string[] = [];
|
|
148
|
+
|
|
149
|
+
const flushParagraph = () => {
|
|
150
|
+
if (paragraph.length) blocks.push({ kind: 'paragraph', lines: paragraph });
|
|
151
|
+
paragraph = [];
|
|
152
|
+
};
|
|
153
|
+
const flushList = () => {
|
|
154
|
+
if (list.length) blocks.push({ kind: 'list', lines: list });
|
|
155
|
+
list = [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
|
|
159
|
+
const line = raw.replace(/\s+$/, '');
|
|
160
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
|
|
161
|
+
const bullet = /^\s{0,3}[-*+]\s+(.*)$/.exec(line);
|
|
162
|
+
const numbered = /^\s{0,3}\d+[.)]\s+(.*)$/.exec(line);
|
|
163
|
+
const rule = /^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/.test(line);
|
|
164
|
+
|
|
165
|
+
if (!line.trim()) {
|
|
166
|
+
flushList();
|
|
167
|
+
flushParagraph();
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (rule) {
|
|
171
|
+
flushList();
|
|
172
|
+
flushParagraph();
|
|
173
|
+
blocks.push({ kind: 'rule', lines: [] });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (heading) {
|
|
177
|
+
flushList();
|
|
178
|
+
flushParagraph();
|
|
179
|
+
blocks.push({ kind: 'heading', lines: [heading[2] ?? ''], level: heading[1].length });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (bullet || numbered) {
|
|
183
|
+
flushParagraph();
|
|
184
|
+
list.push((bullet ? bullet[1] : numbered?.[1]) ?? '');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
flushList();
|
|
188
|
+
paragraph.push(line);
|
|
189
|
+
}
|
|
190
|
+
flushList();
|
|
191
|
+
flushParagraph();
|
|
192
|
+
return blocks;
|
|
193
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './ConnectAccountCard';
|
|
|
3
3
|
export * from './EmailList';
|
|
4
4
|
export * from './EventList';
|
|
5
5
|
export * from './EventListItem';
|
|
6
|
+
export * from './Markdown';
|
|
6
7
|
export * from './ReconsentPromptCard';
|
|
7
8
|
|
|
8
9
|
// The connections layer (bd startsim-768w.22.3.4): ONE schema-driven form and
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import * as React from 'react';
|
|
33
33
|
|
|
34
|
+
import { Markdown, stripMarkdown } from '../Markdown';
|
|
34
35
|
import type {
|
|
35
36
|
AskMode,
|
|
36
37
|
AskRequest,
|
|
@@ -259,7 +260,10 @@ function AnswerPanel({
|
|
|
259
260
|
</p>
|
|
260
261
|
) : null}
|
|
261
262
|
|
|
262
|
-
|
|
263
|
+
{/* A model asked for prose answers in Markdown. Rendering it as literal
|
|
264
|
+
text puts "**$4,800**" on the screen, which reads as the product
|
|
265
|
+
failing rather than as the model emphasising the number. */}
|
|
266
|
+
<Markdown text={result.answer} className={`mt-3 ${cn.answer}`} />
|
|
263
267
|
|
|
264
268
|
{canSave ? (
|
|
265
269
|
<button
|
|
@@ -300,7 +304,10 @@ function AnswerPanel({
|
|
|
300
304
|
{/* The model's own quote, so the claim can be checked against the
|
|
301
305
|
record without opening it. */}
|
|
302
306
|
{citation.quote ? (
|
|
303
|
-
|
|
307
|
+
// Flattened, not rendered: a quotation is a span of the record's
|
|
308
|
+
// own words, and it should read as one line inside the quote
|
|
309
|
+
// marks rather than re-opening as a heading or a bullet.
|
|
310
|
+
<p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(citation.quote)}”</p>
|
|
304
311
|
) : null}
|
|
305
312
|
</div>
|
|
306
313
|
))}
|
|
@@ -337,7 +344,7 @@ function ConsideredList({
|
|
|
337
344
|
return (
|
|
338
345
|
<details className="mt-3">
|
|
339
346
|
<summary className="cursor-pointer text-sm text-gray-600">
|
|
340
|
-
{unused.length} more record{unused.length === 1 ? '' : 's'}
|
|
347
|
+
{unused.length} more record{unused.length === 1 ? ' was' : 's were'} searched but not cited
|
|
341
348
|
</summary>
|
|
342
349
|
<div className="mt-2 space-y-1">
|
|
343
350
|
{unused.map((item) => (
|
|
@@ -360,16 +367,34 @@ function ConsideredList({
|
|
|
360
367
|
);
|
|
361
368
|
}
|
|
362
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Who answered, said plainly.
|
|
372
|
+
*
|
|
373
|
+
* THREE CASES, not two (bd startsim-768w.38.21). A live model names itself. A
|
|
374
|
+
* STUB says it is a stub. And a workspace with NO provider configured is not a
|
|
375
|
+
* stub at all — "Answered by a none stub ((provider default))" was neither
|
|
376
|
+
* English nor true. Every branch keeps the `live: false` honesty; the point is
|
|
377
|
+
* to say the true thing clearly, not to soften it.
|
|
378
|
+
*/
|
|
379
|
+
function providerSentence(provider: AskResult['provider']): string {
|
|
380
|
+
const model = provider.model?.trim() ?? '';
|
|
381
|
+
if (provider.live) {
|
|
382
|
+
return model ? `Answered by ${provider.provider} · ${model}` : `Answered by ${provider.provider}`;
|
|
383
|
+
}
|
|
384
|
+
if (!provider.provider || provider.provider === 'none') {
|
|
385
|
+
return 'No model is configured for this workspace — no live model was called.';
|
|
386
|
+
}
|
|
387
|
+
return model
|
|
388
|
+
? `Answered by a ${provider.provider} stub (${model}) — no live model was called`
|
|
389
|
+
: `Answered by a ${provider.provider} stub — no live model was called`;
|
|
390
|
+
}
|
|
391
|
+
|
|
363
392
|
function ProvenanceFooter({
|
|
364
393
|
result, classNames: cn,
|
|
365
394
|
}: { result: AskResult; classNames: Record<AskSlot, string> }) {
|
|
366
395
|
return (
|
|
367
396
|
<div className="mt-4 border-t pt-2">
|
|
368
|
-
<p className={cn.muted}>
|
|
369
|
-
{result.provider.live
|
|
370
|
-
? `Answered by ${result.provider.provider} · ${result.provider.model}`
|
|
371
|
-
: `Answered by a ${result.provider.provider} stub (${result.provider.model}) — no live model was called`}
|
|
372
|
-
</p>
|
|
397
|
+
<p className={cn.muted}>{providerSentence(result.provider)}</p>
|
|
373
398
|
{/* A non-zero drop means something upstream returned rows from another
|
|
374
399
|
workspace. It is stated rather than swallowed, because the guard
|
|
375
400
|
catching it is good news about the guard and bad news about the API. */}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import * as React from 'react';
|
|
29
29
|
|
|
30
|
+
import { Markdown, stripMarkdown } from '../Markdown';
|
|
30
31
|
import { enforceScope } from '../../corpus/retrieval';
|
|
31
32
|
import type {
|
|
32
33
|
CorpusApi,
|
|
@@ -107,8 +108,13 @@ function relative(value: string | null | undefined, now: number): string {
|
|
|
107
108
|
return `${Math.round(hours / 24)}d ago`;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A row's one-line preview. The Markdown is FLATTENED rather than rendered
|
|
113
|
+
* here — a truncated heading and half a bold span are worse than plain prose,
|
|
114
|
+
* and the row is a summary, not the document.
|
|
115
|
+
*/
|
|
110
116
|
function preview(document: SourceDocument, length = 180): string {
|
|
111
|
-
const text = document.body || document.sections[0]?.text || '';
|
|
117
|
+
const text = stripMarkdown(document.body || document.sections[0]?.text || '');
|
|
112
118
|
if (!text) return '';
|
|
113
119
|
return text.length > length ? `${text.slice(0, length).trim()}…` : text;
|
|
114
120
|
}
|
|
@@ -586,7 +592,9 @@ function DocumentDetail({
|
|
|
586
592
|
{document.body ? (
|
|
587
593
|
<div className="mt-3">
|
|
588
594
|
<p className="text-sm font-medium text-gray-900">Summary</p>
|
|
589
|
-
|
|
595
|
+
{/* Sources write Markdown — a literal "## Decisions" on the screen
|
|
596
|
+
reads as a broken product, not as the source's punctuation. */}
|
|
597
|
+
<Markdown text={document.body} className="mt-1 text-sm text-gray-700" />
|
|
590
598
|
</div>
|
|
591
599
|
) : null}
|
|
592
600
|
|
package/src/corpus/client.ts
CHANGED
|
@@ -16,9 +16,24 @@
|
|
|
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
|
+
indexParticipants,
|
|
32
|
+
toMoneyEvent,
|
|
33
|
+
toParty,
|
|
34
|
+
toSourceDocument,
|
|
35
|
+
} from './entities';
|
|
36
|
+
import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
22
37
|
import type {
|
|
23
38
|
CorpusApi,
|
|
24
39
|
CorpusPage,
|
|
@@ -26,7 +41,7 @@ import type {
|
|
|
26
41
|
DocumentQuery,
|
|
27
42
|
MoneyEvent,
|
|
28
43
|
Party,
|
|
29
|
-
|
|
44
|
+
PartyRef,
|
|
30
45
|
SourceFreshness,
|
|
31
46
|
} from './types';
|
|
32
47
|
|
|
@@ -49,9 +64,34 @@ export interface CreateCorpusApiOptions {
|
|
|
49
64
|
*/
|
|
50
65
|
supportedFilters?: Array<keyof DocumentQuery>;
|
|
51
66
|
/** Endpoint bases, without leading/trailing slashes (the client convention). */
|
|
52
|
-
paths?: { entities?: string; connections?: string };
|
|
67
|
+
paths?: { entities?: string; connections?: string; relationships?: string };
|
|
53
68
|
/** How old a source's last sync may be before the Library calls it stale. */
|
|
54
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;
|
|
55
95
|
}
|
|
56
96
|
|
|
57
97
|
interface DrfPage<T> {
|
|
@@ -92,10 +132,15 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
|
92
132
|
sourceLabels = {},
|
|
93
133
|
supportedFilters = [],
|
|
94
134
|
staleAfterHours = 26,
|
|
135
|
+
resolveParticipants = true,
|
|
136
|
+
participantEdges = DEFAULT_PARTICIPANT_EDGES,
|
|
137
|
+
maxGraphPages = 20,
|
|
138
|
+
graphTtlMs = 30_000,
|
|
95
139
|
} = options;
|
|
96
140
|
const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
|
|
97
141
|
const entities = options.paths?.entities ?? 'api/v1/entities';
|
|
98
142
|
const connections = options.paths?.connections ?? 'api/v1/connections';
|
|
143
|
+
const relationships = options.paths?.relationships ?? 'api/v1/relationships';
|
|
99
144
|
|
|
100
145
|
async function page<T>(
|
|
101
146
|
type: string,
|
|
@@ -114,26 +159,88 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi {
|
|
|
114
159
|
};
|
|
115
160
|
}
|
|
116
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
|
+
|
|
117
209
|
return {
|
|
118
|
-
listDocuments(query) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
};
|
|
122
221
|
},
|
|
123
222
|
|
|
124
223
|
async getDocument(id) {
|
|
125
|
-
const entity = await
|
|
126
|
-
|
|
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)) ?? []);
|
|
127
229
|
},
|
|
128
230
|
|
|
129
231
|
listParties(query = {}) {
|
|
130
232
|
return page<Party>(types.party, query, (entity) => toParty(entity, scope));
|
|
131
233
|
},
|
|
132
234
|
|
|
133
|
-
listMoneyEvents(query = {}) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
};
|
|
137
244
|
},
|
|
138
245
|
|
|
139
246
|
/**
|