@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.
Files changed (115) hide show
  1. package/FEEDBACK.md +137 -0
  2. package/PROPOSALS.md +146 -5
  3. package/README.md +168 -2
  4. package/REVIEW.md +611 -7
  5. package/agents/execution.md +87 -0
  6. package/dist/chunks/{execution-NoDh3pjs.js → execution-BzVQax0n.js} +528 -42
  7. package/dist/chunks/execution-BzVQax0n.js.map +1 -0
  8. package/dist/chunks/{feedback-CJB900iG.js → feedback-D3UTF8fm.js} +175 -36
  9. package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
  10. package/dist/chunks/{proposal-errors-VxcTU6Db.js → proposal-contracts-77Uo1B1j.js} +38 -44
  11. package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
  12. package/dist/chunks/reviewer-instruction-CEc3AOuU.js +33 -0
  13. package/dist/chunks/reviewer-instruction-CEc3AOuU.js.map +1 -0
  14. package/dist/dto.d.ts +75 -0
  15. package/dist/dto.d.ts.map +1 -1
  16. package/dist/execution-contracts.d.ts +4 -1
  17. package/dist/execution-contracts.d.ts.map +1 -1
  18. package/dist/execution-dto.d.ts +16 -0
  19. package/dist/execution-dto.d.ts.map +1 -1
  20. package/dist/execution-internal.d.ts +2 -0
  21. package/dist/execution-internal.d.ts.map +1 -1
  22. package/dist/execution.d.ts +101 -4
  23. package/dist/execution.d.ts.map +1 -1
  24. package/dist/execution.js +2 -2
  25. package/dist/feedback-dto.d.ts +18 -4
  26. package/dist/feedback-dto.d.ts.map +1 -1
  27. package/dist/feedback-internal.d.ts +9 -0
  28. package/dist/feedback-internal.d.ts.map +1 -1
  29. package/dist/feedback.d.ts +10 -0
  30. package/dist/feedback.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/manifest.json +339 -3
  33. package/dist/models.d.ts +15 -0
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/models.js +82 -2
  36. package/dist/models.js.map +1 -1
  37. package/dist/policy.d.ts +3 -0
  38. package/dist/policy.d.ts.map +1 -1
  39. package/dist/presented-filename.d.ts +11 -0
  40. package/dist/presented-filename.d.ts.map +1 -0
  41. package/dist/proposal-contracts.d.ts +31 -6
  42. package/dist/proposal-contracts.d.ts.map +1 -1
  43. package/dist/proposal-dto.d.ts +17 -0
  44. package/dist/proposal-dto.d.ts.map +1 -1
  45. package/dist/proposal-errors.d.ts +20 -0
  46. package/dist/proposal-errors.d.ts.map +1 -1
  47. package/dist/proposal-validation.d.ts +26 -0
  48. package/dist/proposal-validation.d.ts.map +1 -1
  49. package/dist/proposals.d.ts +6 -1
  50. package/dist/proposals.d.ts.map +1 -1
  51. package/dist/proposals.js +268 -71
  52. package/dist/proposals.js.map +1 -1
  53. package/dist/redaction.d.ts +9 -0
  54. package/dist/redaction.d.ts.map +1 -0
  55. package/dist/review-dto.d.ts +108 -5
  56. package/dist/review-dto.d.ts.map +1 -1
  57. package/dist/review-host.d.ts +52 -3
  58. package/dist/review-host.d.ts.map +1 -1
  59. package/dist/review-instruction-view.d.ts +12 -0
  60. package/dist/review-instruction-view.d.ts.map +1 -0
  61. package/dist/review-summary.d.ts.map +1 -1
  62. package/dist/reviewer-instruction.d.ts +20 -0
  63. package/dist/reviewer-instruction.d.ts.map +1 -0
  64. package/dist/server.d.ts +252 -13
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +1190 -85
  67. package/dist/server.js.map +1 -1
  68. package/dist/smrt-knowledge.json +137 -23
  69. package/dist/sources/common.d.ts +11 -0
  70. package/dist/sources/common.d.ts.map +1 -1
  71. package/dist/sources/email.d.ts.map +1 -1
  72. package/dist/sources/http.d.ts.map +1 -1
  73. package/dist/sources/watch-folder.d.ts.map +1 -1
  74. package/dist/svelte/components/ActionReview.svelte +54 -55
  75. package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
  76. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  77. package/dist/svelte/components/CandidatePicker.svelte +72 -0
  78. package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
  79. package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
  80. package/dist/svelte/components/EvidenceViewer.svelte +60 -4
  81. package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
  82. package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
  83. package/dist/svelte/components/InboxView.svelte +172 -19
  84. package/dist/svelte/components/InboxView.svelte.d.ts +36 -2
  85. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  86. package/dist/svelte/components/IntakeInbox.svelte +6 -3
  87. package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
  88. package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
  89. package/dist/svelte/components/IntakeReview.svelte +4 -2
  90. package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
  91. package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
  92. package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
  93. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
  94. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
  95. package/dist/svelte/components/ReviewView.svelte +653 -71
  96. package/dist/svelte/components/ReviewView.svelte.d.ts +20 -1
  97. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  98. package/dist/svelte/components/TriageView.svelte +669 -0
  99. package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
  100. package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
  101. package/dist/svelte/i18n.d.ts +56 -0
  102. package/dist/svelte/i18n.d.ts.map +1 -1
  103. package/dist/svelte/i18n.js +56 -0
  104. package/dist/svelte/index.d.ts +2 -1
  105. package/dist/svelte/index.d.ts.map +1 -1
  106. package/dist/svelte/index.js +1 -0
  107. package/dist/svelte/view-utils.d.ts +67 -0
  108. package/dist/svelte/view-utils.d.ts.map +1 -1
  109. package/dist/svelte/view-utils.js +157 -0
  110. package/package.json +13 -13
  111. package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
  112. package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
  113. package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
  114. package/dist/chunks/feedback-CJB900iG.js.map +0 -1
  115. 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 { evidenceUrl, extractedContent, pageGroups } from '../view-utils.js';
