@happyvertical/smrt-ingestion 0.57.20 → 0.57.22
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/FEEDBACK.md +137 -0
- package/PROPOSALS.md +146 -5
- package/README.md +168 -2
- package/REVIEW.md +611 -7
- package/agents/execution.md +87 -0
- package/dist/chunks/{execution-NoDh3pjs.js → execution-BzVQax0n.js} +528 -42
- package/dist/chunks/execution-BzVQax0n.js.map +1 -0
- package/dist/chunks/{feedback-CJB900iG.js → feedback-D3UTF8fm.js} +175 -36
- package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
- package/dist/chunks/{proposal-errors-VxcTU6Db.js → proposal-contracts-77Uo1B1j.js} +38 -44
- package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
- package/dist/chunks/reviewer-instruction-CEc3AOuU.js +33 -0
- package/dist/chunks/reviewer-instruction-CEc3AOuU.js.map +1 -0
- package/dist/dto.d.ts +75 -0
- package/dist/dto.d.ts.map +1 -1
- package/dist/execution-contracts.d.ts +4 -1
- package/dist/execution-contracts.d.ts.map +1 -1
- package/dist/execution-dto.d.ts +16 -0
- package/dist/execution-dto.d.ts.map +1 -1
- package/dist/execution-internal.d.ts +2 -0
- package/dist/execution-internal.d.ts.map +1 -1
- package/dist/execution.d.ts +101 -4
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +2 -2
- package/dist/feedback-dto.d.ts +18 -4
- package/dist/feedback-dto.d.ts.map +1 -1
- package/dist/feedback-internal.d.ts +9 -0
- package/dist/feedback-internal.d.ts.map +1 -1
- package/dist/feedback.d.ts +10 -0
- package/dist/feedback.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/manifest.json +339 -3
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +82 -2
- package/dist/models.js.map +1 -1
- package/dist/policy.d.ts +3 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/presented-filename.d.ts +11 -0
- package/dist/presented-filename.d.ts.map +1 -0
- package/dist/proposal-contracts.d.ts +31 -6
- package/dist/proposal-contracts.d.ts.map +1 -1
- package/dist/proposal-dto.d.ts +17 -0
- package/dist/proposal-dto.d.ts.map +1 -1
- package/dist/proposal-errors.d.ts +20 -0
- package/dist/proposal-errors.d.ts.map +1 -1
- package/dist/proposal-validation.d.ts +26 -0
- package/dist/proposal-validation.d.ts.map +1 -1
- package/dist/proposals.d.ts +6 -1
- package/dist/proposals.d.ts.map +1 -1
- package/dist/proposals.js +268 -71
- package/dist/proposals.js.map +1 -1
- package/dist/redaction.d.ts +9 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/review-dto.d.ts +108 -5
- package/dist/review-dto.d.ts.map +1 -1
- package/dist/review-host.d.ts +52 -3
- package/dist/review-host.d.ts.map +1 -1
- package/dist/review-instruction-view.d.ts +12 -0
- package/dist/review-instruction-view.d.ts.map +1 -0
- package/dist/review-summary.d.ts.map +1 -1
- package/dist/reviewer-instruction.d.ts +20 -0
- package/dist/reviewer-instruction.d.ts.map +1 -0
- package/dist/server.d.ts +252 -13
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1190 -85
- package/dist/server.js.map +1 -1
- package/dist/smrt-knowledge.json +137 -23
- package/dist/sources/common.d.ts +11 -0
- package/dist/sources/common.d.ts.map +1 -1
- package/dist/sources/email.d.ts.map +1 -1
- package/dist/sources/http.d.ts.map +1 -1
- package/dist/sources/watch-folder.d.ts.map +1 -1
- package/dist/svelte/components/ActionReview.svelte +54 -55
- package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
- package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
- package/dist/svelte/components/CandidatePicker.svelte +72 -0
- package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
- package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
- package/dist/svelte/components/EvidenceViewer.svelte +60 -4
- package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
- package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
- package/dist/svelte/components/InboxView.svelte +172 -19
- package/dist/svelte/components/InboxView.svelte.d.ts +36 -2
- package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeInbox.svelte +6 -3
- package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
- package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeReview.svelte +4 -2
- package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
- package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
- package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
- package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
- package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReviewView.svelte +653 -71
- package/dist/svelte/components/ReviewView.svelte.d.ts +20 -1
- package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
- package/dist/svelte/components/TriageView.svelte +669 -0
- package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
- package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +56 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +56 -0
- package/dist/svelte/index.d.ts +2 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +1 -0
- package/dist/svelte/view-utils.d.ts +67 -0
- package/dist/svelte/view-utils.d.ts.map +1 -1
- package/dist/svelte/view-utils.js +157 -0
- package/package.json +13 -13
- package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
- package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
- package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
- package/dist/chunks/feedback-CJB900iG.js.map +0 -1
- package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
|
@@ -1,88 +1,273 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Textarea } from '@happyvertical/smrt-ui/forms';
|
|
2
|
+
import { Input, Select, Textarea } from '@happyvertical/smrt-ui/forms';
|
|
3
3
|
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
4
4
|
import { Button } from '@happyvertical/smrt-ui/ui';
|
|
5
|
-
import { onDestroy, onMount } from 'svelte';
|
|
5
|
+
import { onDestroy, onMount, untrack } from 'svelte';
|
|
6
6
|
import type { EvidenceLocation } from '../../extraction-types.js';
|
|
7
|
+
import type {
|
|
8
|
+
GeneratedSuggestion,
|
|
9
|
+
ProposalCatalogEntry,
|
|
10
|
+
} from '../../proposal-dto.js';
|
|
7
11
|
import type { IntakeReviewHost, ItemReviewView } from '../../review-dto.js';
|
|
8
12
|
import { M } from '../i18n.js';
|
|
9
|
-
import {
|
|
10
|
-
|
|
13
|
+
import {
|
|
14
|
+
argumentsObject,
|
|
15
|
+
draftArguments,
|
|
16
|
+
evidenceUrl,
|
|
17
|
+
extractedContent,
|
|
18
|
+
missingArguments,
|
|
19
|
+
missingRequired,
|
|
20
|
+
observedPages,
|
|
21
|
+
pageGroups,
|
|
22
|
+
reviewInFlight,
|
|
23
|
+
} from '../view-utils.js';
|
|
24
|
+
import ActionReview, { type ReviewOutcome } from './ActionReview.svelte';
|
|
25
|
+
import CandidatePicker from './CandidatePicker.svelte';
|
|
11
26
|
import EvidenceViewer from './EvidenceViewer.svelte';
|
|
12
27
|
export interface Props {
|
|
13
28
|
host: IntakeReviewHost;
|
|
14
29
|
itemId: string;
|
|
30
|
+
/** Reload interval while the host reports the item as processing. */
|
|
31
|
+
pollMs?: number;
|
|
32
|
+
/** `split` puts the original beside the form; `stacked` puts it above, one part at a time. */
|
|
33
|
+
layout?: 'split' | 'stacked';
|
|
34
|
+
/**
|
|
35
|
+
* Every displayed view (load, in-place poll patch, denial projection), and
|
|
36
|
+
* `undefined` when a displayed view is cleared for a reload or an operation
|
|
37
|
+
* whose result has not been shown yet. `denied` is true when it was cleared
|
|
38
|
+
* because a callback failed (the view fails closed), not for a reload.
|
|
39
|
+
* Presentation only.
|
|
40
|
+
*/
|
|
41
|
+
onview?: (
|
|
42
|
+
view: ItemReviewView | undefined,
|
|
43
|
+
denied: boolean,
|
|
44
|
+
issued: number,
|
|
45
|
+
) => void;
|
|
46
|
+
/** Numbers each load when its request starts; reported as `issued` with the view. */
|
|
47
|
+
stamp?: () => number;
|
|
48
|
+
/** Whether the reviewer has unsent input on this item. */
|
|
49
|
+
ondirty?: (dirty: boolean) => void;
|
|
50
|
+
/** After an approve, apply or resolve and the reload that follows it succeeded. */
|
|
51
|
+
onoutcome?: (outcome: ReviewOutcome, view: ItemReviewView) => void;
|
|
15
52
|
}
|
|
16
|
-
let {
|
|
53
|
+
let {
|
|
54
|
+
host,
|
|
55
|
+
itemId,
|
|
56
|
+
pollMs = 2000,
|
|
57
|
+
layout = 'split',
|
|
58
|
+
onview,
|
|
59
|
+
stamp,
|
|
60
|
+
ondirty,
|
|
61
|
+
onoutcome,
|
|
62
|
+
}: Props = $props();
|
|
17
63
|
const { t } = useI18n();
|
|
18
64
|
let view = $state<ItemReviewView>(),
|
|
19
65
|
pending = $state(false),
|
|
20
66
|
error = $state(false),
|
|
21
67
|
splits = $state<Record<string, string>>({}),
|
|
68
|
+
instruction = $state(''),
|
|
69
|
+
refreshFailed = $state(false),
|
|
70
|
+
instructionBusy = $state(false),
|
|
71
|
+
// The draft the server refused as invalid; the note stays until it changes.
|
|
72
|
+
invalidDraft = $state<string>(),
|
|
73
|
+
// The draft a stale result left unrecorded; the note stays until the
|
|
74
|
+
// reviewer edits or resubmits it.
|
|
75
|
+
unrecordedDraft = $state<string>(),
|
|
76
|
+
// Human drafts for `needs_review` suggestions, keyed by `attemptId:index`.
|
|
77
|
+
// They survive refresh, failures and actions on other cards and are cleared
|
|
78
|
+
// only by a successful preview of their own card.
|
|
79
|
+
drafts = $state<Record<string, string>>({}),
|
|
80
|
+
// Drafts the reviewer tried to preview while unparsable; the alert clears
|
|
81
|
+
// as soon as the draft parses again.
|
|
82
|
+
invalidDrafts = $state<Record<string, true>>({}),
|
|
83
|
+
// Suggestion whose last preview the host rejected; its draft is kept.
|
|
84
|
+
previewRejected = $state<number>(),
|
|
22
85
|
invalid = $state(false);
|
|
86
|
+
/** Same ceiling the server enforces (REVIEWER_INSTRUCTION_MAX_LENGTH). */
|
|
87
|
+
const INSTRUCTION_MAX_LENGTH = 4000;
|
|
88
|
+
const instructionValid = $derived(
|
|
89
|
+
!!instruction.trim() && instruction.length <= INSTRUCTION_MAX_LENGTH,
|
|
90
|
+
);
|
|
91
|
+
// The busy note after a stale click stays only while the item is still in
|
|
92
|
+
// flight; any reload (including the one answering the click) that shows it
|
|
93
|
+
// settled clears it.
|
|
94
|
+
function settleBusy() {
|
|
95
|
+
if (instructionBusy && !inFlight(view)) instructionBusy = false;
|
|
96
|
+
}
|
|
97
|
+
/** Consecutive thrown poll ticks tolerated before failing closed. */
|
|
98
|
+
const MAX_POLL_FAILURES = 3;
|
|
23
99
|
let alive = true,
|
|
24
100
|
epoch = 0,
|
|
25
|
-
loadedPages = 1
|
|
101
|
+
loadedPages = 1,
|
|
102
|
+
poll: ReturnType<typeof setTimeout> | undefined,
|
|
103
|
+
pollTicks = 0,
|
|
104
|
+
pollFailures = 0;
|
|
26
105
|
onDestroy(() => {
|
|
27
106
|
alive = false;
|
|
28
107
|
epoch++;
|
|
108
|
+
clearTimeout(poll);
|
|
109
|
+
});
|
|
110
|
+
// Includes the server's own predicate, which also covers the gap between a
|
|
111
|
+
// completed extraction and its not-yet-appended interpretation.
|
|
112
|
+
const inFlight = reviewInFlight;
|
|
113
|
+
// Parents follow the displayed view (a workspace keeps its list row in step);
|
|
114
|
+
// untracked so the callback's own reads never re-run this effect. Undefined
|
|
115
|
+
// means a displayed view was cleared for a reload or an operation.
|
|
116
|
+
let shown = false;
|
|
117
|
+
// The read sequence number of the load that produced the displayed view.
|
|
118
|
+
let viewIssued = 0;
|
|
119
|
+
$effect(() => {
|
|
120
|
+
const current = view;
|
|
121
|
+
// Read too, so a denial after an operation cleared the view still reports.
|
|
122
|
+
const denied = !current && error;
|
|
123
|
+
// A first load that failed reports its denial too.
|
|
124
|
+
if (!current && !shown && !denied) return;
|
|
125
|
+
shown = true;
|
|
126
|
+
untrack(() => onview?.(current, denied, viewIssued));
|
|
127
|
+
});
|
|
128
|
+
// Unsent reviewer input on this item (instruction, drafts, split edits,
|
|
129
|
+
// start/resolve fields, action editors); a workspace confirms before leaving.
|
|
130
|
+
// It is read from the drafts themselves, against the text each draft started
|
|
131
|
+
// from, so it holds while a reload has cleared the view. While the view is
|
|
132
|
+
// denied nothing is shown, so nothing counts: the reviewer is never asked to
|
|
133
|
+
// discard input they cannot see (it is kept, and returns with an authorized
|
|
134
|
+
// refresh, as before).
|
|
135
|
+
let actionDirty = $state<Record<string, boolean>>({}),
|
|
136
|
+
// The generated text each suggestion draft and split draft started from.
|
|
137
|
+
draftBase = $state<Record<string, string>>({}),
|
|
138
|
+
splitBase = $state<Record<string, string>>({});
|
|
139
|
+
const dirty = $derived(
|
|
140
|
+
(view ? view.availability === 'available' : !error) &&
|
|
141
|
+
(!!instruction.trim() ||
|
|
142
|
+
editedFields() ||
|
|
143
|
+
Object.values(actionDirty).some(Boolean) ||
|
|
144
|
+
Object.entries(drafts).some(([key, draft]) => draft !== draftBase[key]) ||
|
|
145
|
+
Object.entries(splits).some(([id, draft]) => draft !== splitBase[id])),
|
|
146
|
+
);
|
|
147
|
+
/** Start-action and resolve fields (declared with their controls below). */
|
|
148
|
+
function editedFields() {
|
|
149
|
+
return !!resolveReason || !!startHandler || !!startReason;
|
|
150
|
+
}
|
|
151
|
+
// Split drafts start from the generation's groups whenever a view shows them.
|
|
152
|
+
$effect(() => {
|
|
153
|
+
const current = view;
|
|
154
|
+
if (current)
|
|
155
|
+
splitBase = Object.fromEntries(
|
|
156
|
+
(current.generation?.splits ?? []).map((split) => [
|
|
157
|
+
split.evidenceId,
|
|
158
|
+
JSON.stringify(split.groups),
|
|
159
|
+
]),
|
|
160
|
+
);
|
|
29
161
|
});
|
|
162
|
+
// A closed item takes no instruction, suggestion, split or started action, so
|
|
163
|
+
// such input left when it closed can never be sent: it is discarded rather
|
|
164
|
+
// than kept as unsent work.
|
|
165
|
+
$effect(() => {
|
|
166
|
+
if (view?.availability !== 'available' || !view.entry.item.disposition)
|
|
167
|
+
return;
|
|
168
|
+
untrack(() => {
|
|
169
|
+
instruction = '';
|
|
170
|
+
drafts = {};
|
|
171
|
+
draftBase = {};
|
|
172
|
+
startHandler = '';
|
|
173
|
+
startArgs = '';
|
|
174
|
+
startReason = '';
|
|
175
|
+
splits = {};
|
|
176
|
+
resolveReason = '';
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
$effect(() => {
|
|
180
|
+
const current = dirty;
|
|
181
|
+
untrack(() => ondirty?.(current));
|
|
182
|
+
});
|
|
183
|
+
// The original's chosen part and pages outlive in-place reloads and
|
|
184
|
+
// operations (the viewer itself unmounts while a reload is pending).
|
|
185
|
+
let viewerPart = $state<string>(),
|
|
186
|
+
viewerPages = $state<Record<string, number>>({});
|
|
187
|
+
/** Prefer the server's retry binding; otherwise the displayed generation. */
|
|
188
|
+
function instructionBinding(current: ItemReviewView | undefined) {
|
|
189
|
+
if (current?.instruction?.binding) return current.instruction.binding;
|
|
190
|
+
return current?.generation && current.analysis
|
|
191
|
+
? {
|
|
192
|
+
attemptId: current.analysis.attemptId,
|
|
193
|
+
expectedRevision: current.analysis.revision,
|
|
194
|
+
}
|
|
195
|
+
: undefined;
|
|
196
|
+
}
|
|
197
|
+
// Background stages (reinterpretation after an instruction) finish without a
|
|
198
|
+
// push channel; the authenticated load is repeated and patched in place so
|
|
199
|
+
// drafts, focus and the evidence viewer survive. The delay backs off to 5x.
|
|
200
|
+
function schedulePoll(ticket: number) {
|
|
201
|
+
clearTimeout(poll);
|
|
202
|
+
if (!inFlight(view)) {
|
|
203
|
+
pollTicks = 0;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const delay = Math.min(pollMs * 1.5 ** pollTicks, pollMs * 5);
|
|
207
|
+
pollTicks++;
|
|
208
|
+
poll = setTimeout(() => {
|
|
209
|
+
if (alive && ticket === epoch && !pending) void refresh(ticket);
|
|
210
|
+
}, delay);
|
|
211
|
+
}
|
|
30
212
|
function deny() {
|
|
31
213
|
epoch++;
|
|
214
|
+
clearTimeout(poll);
|
|
32
215
|
view = undefined;
|
|
33
216
|
splits = {};
|
|
217
|
+
// Drafts are the reviewer's own typed text, never host data: they are kept
|
|
218
|
+
// in memory (nothing renders while the view is denied) so a failed refresh
|
|
219
|
+
// or a failed action on another card does not discard them.
|
|
34
220
|
error = true;
|
|
35
221
|
pending = false;
|
|
36
222
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
if (
|
|
59
|
-
result &&
|
|
60
|
-
page.entry.item.analysisRevision !== result.entry.item.analysisRevision
|
|
61
|
-
)
|
|
62
|
-
throw new Error('Review changed during pagination');
|
|
63
|
-
result ??= page;
|
|
64
|
-
for (const action of page.reviews.actions) {
|
|
65
|
-
if (!actionIds.has(action.review.actionId)) {
|
|
66
|
-
actionIds.add(action.review.actionId);
|
|
67
|
-
actions.push(action);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
refreshedPages++;
|
|
71
|
-
cursor = page.reviews.nextCursor;
|
|
72
|
-
if (!cursor) break;
|
|
73
|
-
if (cursors.has(cursor)) throw new Error('Repeated review cursor');
|
|
74
|
-
cursors.add(cursor);
|
|
223
|
+
/** Aggregate the displayed pages with fresh cursors; returns undefined when fenced. */
|
|
224
|
+
async function fetchView(
|
|
225
|
+
ticket: number,
|
|
226
|
+
pageCount: number,
|
|
227
|
+
): Promise<
|
|
228
|
+
{ result: ItemReviewView; pages: number; issued: number } | undefined
|
|
229
|
+
> {
|
|
230
|
+
const issued = stamp?.() ?? 0;
|
|
231
|
+
let cursor: string | undefined;
|
|
232
|
+
let result: ItemReviewView | undefined;
|
|
233
|
+
const actions: ItemReviewView['reviews']['actions'] = [];
|
|
234
|
+
const actionIds = new Set<string>();
|
|
235
|
+
const cursors = new Set<string>();
|
|
236
|
+
let refreshedPages = 0;
|
|
237
|
+
for (let index = 0; index < pageCount; index++) {
|
|
238
|
+
const page = await host.load(itemId, cursor);
|
|
239
|
+
if (!alive || ticket !== epoch) return undefined;
|
|
240
|
+
if (page.availability !== 'available') {
|
|
241
|
+
result = page;
|
|
242
|
+
break;
|
|
75
243
|
}
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
244
|
+
if (
|
|
245
|
+
result &&
|
|
246
|
+
page.entry.item.analysisRevision !== result.entry.item.analysisRevision
|
|
247
|
+
)
|
|
248
|
+
throw new Error('Review changed during pagination');
|
|
249
|
+
result ??= page;
|
|
250
|
+
for (const action of page.reviews.actions) {
|
|
251
|
+
if (!actionIds.has(action.review.actionId)) {
|
|
252
|
+
actionIds.add(action.review.actionId);
|
|
253
|
+
actions.push(action);
|
|
254
|
+
}
|
|
83
255
|
}
|
|
84
|
-
|
|
85
|
-
|
|
256
|
+
refreshedPages++;
|
|
257
|
+
cursor = page.reviews.nextCursor;
|
|
258
|
+
if (!cursor) break;
|
|
259
|
+
if (cursors.has(cursor)) throw new Error('Repeated review cursor');
|
|
260
|
+
cursors.add(cursor);
|
|
261
|
+
}
|
|
262
|
+
if (!result) throw new Error('Missing review page');
|
|
263
|
+
if (result.availability === 'available')
|
|
264
|
+
result = {
|
|
265
|
+
...result,
|
|
266
|
+
reviews: { ...result.reviews, actions, nextCursor: cursor },
|
|
267
|
+
};
|
|
268
|
+
// A host denial must not leave any original, candidate, or argument in the DOM.
|
|
269
|
+
return {
|
|
270
|
+
result:
|
|
86
271
|
result.availability === 'available'
|
|
87
272
|
? result
|
|
88
273
|
: {
|
|
@@ -90,44 +275,279 @@ async function load(pageCount = loadedPages) {
|
|
|
90
275
|
evidence: [],
|
|
91
276
|
reviews: { actions: [] },
|
|
92
277
|
availability: result.availability,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
278
|
+
},
|
|
279
|
+
pages: refreshedPages,
|
|
280
|
+
issued,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Keep only drafts the reviewer edited; untouched ones follow the new generation. */
|
|
284
|
+
function splitDrafts(
|
|
285
|
+
next: ItemReviewView,
|
|
286
|
+
keep: Record<string, string>,
|
|
287
|
+
previous?: ItemReviewView,
|
|
288
|
+
) {
|
|
289
|
+
const generated = new Map(
|
|
290
|
+
(previous?.generation?.splits ?? []).map((split) => [
|
|
291
|
+
split.evidenceId,
|
|
292
|
+
JSON.stringify(split.groups),
|
|
293
|
+
]),
|
|
294
|
+
);
|
|
295
|
+
return Object.fromEntries(
|
|
296
|
+
(next.generation?.splits ?? []).map((split) => {
|
|
297
|
+
const draft = keep[split.evidenceId];
|
|
298
|
+
const edited =
|
|
299
|
+
draft !== undefined && draft !== generated.get(split.evidenceId);
|
|
300
|
+
return [split.evidenceId, edited ? draft : JSON.stringify(split.groups)];
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
function syncSuggestionDrafts(current: ItemReviewView) {
|
|
305
|
+
// Drafts follow availability: revoked or expired access discards them;
|
|
306
|
+
// a transient `unavailable` or `stale` result keeps them (nothing renders
|
|
307
|
+
// meanwhile) so the reviewer's typing survives until the view returns;
|
|
308
|
+
// an available view prunes drafts of attempts or cards no longer offered.
|
|
309
|
+
if (
|
|
310
|
+
current.availability === 'revoked' ||
|
|
311
|
+
current.availability === 'expired'
|
|
312
|
+
) {
|
|
313
|
+
drafts = {};
|
|
314
|
+
draftBase = {};
|
|
315
|
+
invalidDrafts = {};
|
|
316
|
+
} else if (current.availability === 'available') {
|
|
317
|
+
const attemptId = current.analysis?.attemptId;
|
|
318
|
+
const keys = new Set<string>();
|
|
319
|
+
(current.generation?.suggestions ?? []).forEach((suggestion, index) => {
|
|
320
|
+
if (!attemptId || suggestion.disposition !== 'needs_review') return;
|
|
321
|
+
const key = `${attemptId}:${index}`;
|
|
322
|
+
keys.add(key);
|
|
323
|
+
const generated = JSON.stringify(suggestion.args, null, 2);
|
|
324
|
+
drafts[key] ??= generated;
|
|
325
|
+
draftBase[key] ??= generated;
|
|
326
|
+
});
|
|
327
|
+
for (const key of Object.keys(drafts))
|
|
328
|
+
if (!keys.has(key)) {
|
|
329
|
+
delete drafts[key];
|
|
330
|
+
delete draftBase[key];
|
|
331
|
+
delete invalidDrafts[key];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function load(pageCount = loadedPages) {
|
|
336
|
+
const ticket = ++epoch;
|
|
337
|
+
clearTimeout(poll);
|
|
338
|
+
pollTicks = 0;
|
|
339
|
+
pollFailures = 0;
|
|
340
|
+
refreshFailed = false;
|
|
341
|
+
view = undefined;
|
|
342
|
+
splits = {};
|
|
343
|
+
previewRejected = undefined;
|
|
344
|
+
pending = true;
|
|
345
|
+
error = false;
|
|
346
|
+
invalid = false;
|
|
347
|
+
try {
|
|
348
|
+
const fetched = await fetchView(ticket, pageCount);
|
|
349
|
+
if (!fetched) return;
|
|
350
|
+
if (fetched.result.availability === 'available')
|
|
351
|
+
loadedPages = fetched.pages;
|
|
352
|
+
viewIssued = fetched.issued;
|
|
353
|
+
view = fetched.result;
|
|
354
|
+
syncSuggestionDrafts(view);
|
|
355
|
+
splits = splitDrafts(view, {});
|
|
356
|
+
settleBusy();
|
|
100
357
|
} catch {
|
|
101
358
|
if (alive && ticket === epoch) deny();
|
|
102
359
|
} finally {
|
|
103
|
-
if (alive && ticket === epoch)
|
|
360
|
+
if (alive && ticket === epoch) {
|
|
361
|
+
pending = false;
|
|
362
|
+
schedulePoll(ticket);
|
|
363
|
+
}
|
|
104
364
|
}
|
|
105
365
|
}
|
|
106
|
-
|
|
366
|
+
/** Poll tick: patch the view in place without blanking it or resetting drafts. */
|
|
367
|
+
async function refresh(ticket: number) {
|
|
368
|
+
try {
|
|
369
|
+
const fetched = await fetchView(ticket, loadedPages);
|
|
370
|
+
if (!fetched || !alive || ticket !== epoch) return;
|
|
371
|
+
pollFailures = 0;
|
|
372
|
+
refreshFailed = false;
|
|
373
|
+
// An explicit host denial fails closed at once.
|
|
374
|
+
if (fetched.result.availability !== 'available') {
|
|
375
|
+
viewIssued = fetched.issued;
|
|
376
|
+
view = fetched.result;
|
|
377
|
+
syncSuggestionDrafts(view);
|
|
378
|
+
splits = {};
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (JSON.stringify(fetched.result) !== JSON.stringify(view)) {
|
|
382
|
+
loadedPages = fetched.pages;
|
|
383
|
+
splits = splitDrafts(fetched.result, splits, view);
|
|
384
|
+
viewIssued = fetched.issued;
|
|
385
|
+
view = fetched.result;
|
|
386
|
+
syncSuggestionDrafts(view);
|
|
387
|
+
} else if (view) {
|
|
388
|
+
// Unchanged, but read again: the displayed view is this fresh.
|
|
389
|
+
viewIssued = fetched.issued;
|
|
390
|
+
const current = view;
|
|
391
|
+
untrack(() => onview?.(current, false, viewIssued));
|
|
392
|
+
}
|
|
393
|
+
settleBusy();
|
|
394
|
+
} catch {
|
|
395
|
+
if (!alive || ticket !== epoch) return;
|
|
396
|
+
// A thrown tick may be transient: keep the view and keep backing off, but
|
|
397
|
+
// a host may also throw for revoked access, so fail closed after a bound.
|
|
398
|
+
pollFailures++;
|
|
399
|
+
if (pollFailures >= MAX_POLL_FAILURES) {
|
|
400
|
+
refreshFailed = false;
|
|
401
|
+
deny();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
refreshFailed = true;
|
|
405
|
+
} finally {
|
|
406
|
+
if (alive && ticket === epoch && !pending) schedulePoll(ticket);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function run(operation: () => Promise<unknown>, outcome?: ReviewOutcome) {
|
|
107
410
|
if (pending) return;
|
|
108
411
|
const ticket = ++epoch;
|
|
412
|
+
clearTimeout(poll);
|
|
109
413
|
pending = true;
|
|
110
414
|
error = false;
|
|
111
415
|
view = undefined;
|
|
112
416
|
splits = {};
|
|
113
417
|
try {
|
|
114
418
|
await operation();
|
|
115
|
-
if (alive
|
|
419
|
+
if (!alive || ticket !== epoch) return;
|
|
420
|
+
await load();
|
|
421
|
+
// Only the reload that answered this operation reports its outcome.
|
|
422
|
+
// `load` reassigned the view; TypeScript still narrows it to undefined.
|
|
423
|
+
const settled = view as ItemReviewView | undefined;
|
|
424
|
+
if (
|
|
425
|
+
outcome &&
|
|
426
|
+
alive &&
|
|
427
|
+
ticket + 1 === epoch &&
|
|
428
|
+
settled?.availability === 'available'
|
|
429
|
+
)
|
|
430
|
+
onoutcome?.(outcome, settled);
|
|
116
431
|
} catch {
|
|
117
432
|
if (alive && ticket === epoch) deny();
|
|
118
433
|
}
|
|
119
434
|
}
|
|
120
|
-
function
|
|
435
|
+
function offeredHandler(suggestion: GeneratedSuggestion) {
|
|
436
|
+
return view?.generation?.offered.find(
|
|
437
|
+
(entry) =>
|
|
438
|
+
entry.handler.id === suggestion.handlerId &&
|
|
439
|
+
entry.handler.version === suggestion.handlerVersion,
|
|
440
|
+
)?.handler;
|
|
441
|
+
}
|
|
442
|
+
// Presentation only: the host rechecks stage, outcome, handler and authority.
|
|
443
|
+
function previewable(suggestion: GeneratedSuggestion) {
|
|
444
|
+
return (
|
|
445
|
+
!view?.entry.item.disposition &&
|
|
446
|
+
!!view?.analysis &&
|
|
447
|
+
view.generation?.outcome !== 'provider_error' &&
|
|
448
|
+
['ready_for_review', 'needs_review'].includes(suggestion.disposition)
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
function draftKey(index: number) {
|
|
452
|
+
return `${view?.analysis?.attemptId ?? ''}:${index}`;
|
|
453
|
+
}
|
|
454
|
+
function parseable(draft: string | undefined) {
|
|
455
|
+
if (draft === undefined) return false;
|
|
456
|
+
try {
|
|
457
|
+
argumentsObject(draft);
|
|
458
|
+
return true;
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function edited(index: number) {
|
|
464
|
+
const suggestion = view?.generation?.suggestions[index];
|
|
465
|
+
const draft = drafts[draftKey(index)];
|
|
466
|
+
return (
|
|
467
|
+
!!suggestion &&
|
|
468
|
+
draft !== undefined &&
|
|
469
|
+
draft !== JSON.stringify(suggestion.args, null, 2)
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
// Every schema-required argument must be present in the draft before preview;
|
|
473
|
+
// the host and handler still validate the published arguments.
|
|
474
|
+
function canPreview(index: number) {
|
|
475
|
+
const suggestion = view?.generation?.suggestions[index];
|
|
476
|
+
if (!suggestion || !previewable(suggestion)) return false;
|
|
477
|
+
if (suggestion.disposition !== 'needs_review') return true;
|
|
478
|
+
const draft = drafts[draftKey(index)];
|
|
479
|
+
if (draft === undefined) return false;
|
|
480
|
+
if (suggestion.missingFields.length && !edited(index)) return false;
|
|
481
|
+
const handler = offeredHandler(suggestion);
|
|
482
|
+
return !handler || !missingRequired(handler.argsSchema, draft).length;
|
|
483
|
+
}
|
|
484
|
+
async function preview(index: number) {
|
|
121
485
|
const analysis = view?.analysis;
|
|
122
486
|
const suggestion = view?.generation?.suggestions[index];
|
|
123
|
-
if (!analysis || suggestion
|
|
487
|
+
if (pending || !analysis || !suggestion || !canPreview(index)) return;
|
|
488
|
+
let args: Record<string, unknown> | undefined;
|
|
489
|
+
const key = draftKey(index);
|
|
490
|
+
if (suggestion.disposition === 'needs_review' && edited(index)) {
|
|
491
|
+
try {
|
|
492
|
+
args = argumentsObject(drafts[key]);
|
|
493
|
+
} catch {
|
|
494
|
+
invalidDrafts[key] = true;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
124
498
|
const input = {
|
|
125
499
|
itemId,
|
|
126
500
|
attemptId: analysis.attemptId,
|
|
127
501
|
index,
|
|
128
502
|
requestId: crypto.randomUUID(),
|
|
503
|
+
...(args ? { args } : {}),
|
|
504
|
+
};
|
|
505
|
+
// Only the handler's validation refusal stays inline and keeps the view:
|
|
506
|
+
// a resolved `{ rejected: 'invalid' }` or a thrown error named
|
|
507
|
+
// `HandlerArgumentsInvalidError`. Anything else fails closed.
|
|
508
|
+
const ticket = ++epoch;
|
|
509
|
+
pending = true;
|
|
510
|
+
error = false;
|
|
511
|
+
previewRejected = undefined;
|
|
512
|
+
try {
|
|
513
|
+
const outcome = await host.preview(input);
|
|
514
|
+
if (!alive || ticket !== epoch) return;
|
|
515
|
+
if (outcome?.rejected === 'invalid') {
|
|
516
|
+
previewRejected = index;
|
|
517
|
+
pending = false;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// Only a successful preview of this card consumes its draft.
|
|
521
|
+
delete drafts[key];
|
|
522
|
+
delete draftBase[key];
|
|
523
|
+
delete invalidDrafts[key];
|
|
524
|
+
await load();
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (!alive || ticket !== epoch) return;
|
|
527
|
+
if (
|
|
528
|
+
(error as { name?: unknown } | null)?.name ===
|
|
529
|
+
'HandlerArgumentsInvalidError'
|
|
530
|
+
) {
|
|
531
|
+
previewRejected = index;
|
|
532
|
+
pending = false;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
deny();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function chooseFor(index: number) {
|
|
539
|
+
return (field: string, id: string) => {
|
|
540
|
+
const key = draftKey(index);
|
|
541
|
+
try {
|
|
542
|
+
drafts[key] = JSON.stringify(
|
|
543
|
+
{ ...argumentsObject(drafts[key] ?? '{}'), [field]: id },
|
|
544
|
+
null,
|
|
545
|
+
2,
|
|
546
|
+
);
|
|
547
|
+
} catch {
|
|
548
|
+
invalidDrafts[key] = true;
|
|
549
|
+
}
|
|
129
550
|
};
|
|
130
|
-
void run(() => host.preview(input));
|
|
131
551
|
}
|
|
132
552
|
function split(evidenceId: string) {
|
|
133
553
|
const analysis = view?.analysis;
|
|
@@ -149,6 +569,115 @@ function split(evidenceId: string) {
|
|
|
149
569
|
};
|
|
150
570
|
void run(() => host.split(input));
|
|
151
571
|
}
|
|
572
|
+
// Reviewer-chosen action and no-action disposition. Presentation only; the
|
|
573
|
+
// server previews, checks reviewer authority and requires separate approval.
|
|
574
|
+
let startHandler = $state(''),
|
|
575
|
+
startArgs = $state(''),
|
|
576
|
+
startReason = $state(''),
|
|
577
|
+
startInvalid = $state(false),
|
|
578
|
+
resolveReason = $state('');
|
|
579
|
+
const startEntry = $derived.by((): ProposalCatalogEntry | undefined =>
|
|
580
|
+
view?.catalog?.find(
|
|
581
|
+
(entry) =>
|
|
582
|
+
entry.kind === 'operation' &&
|
|
583
|
+
`${entry.id}@${entry.version}` === startHandler,
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
const startMissing = $derived.by((): string[] => {
|
|
587
|
+
if (!startEntry) return [];
|
|
588
|
+
try {
|
|
589
|
+
return missingArguments(startEntry, argumentsObject(startArgs));
|
|
590
|
+
} catch {
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
function draftStart() {
|
|
595
|
+
startInvalid = false;
|
|
596
|
+
const entry = view?.catalog?.find(
|
|
597
|
+
(candidate) => `${candidate.id}@${candidate.version}` === startHandler,
|
|
598
|
+
);
|
|
599
|
+
startArgs = entry
|
|
600
|
+
? JSON.stringify(
|
|
601
|
+
draftArguments(
|
|
602
|
+
entry,
|
|
603
|
+
view?.generation?.suggestions ?? [],
|
|
604
|
+
view?.evidence.map((part) => part.evidence.id) ?? [],
|
|
605
|
+
),
|
|
606
|
+
null,
|
|
607
|
+
2,
|
|
608
|
+
)
|
|
609
|
+
: '';
|
|
610
|
+
}
|
|
611
|
+
function startAction() {
|
|
612
|
+
const entry = startEntry;
|
|
613
|
+
if (!entry || !view?.analysis || !host.startAction) return;
|
|
614
|
+
let args: Record<string, unknown>;
|
|
615
|
+
try {
|
|
616
|
+
args = argumentsObject(startArgs);
|
|
617
|
+
} catch {
|
|
618
|
+
startInvalid = true;
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const input = {
|
|
622
|
+
itemId,
|
|
623
|
+
handlerId: entry.id,
|
|
624
|
+
handlerVersion: entry.version,
|
|
625
|
+
args,
|
|
626
|
+
requestId: crypto.randomUUID(),
|
|
627
|
+
...(startReason ? { reason: startReason } : {}),
|
|
628
|
+
};
|
|
629
|
+
const start = host.startAction;
|
|
630
|
+
void run(async () => {
|
|
631
|
+
await start(input);
|
|
632
|
+
startHandler = '';
|
|
633
|
+
startArgs = '';
|
|
634
|
+
startReason = '';
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function resolve() {
|
|
638
|
+
if (!host.resolve) return;
|
|
639
|
+
const input = {
|
|
640
|
+
itemId,
|
|
641
|
+
disposition: 'resolve_no_action' as const,
|
|
642
|
+
requestId: crypto.randomUUID(),
|
|
643
|
+
...(resolveReason ? { reason: resolveReason } : {}),
|
|
644
|
+
};
|
|
645
|
+
const close = host.resolve;
|
|
646
|
+
void run(async () => {
|
|
647
|
+
await close(input);
|
|
648
|
+
resolveReason = '';
|
|
649
|
+
}, 'resolve');
|
|
650
|
+
}
|
|
651
|
+
function instruct() {
|
|
652
|
+
const binding = instructionBinding(view);
|
|
653
|
+
const text = instruction.trim();
|
|
654
|
+
const operation = host.instruct;
|
|
655
|
+
if (!binding || !instructionValid || !operation || inFlight(view)) return;
|
|
656
|
+
instructionBusy = false;
|
|
657
|
+
invalidDraft = undefined;
|
|
658
|
+
unrecordedDraft = undefined;
|
|
659
|
+
const input = {
|
|
660
|
+
itemId,
|
|
661
|
+
...binding,
|
|
662
|
+
text,
|
|
663
|
+
requestId: crypto.randomUUID(),
|
|
664
|
+
};
|
|
665
|
+
// A stale result is a refresh-and-retry signal: run() reloads the view and
|
|
666
|
+
// the draft is kept; it is never treated as a denial.
|
|
667
|
+
void run(async () => {
|
|
668
|
+
const result = await operation(input);
|
|
669
|
+
if (result && 'stale' in result && result.stale === true) {
|
|
670
|
+
instructionBusy = true;
|
|
671
|
+
unrecordedDraft = instruction;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (result && 'invalid' in result && result.invalid === true) {
|
|
675
|
+
invalidDraft = instruction;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
instruction = '';
|
|
679
|
+
});
|
|
680
|
+
}
|
|
152
681
|
function locationLabel(location: EvidenceLocation) {
|
|
153
682
|
switch (location.kind) {
|
|
154
683
|
case 'page':
|
|
@@ -199,12 +728,25 @@ onMount(() => {
|
|
|
199
728
|
<header><h1>{t(M['ingestion.review'])}</h1><Button onclick={() => load()} disabled={pending}>{t(M['ingestion.refresh'])}</Button></header>
|
|
200
729
|
{#if pending}<p role="status">{t(M['ingestion.loading'])}</p>{/if}
|
|
201
730
|
{#if error}<p role="alert">{t(M['ingestion.error'])}</p>{/if}
|
|
731
|
+
{#if refreshFailed}<p role="status" data-testid="refresh-retrying">{t(M['ingestion.refreshRetrying'])}</p>{/if}
|
|
202
732
|
{#if view}
|
|
203
733
|
{#if view.availability !== 'available'}<p role="alert">{t(M[`ingestion.${view.availability}`])}</p>
|
|
204
734
|
{:else}
|
|
205
735
|
{@const extracted = view.analysis ? extractedContent(view.analysis.result.output) : []}
|
|
206
736
|
<h2>{view.entry.label}</h2><p>{t(M[`ingestion.${view.entry.state}`])}</p>
|
|
207
|
-
<div class="review-grid"><EvidenceViewer evidence={view.evidence} /><div class="details">
|
|
737
|
+
<div class="review-grid" class:stacked={layout === 'stacked'}><EvidenceViewer evidence={view.evidence} pages={observedPages(view)} layout={layout === 'stacked' ? 'pager' : 'stack'} bind:chosen={viewerPart} bind:page={viewerPages} /><div class="details">
|
|
738
|
+
{#if view.instruction}
|
|
739
|
+
{@const applied = view.instruction.applied}{@const current = view.instruction.current}
|
|
740
|
+
{#if inFlight(view) && current}<p role="status" data-testid="reinterpreting">{t(M['ingestion.reinterpreting'])}</p>{/if}
|
|
741
|
+
{#if view.instruction.failure === 'not_started'}<p role="alert" data-testid="instruction-failure">{t(M['ingestion.reinterpretNotStarted'])}</p>{:else if view.instruction.failure}<p role="alert" data-testid="instruction-failure">{t(M['ingestion.reinterpretFailed'])} ({view.instruction.failure})</p>{/if}
|
|
742
|
+
{#if applied}<p data-testid="applied-instruction">{t(M['ingestion.appliedInstruction'])}: <q>{applied.text}</q></p>{#if view.instruction.stale}<p role="status" data-testid="instruction-stale">{t(M['ingestion.instructionStale'])}</p>{/if}{/if}
|
|
743
|
+
{#if current && current.id !== applied?.id}<p data-testid="pending-instruction">{t(M['ingestion.pendingInstruction'])}: <q>{current.text}</q></p>{/if}
|
|
744
|
+
{#if view.instruction.dropped}<p role="status" data-testid="dropped-instruction">{t(M['ingestion.droppedInstruction'])}: <q>{view.instruction.dropped.text}</q></p>{/if}
|
|
745
|
+
{/if}
|
|
746
|
+
{#if instructionBusy}<p role="status" data-testid="instruction-busy">{t(M['ingestion.instructionBusy'])}</p>{:else if unrecordedDraft !== undefined && unrecordedDraft === instruction}<p role="status" data-testid="instruction-not-recorded">{t(M['ingestion.instructionNotRecorded'])}</p>{/if}
|
|
747
|
+
{#if host.instruct && !view.entry.item.disposition && instructionBinding(view)}
|
|
748
|
+
<div><Textarea aria-label={t(M['ingestion.instructions'])} aria-describedby="instruction-length" maxlength={INSTRUCTION_MAX_LENGTH} bind:value={instruction} /><p id="instruction-length" data-testid="instruction-length">{t(M['ingestion.instructionLength'], { count: instruction.length, max: INSTRUCTION_MAX_LENGTH })}</p>{#if invalidDraft !== undefined && invalidDraft === instruction}<p role="alert" data-testid="instruction-invalid">{t(M['ingestion.instructionInvalid'], { max: INSTRUCTION_MAX_LENGTH })}</p>{/if}<Button disabled={!instructionValid || inFlight(view)} onclick={instruct}>{t(M['ingestion.reinterpret'])}</Button></div>
|
|
749
|
+
{/if}
|
|
208
750
|
{#if extracted.length}
|
|
209
751
|
<h2>{t(M['ingestion.extracted'])}</h2>
|
|
210
752
|
{#each extracted as segment}
|
|
@@ -224,15 +766,55 @@ onMount(() => {
|
|
|
224
766
|
{#if suggestion.alternatives.length}<h4>{t(M['ingestion.alternatives'])}</h4><ul>{#each suggestion.alternatives as alternative}<li>{alternative}</li>{/each}</ul>{/if}
|
|
225
767
|
{#if suggestion.missingFields.length}<h4>{t(M['ingestion.missing'])}</h4><ul>{#each suggestion.missingFields as missing}<li>{missing}</li>{/each}</ul>{/if}
|
|
226
768
|
<ul>{#each suggestion.evidence as reference}{@const url = locationUrl(reference.evidenceId,reference.location)}<li>{#if url}<Button href={url} target="_blank" rel="noopener noreferrer">{locationLabel(reference.location)}</Button>{:else}{locationLabel(reference.location)}{/if}</li>{/each}</ul>
|
|
227
|
-
|
|
769
|
+
{#if !view.entry.item.disposition}
|
|
770
|
+
{#if suggestion.disposition === 'needs_review' && previewable(suggestion)}
|
|
771
|
+
{@const handler = offeredHandler(suggestion)}
|
|
772
|
+
{@const key = draftKey(index)}
|
|
773
|
+
<p>{t(M['ingestion.completeSuggestion'])}</p>
|
|
774
|
+
<Textarea aria-label={t(M['ingestion.suggestionArgs'])} bind:value={drafts[key]} rows={6} />
|
|
775
|
+
{#if handler}<CandidatePicker {host} {itemId} catalog={handler} {deny} onchoose={chooseFor(index)} />{/if}
|
|
776
|
+
{#if edited(index)}<p>{t(M['ingestion.reviewAgain'])}</p>{/if}
|
|
777
|
+
{#if invalidDrafts[key] && !parseable(drafts[key])}<p role="alert">{t(M['ingestion.invalid'])}</p>{/if}
|
|
778
|
+
{/if}
|
|
779
|
+
{#if previewRejected === index}<p role="alert">{t(M['ingestion.previewRejected'])}</p>{/if}
|
|
780
|
+
<Button disabled={!canPreview(index)} onclick={() => preview(index)}>{t(M['ingestion.preview'])}</Button>
|
|
781
|
+
{/if}
|
|
228
782
|
</article>
|
|
229
783
|
{/each}
|
|
230
|
-
{#each generation.splits as proposed (proposed.evidenceId)}<div><Textarea aria-label={`${t(M['ingestion.split'])}: ${proposed.evidenceId}`} bind:value={splits[proposed.evidenceId]} /><Button disabled={!view.analysis} onclick={() => split(proposed.evidenceId)}>{t(M['ingestion.saveSplit'])}</Button></div>{/each}
|
|
784
|
+
{#if !view.entry.item.disposition}{#each generation.splits as proposed (proposed.evidenceId)}<div><Textarea aria-label={`${t(M['ingestion.split'])}: ${proposed.evidenceId}`} bind:value={splits[proposed.evidenceId]} /><Button disabled={!view.analysis} onclick={() => split(proposed.evidenceId)}>{t(M['ingestion.saveSplit'])}</Button></div>{/each}{/if}
|
|
231
785
|
{#if invalid}<p role="alert">{t(M['ingestion.invalid'])}</p>{/if}
|
|
232
786
|
{/if}
|
|
787
|
+
{#if view.entry.item.disposition}
|
|
788
|
+
{#if view.disposition?.reviewer}
|
|
789
|
+
<p data-testid="disposition">{t(M['ingestion.resolvedBy'], { reviewer: view.disposition.reviewer })}{#if view.disposition.reason}: {view.disposition.reason}{/if}</p>
|
|
790
|
+
{:else}
|
|
791
|
+
<p data-testid="disposition">{t(M['ingestion.resolved'])}</p>
|
|
792
|
+
{/if}
|
|
793
|
+
{:else}
|
|
794
|
+
{#if host.startAction && view.catalog?.some((entry) => entry.kind === 'operation')}
|
|
795
|
+
<fieldset class="start"><legend>{t(M['ingestion.startAs'])}</legend>
|
|
796
|
+
<Select aria-label={t(M['ingestion.startHandler'])} bind:value={startHandler} onchange={draftStart} disabled={pending}><option value="">{t(M['ingestion.startHandler'])}</option>{#each view.catalog as entry (`${entry.id}@${entry.version}`)}{#if entry.kind === 'operation'}<option value={`${entry.id}@${entry.version}`}>{entry.id}</option>{/if}{/each}</Select>
|
|
797
|
+
{#if startEntry}
|
|
798
|
+
<p>{startEntry.description}</p>
|
|
799
|
+
<Textarea aria-label={t(M['ingestion.startArgs'])} bind:value={startArgs} rows={8} />
|
|
800
|
+
{#if startMissing.length}<h4>{t(M['ingestion.missing'])}</h4><ul>{#each startMissing as missing}<li>{missing}</li>{/each}</ul>{/if}
|
|
801
|
+
<Input aria-label={t(M['ingestion.reason'])} bind:value={startReason} />
|
|
802
|
+
<p>{t(M['ingestion.startHelp'])}</p>
|
|
803
|
+
{#if startInvalid}<p role="alert">{t(M['ingestion.invalid'])}</p>{/if}
|
|
804
|
+
<Button disabled={pending || !view.analysis} onclick={startAction}>{t(M['ingestion.start'])}</Button>
|
|
805
|
+
{/if}
|
|
806
|
+
</fieldset>
|
|
807
|
+
{/if}
|
|
808
|
+
{#if host.resolve}
|
|
809
|
+
<fieldset class="resolve"><legend>{t(M['ingestion.resolveNoAction'])}</legend>
|
|
810
|
+
<Input aria-label={t(M['ingestion.resolveReason'])} bind:value={resolveReason} disabled={pending} />
|
|
811
|
+
<Button disabled={pending} onclick={resolve}>{t(M['ingestion.resolveNoAction'])}</Button>
|
|
812
|
+
</fieldset>
|
|
813
|
+
{/if}
|
|
814
|
+
{/if}
|
|
233
815
|
{#if !view.reviews.actions.length}<p>{t(M['ingestion.noReviews'])}</p>{/if}
|
|
234
816
|
{#each view.reviews.actions as action (`${action.review.actionId}:${action.review.revision}:${action.review.reviewVersion}`)}
|
|
235
|
-
<ActionReview {host} {itemId} {action} {run} {deny} currentAttemptId={action.stalePlan && view.reviews.actions.find((entry) => entry.stalePlan?.id === action.stalePlan?.id) === action ? action.attemptId : undefined} catalog={view.generation?.offered.find((entry) => entry.handler.id === action.handlerId && entry.handler.version === action.handlerVersion)?.handler} />
|
|
817
|
+
<ActionReview {host} {itemId} {action} {run} {deny} ondirty={(edited) => { actionDirty[action.review.actionId] = edited; }} resolved={!!view.entry.item.disposition} currentAttemptId={action.stalePlan && view.reviews.actions.find((entry) => entry.stalePlan?.id === action.stalePlan?.id) === action ? action.attemptId : undefined} catalog={view.generation?.offered.find((entry) => entry.handler.id === action.handlerId && entry.handler.version === action.handlerVersion)?.handler} />
|
|
236
818
|
{/each}
|
|
237
819
|
{#if view.reviews.nextCursor}<Button onclick={() => load(loadedPages + 1)}>{t(M['ingestion.more'])}</Button>{/if}
|
|
238
820
|
</div></div>
|
|
@@ -241,6 +823,6 @@ onMount(() => {
|
|
|
241
823
|
<style>
|
|
242
824
|
@layer smrt.reset, smrt.structure, smrt.theme;
|
|
243
825
|
@layer smrt.theme {
|
|
244
|
-
section{overflow-wrap:anywhere;max-width:90rem;margin-inline:auto;padding:1rem;color:var(--smrt-color-on-surface)}header{display:flex;align-items:center;justify-content:space-between;gap:1rem}.review-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1.5rem}.details,article{min-width:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:24rem;overflow:auto}article{padding-block:1rem;border-block-end:var(--smrt-hairline)}@media(max-width:48rem){.review-grid{grid-template-columns:minmax(0,1fr)}header{flex-wrap:wrap}}
|
|
826
|
+
section{overflow-wrap:anywhere;max-width:90rem;margin-inline:auto;padding:1rem;color:var(--smrt-color-on-surface)}header{display:flex;align-items:center;justify-content:space-between;gap:1rem}.review-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1.5rem}.review-grid.stacked{grid-template-columns:minmax(0,1fr)}.details,article{min-width:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:24rem;overflow:auto}article{padding-block:1rem;border-block-end:var(--smrt-hairline)}fieldset{margin-block:1rem;min-width:0;display:grid;gap:.5rem}@media(max-width:48rem){.review-grid{grid-template-columns:minmax(0,1fr)}header{flex-wrap:wrap}}
|
|
245
827
|
}
|
|
246
828
|
</style>
|