@startsimpli/integrations 0.2.14 → 0.2.17
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-field-gate.test.ts +246 -0
- package/src/__tests__/corpus-llm-timeout.test.ts +91 -0
- package/src/__tests__/corpus-pages.test.tsx +47 -0
- package/src/__tests__/corpus-runs.test.ts +631 -0
- package/src/__tests__/corpus-tracker.test.tsx +254 -1
- package/src/components/pages/AskPage.tsx +11 -0
- package/src/components/pages/LibraryPage.tsx +12 -0
- package/src/components/pages/TrackerPage.tsx +326 -2
- package/src/corpus/ask.ts +21 -0
- package/src/corpus/client.ts +57 -1
- package/src/corpus/entities.ts +117 -1
- package/src/corpus/extract.ts +85 -8
- package/src/corpus/fixtures.ts +34 -0
- package/src/corpus/index.ts +29 -0
- package/src/corpus/llm.ts +32 -3
- package/src/corpus/runs.ts +563 -0
- package/src/corpus/types.ts +204 -2
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import * as React from 'react';
|
|
28
28
|
|
|
29
|
+
import { isStalled, runCoverage, runProgress } from '../../corpus/runs';
|
|
29
30
|
import { stripMarkdown } from '../Markdown';
|
|
30
31
|
import type {
|
|
31
32
|
Commitment,
|
|
@@ -34,6 +35,7 @@ import type {
|
|
|
34
35
|
CommitmentTrackerApi,
|
|
35
36
|
CorpusScope,
|
|
36
37
|
ExtractionResult,
|
|
38
|
+
ExtractionRun,
|
|
37
39
|
} from '../../corpus/types';
|
|
38
40
|
|
|
39
41
|
export interface TrackerPageProps {
|
|
@@ -51,12 +53,29 @@ export interface TrackerPageProps {
|
|
|
51
53
|
* supply a clock, one is frozen ONCE for the life of the component.
|
|
52
54
|
*/
|
|
53
55
|
now?: number;
|
|
56
|
+
/**
|
|
57
|
+
* How often the queued sweep is polled while it is going.
|
|
58
|
+
*
|
|
59
|
+
* A six-minute job does not need a second-by-second poll, and the tenant data
|
|
60
|
+
* API pays for every one of them. Three seconds is fast enough that the bar
|
|
61
|
+
* moves and slow enough that watching costs nothing.
|
|
62
|
+
*/
|
|
63
|
+
pollMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* How many times a stalled sweep may be picked back up without anybody
|
|
66
|
+
* pressing anything, per mount.
|
|
67
|
+
*
|
|
68
|
+
* Bounded so a workspace that cannot be reached ends up stopped and legible
|
|
69
|
+
* rather than retrying forever behind an open tab. Twenty-four stints is well
|
|
70
|
+
* over a day of ten-minute sweeps.
|
|
71
|
+
*/
|
|
72
|
+
maxAutoResumes?: number;
|
|
54
73
|
classNames?: Partial<Record<TrackerSlot, string>>;
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
type TrackerSlot =
|
|
58
77
|
| 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
|
|
59
|
-
| 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due';
|
|
78
|
+
| 'input' | 'muted' | 'error' | 'warning' | 'row' | 'due' | 'progressTrack' | 'progressBar';
|
|
60
79
|
|
|
61
80
|
const DEFAULTS: Record<TrackerSlot, string> = {
|
|
62
81
|
root: 'space-y-6',
|
|
@@ -72,6 +91,8 @@ const DEFAULTS: Record<TrackerSlot, string> = {
|
|
|
72
91
|
warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
|
|
73
92
|
row: 'rounded-lg border border-gray-200 p-4',
|
|
74
93
|
due: 'text-sm font-medium text-red-700',
|
|
94
|
+
progressTrack: 'h-2 w-full overflow-hidden rounded-full bg-gray-200',
|
|
95
|
+
progressBar: 'h-2 rounded-full bg-gray-900 transition-all',
|
|
75
96
|
};
|
|
76
97
|
|
|
77
98
|
const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: string }> = [
|
|
@@ -83,14 +104,20 @@ const STATUS_TABS: Array<{ key: 'open' | 'overdue' | 'recent' | 'done'; label: s
|
|
|
83
104
|
|
|
84
105
|
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
85
106
|
|
|
107
|
+
/** States the poll stops at. A finished run is a record, not a subscription. */
|
|
108
|
+
const TERMINAL_RUN = new Set(['succeeded', 'failed', 'cancelled']);
|
|
109
|
+
|
|
86
110
|
export function TrackerPage({
|
|
87
111
|
api,
|
|
88
112
|
scope,
|
|
89
113
|
onOpenDocument,
|
|
90
114
|
now,
|
|
115
|
+
pollMs = 3_000,
|
|
116
|
+
maxAutoResumes = 24,
|
|
91
117
|
classNames = {},
|
|
92
118
|
}: TrackerPageProps) {
|
|
93
119
|
const cn = { ...DEFAULTS, ...classNames };
|
|
120
|
+
const [sweepEpoch, setSweepEpoch] = React.useState(0);
|
|
94
121
|
|
|
95
122
|
// Frozen once. See the note on the prop.
|
|
96
123
|
const [mountedAt] = React.useState(() => Date.now());
|
|
@@ -113,6 +140,18 @@ export function TrackerPage({
|
|
|
113
140
|
const [error, setError] = React.useState('');
|
|
114
141
|
const [run, setRun] = React.useState<ExtractionResult | null>(null);
|
|
115
142
|
const [running, setRunning] = React.useState(false);
|
|
143
|
+
/**
|
|
144
|
+
* The SWEEP, when this deployment runs extraction as a job
|
|
145
|
+
* (bd startsim-768w.38.28).
|
|
146
|
+
*
|
|
147
|
+
* `run` above is one synchronous slice's result and is what a deployment small
|
|
148
|
+
* enough to finish inside a request still gets. This is the durable record of a
|
|
149
|
+
* long one, polled from the server. They are different objects because they
|
|
150
|
+
* describe different things — a result, and a job — and collapsing them would
|
|
151
|
+
* put a cursor on something that has no cursor.
|
|
152
|
+
*/
|
|
153
|
+
const [sweep, setSweep] = React.useState<ExtractionRun | null>(null);
|
|
154
|
+
const [starting, setStarting] = React.useState(false);
|
|
116
155
|
/**
|
|
117
156
|
* Does this workspace hold ANY commitments at all?
|
|
118
157
|
*
|
|
@@ -168,6 +207,98 @@ export function TrackerPage({
|
|
|
168
207
|
// Real input only: the query the user asked for, and the workspace they are in.
|
|
169
208
|
React.useEffect(() => { void load(); }, [load, scope.orgId]);
|
|
170
209
|
|
|
210
|
+
/**
|
|
211
|
+
* WATCH THE SWEEP, and pick one up that was already going.
|
|
212
|
+
*
|
|
213
|
+
* The poll starts on mount rather than on the button, because the run outlives
|
|
214
|
+
* the tab that started it: somebody who closes this page and comes back has to
|
|
215
|
+
* see the job that is still going, not a fresh idle screen with a button that
|
|
216
|
+
* would start a second one. Polling STOPS the moment the run reaches a
|
|
217
|
+
* terminal state — a finished job is a record, not a subscription.
|
|
218
|
+
*
|
|
219
|
+
* `now` is read from the frozen clock, never from `Date.now()` in a dependency
|
|
220
|
+
* array: that is what turned this component into 917 requests in ten minutes
|
|
221
|
+
* once already.
|
|
222
|
+
*/
|
|
223
|
+
const canQueue = Boolean(api.startExtraction && api.getExtractionRun);
|
|
224
|
+
/**
|
|
225
|
+
* The reader, and the last state we saw, held in REFS.
|
|
226
|
+
*
|
|
227
|
+
* Neither is an input to the poll. `load` changes whenever the tab or the
|
|
228
|
+
* owner filter changes, and a poll that restarts on a tab click is a poll that
|
|
229
|
+
* fires an extra request per click; the last-seen status is compared inside
|
|
230
|
+
* the tick rather than through a state updater, because a side effect inside a
|
|
231
|
+
* `setState` updater runs twice under StrictMode. Same discipline as the
|
|
232
|
+
* frozen clock above, and for the same reason: this component has already
|
|
233
|
+
* shipped one runaway.
|
|
234
|
+
*/
|
|
235
|
+
const loadRef = React.useRef(load);
|
|
236
|
+
loadRef.current = load;
|
|
237
|
+
const clockRef = React.useRef(clock);
|
|
238
|
+
clockRef.current = clock;
|
|
239
|
+
const scopeRef = React.useRef(scope);
|
|
240
|
+
scopeRef.current = scope;
|
|
241
|
+
const lastSeen = React.useRef<{ runId: string; status: string } | null>(null);
|
|
242
|
+
const resumes = React.useRef(0);
|
|
243
|
+
|
|
244
|
+
React.useEffect(() => {
|
|
245
|
+
if (!canQueue) return undefined;
|
|
246
|
+
let live = true;
|
|
247
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
248
|
+
|
|
249
|
+
const tick = async () => {
|
|
250
|
+
if (!live) return;
|
|
251
|
+
const latest = await apiRef.current.getExtractionRun!().catch(() => null);
|
|
252
|
+
if (!live) return;
|
|
253
|
+
if (latest) {
|
|
254
|
+
const previous = lastSeen.current;
|
|
255
|
+
lastSeen.current = { runId: latest.runId, status: latest.status };
|
|
256
|
+
setSweep(latest);
|
|
257
|
+
// A sweep that has just reached the end is a reason to re-read the list:
|
|
258
|
+
// the rows it wrote are the entire point of having watched it.
|
|
259
|
+
const settled = latest.status === 'succeeded' || latest.status === 'failed';
|
|
260
|
+
if (settled && (!previous || previous.runId !== latest.runId || previous.status !== latest.status)) {
|
|
261
|
+
void loadRef.current();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A STALLED RUN IS PICKED BACK UP, and this is the one place the browser
|
|
266
|
+
* is allowed near the job.
|
|
267
|
+
*
|
|
268
|
+
* It is NOT the scheduler: the server owns the cursor, the ordering, the
|
|
269
|
+
* lease and the work, and closing this tab does not lose any of it. What
|
|
270
|
+
* the browser uniquely has is a LIVE CREDENTIAL. A sweep is about ten
|
|
271
|
+
* minutes and the token that starts it lives thirty, so a run that began
|
|
272
|
+
* late in a session stops mid-way with its cursor intact; re-presenting a
|
|
273
|
+
* fresh bearer is all it needs to carry on, and asking somebody to click
|
|
274
|
+
* for that would be asking them to do the machine's bookkeeping.
|
|
275
|
+
*
|
|
276
|
+
* Bounded by a ref, not by state, so it can never drive a render. A run
|
|
277
|
+
* that FAILED is not auto-resumed — it said why it stopped and a person
|
|
278
|
+
* should read that before it is tried again.
|
|
279
|
+
*/
|
|
280
|
+
if (
|
|
281
|
+
latest &&
|
|
282
|
+
!TERMINAL_RUN.has(latest.status) &&
|
|
283
|
+
isStalled(latest, clockRef.current) &&
|
|
284
|
+
resumes.current < maxAutoResumes
|
|
285
|
+
) {
|
|
286
|
+
resumes.current += 1;
|
|
287
|
+
await apiRef.current.startExtraction!({ scope: scopeRef.current }).catch(() => undefined);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const busy = latest ? latest.status === 'queued' || latest.status === 'running' : false;
|
|
291
|
+
if (busy) timer = setTimeout(() => { void tick(); }, pollMs);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
void tick();
|
|
295
|
+
return () => {
|
|
296
|
+
live = false;
|
|
297
|
+
if (timer) clearTimeout(timer);
|
|
298
|
+
};
|
|
299
|
+
// `sweepEpoch` re-arms the poll after the button starts a new one.
|
|
300
|
+
}, [canQueue, pollMs, maxAutoResumes, scope.orgId, sweepEpoch]);
|
|
301
|
+
|
|
171
302
|
/**
|
|
172
303
|
* The tab, applied in the browser as well as sent to the server.
|
|
173
304
|
*
|
|
@@ -194,6 +325,11 @@ export function TrackerPage({
|
|
|
194
325
|
return [...byOwner].sort(compareForScreen(clock));
|
|
195
326
|
}, [rows, tab, owner, clock]);
|
|
196
327
|
|
|
328
|
+
const sweepBusy = Boolean(sweep && (sweep.status === 'queued' || sweep.status === 'running'));
|
|
329
|
+
// A run whose lease has lapsed is not running, whatever its status field says.
|
|
330
|
+
// Rendering it as "in progress" is a spinner that never ends.
|
|
331
|
+
const sweepStalled = Boolean(sweep && isStalled(sweep, clock));
|
|
332
|
+
|
|
197
333
|
const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
|
|
198
334
|
(key) => Boolean(query[key]) && !applied.includes(key),
|
|
199
335
|
);
|
|
@@ -225,7 +361,41 @@ export function TrackerPage({
|
|
|
225
361
|
onChange={(event) => setOwner(event.target.value)}
|
|
226
362
|
/>
|
|
227
363
|
</label>
|
|
228
|
-
{
|
|
364
|
+
{canQueue ? (
|
|
365
|
+
/**
|
|
366
|
+
* THE BUTTON NO LONGER WAITS, because it cannot: seventeen meetings
|
|
367
|
+
* is about six minutes of model time and the tenant gateway closes a
|
|
368
|
+
* request at sixty seconds. It starts the job and the screen watches
|
|
369
|
+
* it (bd startsim-768w.38.28).
|
|
370
|
+
*/
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
className={cn.secondaryButton}
|
|
374
|
+
disabled={starting || sweepBusy}
|
|
375
|
+
onClick={async () => {
|
|
376
|
+
setStarting(true);
|
|
377
|
+
setError('');
|
|
378
|
+
try {
|
|
379
|
+
const started = await apiRef.current.startExtraction!({ scope });
|
|
380
|
+
setSweep(started);
|
|
381
|
+
setSweepEpoch((epoch) => epoch + 1);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
setError(failureMessage(err));
|
|
384
|
+
} finally {
|
|
385
|
+
setStarting(false);
|
|
386
|
+
}
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{sweepStalled
|
|
390
|
+
? 'Resume reading your meetings'
|
|
391
|
+
: sweepBusy
|
|
392
|
+
? 'Reading your meetings…'
|
|
393
|
+
: 'Pull from meetings'}
|
|
394
|
+
</button>
|
|
395
|
+
) : api.extract ? (
|
|
396
|
+
// A deployment with no run store still gets the old synchronous
|
|
397
|
+
// call. It is the same primitive; it just has nowhere to keep a
|
|
398
|
+
// cursor, so it is only honest on a corpus that fits in a request.
|
|
229
399
|
<button
|
|
230
400
|
type="button"
|
|
231
401
|
className={cn.secondaryButton}
|
|
@@ -260,6 +430,9 @@ export function TrackerPage({
|
|
|
260
430
|
) : null}
|
|
261
431
|
|
|
262
432
|
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
433
|
+
{sweep ? (
|
|
434
|
+
<SweepPanel sweep={sweep} now={clock} classNames={cn} onOpenDocument={onOpenDocument} />
|
|
435
|
+
) : null}
|
|
263
436
|
{run ? <RunSummary result={run} classNames={cn} onOpenDocument={onOpenDocument} /> : null}
|
|
264
437
|
</section>
|
|
265
438
|
|
|
@@ -380,6 +553,157 @@ function CommitmentRow({
|
|
|
380
553
|
);
|
|
381
554
|
}
|
|
382
555
|
|
|
556
|
+
/**
|
|
557
|
+
* A SWEEP IN FLIGHT, and afterwards what it did (bd startsim-768w.38.28).
|
|
558
|
+
*
|
|
559
|
+
* The old button blocked on a call that could not finish, so the only states it
|
|
560
|
+
* ever had were "spinning" and "504". A six-minute job has four states a person
|
|
561
|
+
* actually needs, and every one of them is a sentence rather than a spinner:
|
|
562
|
+
*
|
|
563
|
+
* GOING how far, out of what, and that it survives leaving the page. A
|
|
564
|
+
* progress bar with no denominator is a spinner with extra steps.
|
|
565
|
+
* STALLED the driver went away — a container replacement, usually. Said out
|
|
566
|
+
* loud with a Resume, because a sweep that silently stops looking
|
|
567
|
+
* busy is the worst of the four.
|
|
568
|
+
* DONE what it wrote, AND whether the model actually answered.
|
|
569
|
+
* FAILED why, and what it kept. Partial progress is progress and nobody
|
|
570
|
+
* should start over by hand because the screen did not say so.
|
|
571
|
+
*
|
|
572
|
+
* THE MODEL LINE IS THE POINT OF THE BEAD. `fromModel: 0` used to be the entire
|
|
573
|
+
* report of a blown provider timeout, and it reads exactly like "the model found
|
|
574
|
+
* nothing to add". It is not allowed to render that way any more: if the model
|
|
575
|
+
* did not answer, this says so, says it timed out when it did, and names how many
|
|
576
|
+
* meetings got only the deterministic pass.
|
|
577
|
+
*/
|
|
578
|
+
function SweepPanel({
|
|
579
|
+
sweep, now, classNames: cn, onOpenDocument,
|
|
580
|
+
}: {
|
|
581
|
+
sweep: ExtractionRun;
|
|
582
|
+
now: number;
|
|
583
|
+
classNames: Record<TrackerSlot, string>;
|
|
584
|
+
onOpenDocument?: (documentId: string) => void;
|
|
585
|
+
}) {
|
|
586
|
+
const progress = runProgress(sweep);
|
|
587
|
+
const coverage = runCoverage(sweep);
|
|
588
|
+
const stalled = isStalled(sweep, now);
|
|
589
|
+
const busy = (sweep.status === 'queued' || sweep.status === 'running') && !stalled;
|
|
590
|
+
const totals = sweep.totals;
|
|
591
|
+
const failure = sweep.lastModelFailure;
|
|
592
|
+
|
|
593
|
+
return (
|
|
594
|
+
<div className={cn.card} role="status" aria-live="polite">
|
|
595
|
+
{busy || stalled ? (
|
|
596
|
+
<>
|
|
597
|
+
<p className="text-sm text-gray-800">
|
|
598
|
+
Reading your meetings — {progress.done} of {progress.total} meetings
|
|
599
|
+
{totals.created > 0 ? `, ${totals.created} items so far` : ''}.
|
|
600
|
+
</p>
|
|
601
|
+
<div className={`mt-2 ${cn.progressTrack}`}>
|
|
602
|
+
<div className={cn.progressBar} style={{ width: `${Math.max(progress.percent, 2)}%` }} />
|
|
603
|
+
</div>
|
|
604
|
+
</>
|
|
605
|
+
) : null}
|
|
606
|
+
|
|
607
|
+
{busy ? (
|
|
608
|
+
<p className={`mt-2 ${cn.muted}`}>
|
|
609
|
+
{/* The single most useful sentence on the screen: it removes the
|
|
610
|
+
reason somebody sits and watches, or presses the button again. */}
|
|
611
|
+
This keeps going if you close this page. Come back and it will still be here.
|
|
612
|
+
</p>
|
|
613
|
+
) : null}
|
|
614
|
+
|
|
615
|
+
{stalled ? (
|
|
616
|
+
<p className={`mt-2 ${cn.warning}`}>
|
|
617
|
+
This run stopped before it finished — the process that was doing the work went
|
|
618
|
+
away. Everything it had already read is saved. Resume picks up at meeting{' '}
|
|
619
|
+
{progress.done + 1}, not at the beginning.
|
|
620
|
+
</p>
|
|
621
|
+
) : null}
|
|
622
|
+
|
|
623
|
+
{sweep.status === 'succeeded' ? (
|
|
624
|
+
<p className="text-sm text-gray-800">
|
|
625
|
+
Read {totals.documentsProcessed} meeting{totals.documentsProcessed === 1 ? '' : 's'}:{' '}
|
|
626
|
+
{totals.created} new, {totals.merged} matched to something already tracked,{' '}
|
|
627
|
+
{totals.resolved} closed by a later meeting.
|
|
628
|
+
</p>
|
|
629
|
+
) : null}
|
|
630
|
+
|
|
631
|
+
{sweep.status === 'failed' ? (
|
|
632
|
+
<p className={cn.error}>
|
|
633
|
+
This run stopped: {sweep.error || 'the workspace could not be reached'}. It kept
|
|
634
|
+
everything it had already read — {progress.done} of {progress.total} meetings,{' '}
|
|
635
|
+
{totals.created} items. Running it again carries on from there.
|
|
636
|
+
</p>
|
|
637
|
+
) : null}
|
|
638
|
+
|
|
639
|
+
{/* WHAT THE MODEL DID, AND WHETHER IT ANSWERED AT ALL. */}
|
|
640
|
+
{sweep.status !== 'queued' && totals.documentsProcessed > 0 ? (
|
|
641
|
+
<p className={cn.muted}>
|
|
642
|
+
{totals.fromRules} came from the meetings’ own next-step lists, {totals.fromModel} were
|
|
643
|
+
read out of the notes by{' '}
|
|
644
|
+
{sweep.provider.live
|
|
645
|
+
? `${sweep.provider.provider} · ${sweep.provider.model}`
|
|
646
|
+
: 'no live model'}
|
|
647
|
+
.
|
|
648
|
+
</p>
|
|
649
|
+
) : null}
|
|
650
|
+
|
|
651
|
+
{totals.modelFailures > 0 ? (
|
|
652
|
+
<p className={`mt-2 ${cn.warning}`} role="status">
|
|
653
|
+
{/* NOT `fromModel: 0` on its own. That reading — "nothing to add" —
|
|
654
|
+
is what hid a week of degraded runs. */}
|
|
655
|
+
On {totals.modelFailures} of {totals.modelFailures + totals.modelCalls} passes the model
|
|
656
|
+
did not answer
|
|
657
|
+
{failure?.timedOut ? ' — it timed out' : failure ? ` — ${failure.message}` : ''}. Those{' '}
|
|
658
|
+
{sweep.modelIncompleteIds.length} meeting
|
|
659
|
+
{sweep.modelIncompleteIds.length === 1 ? '' : 's'} were read by the deterministic pass
|
|
660
|
+
only, so anything stated in the notes but not written into a next-step list is still
|
|
661
|
+
missing from them. Running it again gives the model another go; nothing is duplicated.
|
|
662
|
+
</p>
|
|
663
|
+
) : null}
|
|
664
|
+
|
|
665
|
+
{sweep.status === 'succeeded' && coverage.missed > 0 ? (
|
|
666
|
+
<p className={`mt-2 ${cn.warning}`}>
|
|
667
|
+
{/* A document that arrived mid-sweep shifts the offsets under it. Said
|
|
668
|
+
out loud rather than reported as a complete pass. */}
|
|
669
|
+
{coverage.missed} meeting{coverage.missed === 1 ? '' : 's'} arrived while this was
|
|
670
|
+
running and were not read. Run it again to pick them up.
|
|
671
|
+
</p>
|
|
672
|
+
) : null}
|
|
673
|
+
|
|
674
|
+
{sweep.needsReview.length ? (
|
|
675
|
+
<div className={`mt-2 ${cn.warning}`} role="status">
|
|
676
|
+
<p>
|
|
677
|
+
{sweep.needsReview.length} meeting statement
|
|
678
|
+
{sweep.needsReview.length === 1 ? '' : 's'} looked like they closed something, but not
|
|
679
|
+
closely enough to act on. Nothing was closed automatically.
|
|
680
|
+
</p>
|
|
681
|
+
<ul className="mt-1 space-y-1">
|
|
682
|
+
{sweep.needsReview.map((entry) => (
|
|
683
|
+
<li key={`${entry.commitmentId}:${entry.documentId}`}>
|
|
684
|
+
<button
|
|
685
|
+
type="button"
|
|
686
|
+
className="text-sm underline"
|
|
687
|
+
onClick={() => onOpenDocument?.(entry.documentId)}
|
|
688
|
+
>
|
|
689
|
+
“{stripMarkdown(entry.quote).slice(0, 120)}”
|
|
690
|
+
</button>
|
|
691
|
+
</li>
|
|
692
|
+
))}
|
|
693
|
+
</ul>
|
|
694
|
+
</div>
|
|
695
|
+
) : null}
|
|
696
|
+
|
|
697
|
+
{totals.droppedOutOfScope > 0 ? (
|
|
698
|
+
<p className={cn.error} role="status">
|
|
699
|
+
{totals.droppedOutOfScope} record{totals.droppedOutOfScope === 1 ? '' : 's'} from another
|
|
700
|
+
workspace were returned by the data API and excluded before extracting.
|
|
701
|
+
</p>
|
|
702
|
+
) : null}
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
383
707
|
/**
|
|
384
708
|
* What the last run did, in the terms that make it arguable.
|
|
385
709
|
*
|
package/src/corpus/ask.ts
CHANGED
|
@@ -75,6 +75,7 @@ function systemPrompt(mode: AskRequest['mode']): string {
|
|
|
75
75
|
'You answer questions about a company using only that company\'s own records.',
|
|
76
76
|
...shared,
|
|
77
77
|
'Be specific and short. Prefer naming the record and the date over hedging.',
|
|
78
|
+
'A document may list "withheld_from_this_reader". Those parts were deliberately not shown to you: never claim something was not said or did not happen on the basis of a document that lists them — say the record is partly withheld instead.',
|
|
78
79
|
].join('\n');
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -94,6 +95,7 @@ export function buildAnswerRequest(
|
|
|
94
95
|
occurredAt: document.occurredAt,
|
|
95
96
|
participants: document.participants.map((party) => party.name),
|
|
96
97
|
text: documentText(document, maxChars),
|
|
98
|
+
...(document.withheldFields?.length ? { withheldFields: document.withheldFields } : {}),
|
|
97
99
|
}));
|
|
98
100
|
return {
|
|
99
101
|
question: request.question,
|
|
@@ -126,6 +128,13 @@ export function renderAnswerPrompt(input: AnswerRequest): string {
|
|
|
126
128
|
`source: ${document.source}`,
|
|
127
129
|
`occurred_at: ${document.occurredAt ?? 'unknown'}`,
|
|
128
130
|
document.participants.length ? `participants: ${document.participants.join(', ')}` : '',
|
|
131
|
+
// The model is told what it was NOT given. Otherwise a summary-only view of
|
|
132
|
+
// a meeting whose transcript was withheld is indistinguishable from a
|
|
133
|
+
// meeting where the thing was never said, and the answer comes back as a
|
|
134
|
+
// confident negative about text nobody was allowed to show it.
|
|
135
|
+
document.withheldFields?.length
|
|
136
|
+
? `withheld_from_this_reader: ${document.withheldFields.join(', ')}`
|
|
137
|
+
: '',
|
|
129
138
|
'',
|
|
130
139
|
document.text,
|
|
131
140
|
'</document>',
|
|
@@ -279,6 +288,8 @@ export async function askCorpus(
|
|
|
279
288
|
};
|
|
280
289
|
}
|
|
281
290
|
|
|
291
|
+
const withheldFields = collectWithheld(retrieved);
|
|
292
|
+
|
|
282
293
|
const draft = await llm.answer(
|
|
283
294
|
buildAnswerRequest(request, retrieved, maxCharsPerDocument),
|
|
284
295
|
);
|
|
@@ -294,11 +305,21 @@ export async function askCorpus(
|
|
|
294
305
|
considered: retrieved,
|
|
295
306
|
unresolvedCitations: unresolved,
|
|
296
307
|
droppedOutOfScope,
|
|
308
|
+
...(withheldFields.length ? { withheldFields } : {}),
|
|
297
309
|
provider: llm.describe(),
|
|
298
310
|
freshness,
|
|
299
311
|
};
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
/** Every field withheld on any record that reached the prompt, deduped. */
|
|
315
|
+
function collectWithheld(retrieved: RetrievedDocument[]): string[] {
|
|
316
|
+
const names = new Set<string>();
|
|
317
|
+
for (const item of retrieved) {
|
|
318
|
+
for (const name of item.document.withheldFields ?? []) names.add(name);
|
|
319
|
+
}
|
|
320
|
+
return [...names].sort();
|
|
321
|
+
}
|
|
322
|
+
|
|
302
323
|
/**
|
|
303
324
|
* `uncited` is a real outcome, not a failure to report. The model produced text
|
|
304
325
|
* and nothing traceable; the surface renders that as a warning over the text
|
package/src/corpus/client.ts
CHANGED
|
@@ -27,10 +27,13 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import {
|
|
29
29
|
DEFAULT_PARTICIPANT_EDGES,
|
|
30
|
+
DEFAULT_RUN_KEY,
|
|
30
31
|
DEFAULT_TYPE_KEYS,
|
|
31
32
|
commitmentToEntity,
|
|
33
|
+
extractionRunToEntity,
|
|
32
34
|
indexParticipants,
|
|
33
35
|
toCommitment,
|
|
36
|
+
toExtractionRun,
|
|
34
37
|
toMoneyEvent,
|
|
35
38
|
toParty,
|
|
36
39
|
toSourceDocument,
|
|
@@ -44,6 +47,8 @@ import type {
|
|
|
44
47
|
CorpusPage,
|
|
45
48
|
CorpusScope,
|
|
46
49
|
DocumentQuery,
|
|
50
|
+
ExtractionRun,
|
|
51
|
+
ExtractionRunStore,
|
|
47
52
|
MoneyEvent,
|
|
48
53
|
Party,
|
|
49
54
|
PartyRef,
|
|
@@ -105,6 +110,11 @@ export interface CreateCorpusApiOptions {
|
|
|
105
110
|
maxGraphPages?: number;
|
|
106
111
|
/** How long a loaded graph is reused before it is read again. */
|
|
107
112
|
graphTtlMs?: number;
|
|
113
|
+
/**
|
|
114
|
+
* The `external_id` the singleton extraction-run row lives at. One per
|
|
115
|
+
* workspace; see `ExtractionRunStore` for why it is a singleton.
|
|
116
|
+
*/
|
|
117
|
+
runKey?: string;
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
interface DrfPage<T> {
|
|
@@ -236,7 +246,9 @@ function appliedFilters(
|
|
|
236
246
|
return requested.filter((key) => supported.includes(key));
|
|
237
247
|
}
|
|
238
248
|
|
|
239
|
-
export function createCorpusApi(
|
|
249
|
+
export function createCorpusApi(
|
|
250
|
+
options: CreateCorpusApiOptions,
|
|
251
|
+
): CorpusApi & CommitmentApi & ExtractionRunStore {
|
|
240
252
|
const {
|
|
241
253
|
http,
|
|
242
254
|
scope,
|
|
@@ -249,6 +261,7 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
|
|
|
249
261
|
graphTtlMs = 30_000,
|
|
250
262
|
} = options;
|
|
251
263
|
const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
|
|
264
|
+
const runKey = options.runKey ?? DEFAULT_RUN_KEY;
|
|
252
265
|
const entities = options.paths?.entities ?? 'api/v1/entities';
|
|
253
266
|
const connections = options.paths?.connections ?? 'api/v1/connections';
|
|
254
267
|
const relationships = options.paths?.relationships ?? 'api/v1/relationships';
|
|
@@ -427,6 +440,49 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
|
|
|
427
440
|
}
|
|
428
441
|
},
|
|
429
442
|
|
|
443
|
+
/**
|
|
444
|
+
* The sweep record, read back (bd startsim-768w.38.28).
|
|
445
|
+
*
|
|
446
|
+
* ONE ROW PER WORKSPACE, at a fixed `external_id`, because the tenant entity
|
|
447
|
+
* API filters on `?type=` and nothing else — there is no way to ask for "the
|
|
448
|
+
* newest run", so a row per sweep would mean scanning a list that grows
|
|
449
|
+
* forever on every poll. A singleton is one deterministic read that never
|
|
450
|
+
* grows, and the last few sweeps ride along inside it.
|
|
451
|
+
*
|
|
452
|
+
* FAIL-SOFT ON ABSENCE, NOT ON FAILURE. No run yet is `null`, which is a
|
|
453
|
+
* fact. A 5xx is thrown, because "we could not reach the workspace" and
|
|
454
|
+
* "nothing has ever run" are different answers and the screen renders them
|
|
455
|
+
* differently.
|
|
456
|
+
*/
|
|
457
|
+
async readRun() {
|
|
458
|
+
const response = await http
|
|
459
|
+
.get<DrfPage<TenantEntity>>(entities, {
|
|
460
|
+
params: { type: types.extractionRun, page: 1, page_size: 5 },
|
|
461
|
+
})
|
|
462
|
+
.catch((err: { status?: number }) => {
|
|
463
|
+
// An undeclared type is a 403 from the read gate, and it means exactly
|
|
464
|
+
// "no sweep has ever run here" — the type is declared by the first one.
|
|
465
|
+
if (err?.status === 403 || err?.status === 404) return EMPTY_PAGE as DrfPage<TenantEntity>;
|
|
466
|
+
throw err;
|
|
467
|
+
});
|
|
468
|
+
const row = (response.results ?? []).find(
|
|
469
|
+
(entity) => String(entity.external_id ?? '') === runKey,
|
|
470
|
+
);
|
|
471
|
+
return row ? toExtractionRun(row) : null;
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
async writeRun(run: ExtractionRun) {
|
|
475
|
+
if (!http.post) {
|
|
476
|
+
throw new Error('This corpus client is read-only — no write method was supplied.');
|
|
477
|
+
}
|
|
478
|
+
await http.post(`${entities}/upsert`, extractionRunToEntity(run, types.extractionRun, runKey));
|
|
479
|
+
// The record just written is the record, not whatever the server chose to
|
|
480
|
+
// echo — the upsert response shape is the tenant's, and re-parsing it here
|
|
481
|
+
// would make every field depend on a transform this package does not own.
|
|
482
|
+
const { adopted: _adopted, ...durable } = run;
|
|
483
|
+
return durable as ExtractionRun;
|
|
484
|
+
},
|
|
485
|
+
|
|
430
486
|
async listSources(): Promise<SourceFreshness[]> {
|
|
431
487
|
const [connectionPage, documentPage] = await Promise.all([
|
|
432
488
|
http
|