10
- import ActionReview from './ActionReview.svelte';
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 { host, itemId }: Props = $props();
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
- async function load(pageCount = loadedPages) {
38
- const ticket = ++epoch;
39
- view = undefined;
40
- splits = {};
41
- pending = true;
42
- error = false;
43
- invalid = false;
44
- try {
45
- let cursor: string | undefined;
46
- let result: ItemReviewView | undefined;
47
- const actions: ItemReviewView['reviews']['actions'] = [];
48
- const actionIds = new Set<string>();
49
- const cursors = new Set<string>();
50
- let refreshedPages = 0;
51
- for (let index = 0; index < pageCount; index++) {
52
- const page = await host.load(itemId, cursor);
53
- if (!alive || ticket !== epoch) return;
54
- if (page.availability !== 'available') {
55
- result = page;
56
- break;
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 (!result) throw new Error('Missing review page');
77
- if (result.availability === 'available') {
78
- result = {
79
- ...result,
80
- reviews: { ...result.reviews, actions, nextCursor: cursor },
81
- };
82
- loadedPages = refreshedPages;
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
- // A host denial must not leave any original, candidate, or argument in the DOM.
85
- view =
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
- splits = Object.fromEntries(
95
- (view.generation?.splits ?? []).map((split) => [
96
- split.evidenceId,
97
- JSON.stringify(split.groups),
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) pending = false;
360
+ if (alive && ticket === epoch) {
361
+ pending = false;
362
+ schedulePoll(ticket);
363
+ }
104
364
  }
105
365
  }
106
- async function run(operation: () => Promise<unknown>) {
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 && ticket === epoch) await load();
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 preview(index: number) {
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?.disposition !== 'ready_for_review') return;
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
- <Button disabled={suggestion.disposition !== 'ready_for_review' || !view.analysis} onclick={() => preview(index)}>{t(M['ingestion.preview'])}</Button>
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>