@happyvertical/smrt-ingestion 0.57.20 → 0.57.21

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 (92) hide show
  1. package/FEEDBACK.md +33 -0
  2. package/PROPOSALS.md +127 -5
  3. package/README.md +42 -2
  4. package/REVIEW.md +339 -3
  5. package/agents/execution.md +15 -0
  6. package/dist/chunks/{execution-NoDh3pjs.js → execution-9CfjxX8I.js} +110 -22
  7. package/dist/chunks/execution-9CfjxX8I.js.map +1 -0
  8. package/dist/chunks/{proposal-errors-VxcTU6Db.js → extraction-CJ-zrbg0.js} +4 -10
  9. package/dist/chunks/extraction-CJ-zrbg0.js.map +1 -0
  10. package/dist/chunks/{feedback-CJB900iG.js → feedback-CuUzWKzP.js} +59 -18
  11. package/dist/chunks/feedback-CuUzWKzP.js.map +1 -0
  12. package/dist/chunks/reviewer-instruction-Db3jDN9a.js +33 -0
  13. package/dist/chunks/reviewer-instruction-Db3jDN9a.js.map +1 -0
  14. package/dist/dto.d.ts +6 -0
  15. package/dist/dto.d.ts.map +1 -1
  16. package/dist/execution-internal.d.ts +2 -0
  17. package/dist/execution-internal.d.ts.map +1 -1
  18. package/dist/execution.d.ts +43 -1
  19. package/dist/execution.d.ts.map +1 -1
  20. package/dist/execution.js +1 -1
  21. package/dist/feedback-dto.d.ts +6 -0
  22. package/dist/feedback-dto.d.ts.map +1 -1
  23. package/dist/feedback.d.ts +7 -0
  24. package/dist/feedback.d.ts.map +1 -1
  25. package/dist/manifest.json +19 -1
  26. package/dist/models.d.ts.map +1 -1
  27. package/dist/models.js +10 -1
  28. package/dist/models.js.map +1 -1
  29. package/dist/presented-filename.d.ts +11 -0
  30. package/dist/presented-filename.d.ts.map +1 -0
  31. package/dist/proposal-contracts.d.ts +31 -6
  32. package/dist/proposal-contracts.d.ts.map +1 -1
  33. package/dist/proposal-dto.d.ts +17 -0
  34. package/dist/proposal-dto.d.ts.map +1 -1
  35. package/dist/proposal-errors.d.ts +16 -0
  36. package/dist/proposal-errors.d.ts.map +1 -1
  37. package/dist/proposal-validation.d.ts +26 -0
  38. package/dist/proposal-validation.d.ts.map +1 -1
  39. package/dist/proposals.d.ts +5 -1
  40. package/dist/proposals.d.ts.map +1 -1
  41. package/dist/proposals.js +261 -69
  42. package/dist/proposals.js.map +1 -1
  43. package/dist/review-dto.d.ts +95 -2
  44. package/dist/review-dto.d.ts.map +1 -1
  45. package/dist/review-host.d.ts +52 -3
  46. package/dist/review-host.d.ts.map +1 -1
  47. package/dist/review-instruction-view.d.ts +12 -0
  48. package/dist/review-instruction-view.d.ts.map +1 -0
  49. package/dist/review-summary.d.ts.map +1 -1
  50. package/dist/reviewer-instruction.d.ts +20 -0
  51. package/dist/reviewer-instruction.d.ts.map +1 -0
  52. package/dist/server.d.ts +171 -4
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +835 -49
  55. package/dist/server.js.map +1 -1
  56. package/dist/smrt-knowledge.json +29 -21
  57. package/dist/sources/common.d.ts +11 -0
  58. package/dist/sources/common.d.ts.map +1 -1
  59. package/dist/sources/email.d.ts.map +1 -1
  60. package/dist/sources/http.d.ts.map +1 -1
  61. package/dist/sources/watch-folder.d.ts.map +1 -1
  62. package/dist/svelte/components/ActionReview.svelte +15 -46
  63. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  64. package/dist/svelte/components/CandidatePicker.svelte +72 -0
  65. package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
  66. package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
  67. package/dist/svelte/components/InboxView.svelte +14 -3
  68. package/dist/svelte/components/InboxView.svelte.d.ts +8 -1
  69. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  70. package/dist/svelte/components/IntakeInbox.svelte +6 -3
  71. package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
  72. package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
  73. package/dist/svelte/components/IntakeReview.svelte +4 -2
  74. package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
  75. package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
  76. package/dist/svelte/components/ReviewView.svelte +403 -62
  77. package/dist/svelte/components/ReviewView.svelte.d.ts +2 -0
  78. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  79. package/dist/svelte/i18n.d.ts +17 -0
  80. package/dist/svelte/i18n.d.ts.map +1 -1
  81. package/dist/svelte/i18n.js +17 -0
  82. package/dist/svelte/index.d.ts +1 -1
  83. package/dist/svelte/index.d.ts.map +1 -1
  84. package/dist/svelte/view-utils.d.ts +2 -0
  85. package/dist/svelte/view-utils.d.ts.map +1 -1
  86. package/dist/svelte/view-utils.js +12 -0
  87. package/package.json +13 -13
  88. package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
  89. package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
  90. package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
  91. package/dist/chunks/feedback-CJB900iG.js.map +0 -1
  92. package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
@@ -4,85 +4,164 @@ import { useI18n } from '@happyvertical/smrt-ui/i18n';
4
4
  import { Button } from '@happyvertical/smrt-ui/ui';
5
5
  import { onDestroy, onMount } from 'svelte';
6
6
  import type { EvidenceLocation } from '../../extraction-types.js';
7
+ import type { GeneratedSuggestion } from '../../proposal-dto.js';
7
8
  import type { IntakeReviewHost, ItemReviewView } from '../../review-dto.js';
8
9
  import { M } from '../i18n.js';
9
- import { evidenceUrl, extractedContent, pageGroups } from '../view-utils.js';
10
+ import {
11
+ argumentsObject,
12
+ evidenceUrl,
13
+ extractedContent,
14
+ missingRequired,
15
+ pageGroups,
16
+ } from '../view-utils.js';
10
17
  import ActionReview from './ActionReview.svelte';
18
+ import CandidatePicker from './CandidatePicker.svelte';
11
19
  import EvidenceViewer from './EvidenceViewer.svelte';
12
20
  export interface Props {
13
21
  host: IntakeReviewHost;
14
22
  itemId: string;
23
+ /** Reload interval while the host reports the item as processing. */
24
+ pollMs?: number;
15
25
  }
16
- let { host, itemId }: Props = $props();
26
+ let { host, itemId, pollMs = 2000 }: Props = $props();
17
27
  const { t } = useI18n();
18
28
  let view = $state<ItemReviewView>(),
19
29
  pending = $state(false),
20
30
  error = $state(false),
21
31
  splits = $state<Record<string, string>>({}),
32
+ instruction = $state(''),
33
+ refreshFailed = $state(false),
34
+ instructionBusy = $state(false),
35
+ // The draft the server refused as invalid; the note stays until it changes.
36
+ invalidDraft = $state<string>(),
37
+ // The draft a stale result left unrecorded; the note stays until the
38
+ // reviewer edits or resubmits it.
39
+ unrecordedDraft = $state<string>(),
40
+ // Human drafts for `needs_review` suggestions, keyed by `attemptId:index`.
41
+ // They survive refresh, failures and actions on other cards and are cleared
42
+ // only by a successful preview of their own card.
43
+ drafts = $state<Record<string, string>>({}),
44
+ // Drafts the reviewer tried to preview while unparsable; the alert clears
45
+ // as soon as the draft parses again.
46
+ invalidDrafts = $state<Record<string, true>>({}),
47
+ // Suggestion whose last preview the host rejected; its draft is kept.
48
+ previewRejected = $state<number>(),
22
49
  invalid = $state(false);
50
+ /** Same ceiling the server enforces (REVIEWER_INSTRUCTION_MAX_LENGTH). */
51
+ const INSTRUCTION_MAX_LENGTH = 4000;
52
+ const instructionValid = $derived(
53
+ !!instruction.trim() && instruction.length <= INSTRUCTION_MAX_LENGTH,
54
+ );
55
+ // The busy note after a stale click stays only while the item is still in
56
+ // flight; any reload (including the one answering the click) that shows it
57
+ // settled clears it.
58
+ function settleBusy() {
59
+ if (instructionBusy && !inFlight(view)) instructionBusy = false;
60
+ }
61
+ /** Consecutive thrown poll ticks tolerated before failing closed. */
62
+ const MAX_POLL_FAILURES = 3;
23
63
  let alive = true,
24
64
  epoch = 0,
25
- loadedPages = 1;
65
+ loadedPages = 1,
66
+ poll: ReturnType<typeof setTimeout> | undefined,
67
+ pollTicks = 0,
68
+ pollFailures = 0;
26
69
  onDestroy(() => {
27
70
  alive = false;
28
71
  epoch++;
72
+ clearTimeout(poll);
29
73
  });
74
+ function inFlight(current: ItemReviewView | undefined) {
75
+ return (
76
+ current?.availability === 'available' &&
77
+ (current.entry.state === 'processing' ||
78
+ ['queued', 'running'].includes(current.entry.item.processingState) ||
79
+ // The server's own predicate, which also covers the gap between a
80
+ // completed extraction and its not-yet-appended interpretation.
81
+ !!current.instruction?.reinterpreting)
82
+ );
83
+ }
84
+ /** Prefer the server's retry binding; otherwise the displayed generation. */
85
+ function instructionBinding(current: ItemReviewView | undefined) {
86
+ if (current?.instruction?.binding) return current.instruction.binding;
87
+ return current?.generation && current.analysis
88
+ ? {
89
+ attemptId: current.analysis.attemptId,
90
+ expectedRevision: current.analysis.revision,
91
+ }
92
+ : undefined;
93
+ }
94
+ // Background stages (reinterpretation after an instruction) finish without a
95
+ // push channel; the authenticated load is repeated and patched in place so
96
+ // drafts, focus and the evidence viewer survive. The delay backs off to 5x.
97
+ function schedulePoll(ticket: number) {
98
+ clearTimeout(poll);
99
+ if (!inFlight(view)) {
100
+ pollTicks = 0;
101
+ return;
102
+ }
103
+ const delay = Math.min(pollMs * 1.5 ** pollTicks, pollMs * 5);
104
+ pollTicks++;
105
+ poll = setTimeout(() => {
106
+ if (alive && ticket === epoch && !pending) void refresh(ticket);
107
+ }, delay);
108
+ }
30
109
  function deny() {
31
110
  epoch++;
111
+ clearTimeout(poll);
32
112
  view = undefined;
33
113
  splits = {};
114
+ // Drafts are the reviewer's own typed text, never host data: they are kept
115
+ // in memory (nothing renders while the view is denied) so a failed refresh
116
+ // or a failed action on another card does not discard them.
34
117
  error = true;
35
118
  pending = false;
36
119
  }
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);
120
+ /** Aggregate the displayed pages with fresh cursors; returns undefined when fenced. */
121
+ async function fetchView(
122
+ ticket: number,
123
+ pageCount: number,
124
+ ): Promise<{ result: ItemReviewView; pages: number } | undefined> {
125
+ let cursor: string | undefined;
126
+ let result: ItemReviewView | undefined;
127
+ const actions: ItemReviewView['reviews']['actions'] = [];
128
+ const actionIds = new Set<string>();
129
+ const cursors = new Set<string>();
130
+ let refreshedPages = 0;
131
+ for (let index = 0; index < pageCount; index++) {
132
+ const page = await host.load(itemId, cursor);
133
+ if (!alive || ticket !== epoch) return undefined;
134
+ if (page.availability !== 'available') {
135
+ result = page;
136
+ break;
75
137
  }
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;
138
+ if (
139
+ result &&
140
+ page.entry.item.analysisRevision !== result.entry.item.analysisRevision
141
+ )
142
+ throw new Error('Review changed during pagination');
143
+ result ??= page;
144
+ for (const action of page.reviews.actions) {
145
+ if (!actionIds.has(action.review.actionId)) {
146
+ actionIds.add(action.review.actionId);
147
+ actions.push(action);
148
+ }
83
149
  }
84
- // A host denial must not leave any original, candidate, or argument in the DOM.
85
- view =
150
+ refreshedPages++;
151
+ cursor = page.reviews.nextCursor;
152
+ if (!cursor) break;
153
+ if (cursors.has(cursor)) throw new Error('Repeated review cursor');
154
+ cursors.add(cursor);
155
+ }
156
+ if (!result) throw new Error('Missing review page');
157
+ if (result.availability === 'available')
158
+ result = {
159
+ ...result,
160
+ reviews: { ...result.reviews, actions, nextCursor: cursor },
161
+ };
162
+ // A host denial must not leave any original, candidate, or argument in the DOM.
163
+ return {
164
+ result:
86
165
  result.availability === 'available'
87
166
  ? result
88
167
  : {
@@ -90,22 +169,128 @@ async function load(pageCount = loadedPages) {
90
169
  evidence: [],
91
170
  reviews: { actions: [] },
92
171
  availability: result.availability,
93
- };
94
- splits = Object.fromEntries(
95
- (view.generation?.splits ?? []).map((split) => [
96
- split.evidenceId,
97
- JSON.stringify(split.groups),
98
- ]),
99
- );
172
+ },
173
+ pages: refreshedPages,
174
+ };
175
+ }
176
+ /** Keep only drafts the reviewer edited; untouched ones follow the new generation. */
177
+ function splitDrafts(
178
+ next: ItemReviewView,
179
+ keep: Record<string, string>,
180
+ previous?: ItemReviewView,
181
+ ) {
182
+ const generated = new Map(
183
+ (previous?.generation?.splits ?? []).map((split) => [
184
+ split.evidenceId,
185
+ JSON.stringify(split.groups),
186
+ ]),
187
+ );
188
+ return Object.fromEntries(
189
+ (next.generation?.splits ?? []).map((split) => {
190
+ const draft = keep[split.evidenceId];
191
+ const edited =
192
+ draft !== undefined && draft !== generated.get(split.evidenceId);
193
+ return [split.evidenceId, edited ? draft : JSON.stringify(split.groups)];
194
+ }),
195
+ );
196
+ }
197
+ function syncSuggestionDrafts(current: ItemReviewView) {
198
+ // Drafts follow availability: revoked or expired access discards them;
199
+ // a transient `unavailable` or `stale` result keeps them (nothing renders
200
+ // meanwhile) so the reviewer's typing survives until the view returns;
201
+ // an available view prunes drafts of attempts or cards no longer offered.
202
+ if (
203
+ current.availability === 'revoked' ||
204
+ current.availability === 'expired'
205
+ ) {
206
+ drafts = {};
207
+ invalidDrafts = {};
208
+ } else if (current.availability === 'available') {
209
+ const attemptId = current.analysis?.attemptId;
210
+ const keys = new Set<string>();
211
+ (current.generation?.suggestions ?? []).forEach((suggestion, index) => {
212
+ if (!attemptId || suggestion.disposition !== 'needs_review') return;
213
+ const key = `${attemptId}:${index}`;
214
+ keys.add(key);
215
+ drafts[key] ??= JSON.stringify(suggestion.args, null, 2);
216
+ });
217
+ for (const key of Object.keys(drafts))
218
+ if (!keys.has(key)) {
219
+ delete drafts[key];
220
+ delete invalidDrafts[key];
221
+ }
222
+ }
223
+ }
224
+ async function load(pageCount = loadedPages) {
225
+ const ticket = ++epoch;
226
+ clearTimeout(poll);
227
+ pollTicks = 0;
228
+ pollFailures = 0;
229
+ refreshFailed = false;
230
+ view = undefined;
231
+ splits = {};
232
+ previewRejected = undefined;
233
+ pending = true;
234
+ error = false;
235
+ invalid = false;
236
+ try {
237
+ const fetched = await fetchView(ticket, pageCount);
238
+ if (!fetched) return;
239
+ if (fetched.result.availability === 'available')
240
+ loadedPages = fetched.pages;
241
+ view = fetched.result;
242
+ syncSuggestionDrafts(view);
243
+ splits = splitDrafts(view, {});
244
+ settleBusy();
100
245
  } catch {
101
246
  if (alive && ticket === epoch) deny();
102
247
  } finally {
103
- if (alive && ticket === epoch) pending = false;
248
+ if (alive && ticket === epoch) {
249
+ pending = false;
250
+ schedulePoll(ticket);
251
+ }
252
+ }
253
+ }
254
+ /** Poll tick: patch the view in place without blanking it or resetting drafts. */
255
+ async function refresh(ticket: number) {
256
+ try {
257
+ const fetched = await fetchView(ticket, loadedPages);
258
+ if (!fetched || !alive || ticket !== epoch) return;
259
+ pollFailures = 0;
260
+ refreshFailed = false;
261
+ // An explicit host denial fails closed at once.
262
+ if (fetched.result.availability !== 'available') {
263
+ view = fetched.result;
264
+ syncSuggestionDrafts(view);
265
+ splits = {};
266
+ return;
267
+ }
268
+ if (JSON.stringify(fetched.result) !== JSON.stringify(view)) {
269
+ loadedPages = fetched.pages;
270
+ splits = splitDrafts(fetched.result, splits, view);
271
+ view = fetched.result;
272
+ syncSuggestionDrafts(view);
273
+ }
274
+ settleBusy();
275
+ } catch {
276
+ if (!alive || ticket !== epoch) return;
277
+ // A thrown tick may be transient: keep the view and keep backing off, but
278
+ // a host may also throw for revoked access, so fail closed after a bound.
279
+ pollFailures++;
280
+ if (pollFailures >= MAX_POLL_FAILURES) {
281
+ refreshFailed = false;
282
+ deny();
283
+ return;
284
+ }
285
+ refreshFailed = true;
286
+ } finally {
287
+ if (alive && ticket === epoch && !pending) schedulePoll(ticket);
104
288
  }
105
289
  }
106
290
  async function run(operation: () => Promise<unknown>) {
107
291
  if (pending) return;
108
292
  const ticket = ++epoch;
293
+ clearTimeout(poll);
109
294
  pending = true;
110
295
  error = false;
111
296
  view = undefined;
@@ -117,17 +302,120 @@ async function run(operation: () => Promise<unknown>) {
117
302
  if (alive && ticket === epoch) deny();
118
303
  }
119
304
  }
120
- function preview(index: number) {
305
+ function offeredHandler(suggestion: GeneratedSuggestion) {
306
+ return view?.generation?.offered.find(
307
+ (entry) =>
308
+ entry.handler.id === suggestion.handlerId &&
309
+ entry.handler.version === suggestion.handlerVersion,
310
+ )?.handler;
311
+ }
312
+ // Presentation only: the host rechecks stage, outcome, handler and authority.
313
+ function previewable(suggestion: GeneratedSuggestion) {
314
+ return (
315
+ !!view?.analysis &&
316
+ view.generation?.outcome !== 'provider_error' &&
317
+ ['ready_for_review', 'needs_review'].includes(suggestion.disposition)
318
+ );
319
+ }
320
+ function draftKey(index: number) {
321
+ return `${view?.analysis?.attemptId ?? ''}:${index}`;
322
+ }
323
+ function parseable(draft: string | undefined) {
324
+ if (draft === undefined) return false;
325
+ try {
326
+ argumentsObject(draft);
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+ function edited(index: number) {
333
+ const suggestion = view?.generation?.suggestions[index];
334
+ const draft = drafts[draftKey(index)];
335
+ return (
336
+ !!suggestion &&
337
+ draft !== undefined &&
338
+ draft !== JSON.stringify(suggestion.args, null, 2)
339
+ );
340
+ }
341
+ // Every schema-required argument must be present in the draft before preview;
342
+ // the host and handler still validate the published arguments.
343
+ function canPreview(index: number) {
344
+ const suggestion = view?.generation?.suggestions[index];
345
+ if (!suggestion || !previewable(suggestion)) return false;
346
+ if (suggestion.disposition !== 'needs_review') return true;
347
+ const draft = drafts[draftKey(index)];
348
+ if (draft === undefined) return false;
349
+ if (suggestion.missingFields.length && !edited(index)) return false;
350
+ const handler = offeredHandler(suggestion);
351
+ return !handler || !missingRequired(handler.argsSchema, draft).length;
352
+ }
353
+ async function preview(index: number) {
121
354
  const analysis = view?.analysis;
122
355
  const suggestion = view?.generation?.suggestions[index];
123
- if (!analysis || suggestion?.disposition !== 'ready_for_review') return;
356
+ if (pending || !analysis || !suggestion || !canPreview(index)) return;
357
+ let args: Record<string, unknown> | undefined;
358
+ const key = draftKey(index);
359
+ if (suggestion.disposition === 'needs_review' && edited(index)) {
360
+ try {
361
+ args = argumentsObject(drafts[key]);
362
+ } catch {
363
+ invalidDrafts[key] = true;
364
+ return;
365
+ }
366
+ }
124
367
  const input = {
125
368
  itemId,
126
369
  attemptId: analysis.attemptId,
127
370
  index,
128
371
  requestId: crypto.randomUUID(),
372
+ ...(args ? { args } : {}),
373
+ };
374
+ // Only the handler's validation refusal stays inline and keeps the view:
375
+ // a resolved `{ rejected: 'invalid' }` or a thrown error named
376
+ // `HandlerArgumentsInvalidError`. Anything else fails closed.
377
+ const ticket = ++epoch;
378
+ pending = true;
379
+ error = false;
380
+ previewRejected = undefined;
381
+ try {
382
+ const outcome = await host.preview(input);
383
+ if (!alive || ticket !== epoch) return;
384
+ if (outcome?.rejected === 'invalid') {
385
+ previewRejected = index;
386
+ pending = false;
387
+ return;
388
+ }
389
+ // Only a successful preview of this card consumes its draft.
390
+ delete drafts[key];
391
+ delete invalidDrafts[key];
392
+ await load();
393
+ } catch (error) {
394
+ if (!alive || ticket !== epoch) return;
395
+ if (
396
+ (error as { name?: unknown } | null)?.name ===
397
+ 'HandlerArgumentsInvalidError'
398
+ ) {
399
+ previewRejected = index;
400
+ pending = false;
401
+ return;
402
+ }
403
+ deny();
404
+ }
405
+ }
406
+ function chooseFor(index: number) {
407
+ return (field: string, id: string) => {
408
+ const key = draftKey(index);
409
+ try {
410
+ drafts[key] = JSON.stringify(
411
+ { ...argumentsObject(drafts[key] ?? '{}'), [field]: id },
412
+ null,
413
+ 2,
414
+ );
415
+ } catch {
416
+ invalidDrafts[key] = true;
417
+ }
129
418
  };
130
- void run(() => host.preview(input));
131
419
  }
132
420
  function split(evidenceId: string) {
133
421
  const analysis = view?.analysis;
@@ -149,6 +437,36 @@ function split(evidenceId: string) {
149
437
  };
150
438
  void run(() => host.split(input));
151
439
  }
440
+ function instruct() {
441
+ const binding = instructionBinding(view);
442
+ const text = instruction.trim();
443
+ const operation = host.instruct;
444
+ if (!binding || !instructionValid || !operation || inFlight(view)) return;
445
+ instructionBusy = false;
446
+ invalidDraft = undefined;
447
+ unrecordedDraft = undefined;
448
+ const input = {
449
+ itemId,
450
+ ...binding,
451
+ text,
452
+ requestId: crypto.randomUUID(),
453
+ };
454
+ // A stale result is a refresh-and-retry signal: run() reloads the view and
455
+ // the draft is kept; it is never treated as a denial.
456
+ void run(async () => {
457
+ const result = await operation(input);
458
+ if (result && 'stale' in result && result.stale === true) {
459
+ instructionBusy = true;
460
+ unrecordedDraft = instruction;
461
+ return;
462
+ }
463
+ if (result && 'invalid' in result && result.invalid === true) {
464
+ invalidDraft = instruction;
465
+ return;
466
+ }
467
+ instruction = '';
468
+ });
469
+ }
152
470
  function locationLabel(location: EvidenceLocation) {
153
471
  switch (location.kind) {
154
472
  case 'page':
@@ -199,12 +517,25 @@ onMount(() => {
199
517
  <header><h1>{t(M['ingestion.review'])}</h1><Button onclick={() => load()} disabled={pending}>{t(M['ingestion.refresh'])}</Button></header>
200
518
  {#if pending}<p role="status">{t(M['ingestion.loading'])}</p>{/if}
201
519
  {#if error}<p role="alert">{t(M['ingestion.error'])}</p>{/if}
520
+ {#if refreshFailed}<p role="status" data-testid="refresh-retrying">{t(M['ingestion.refreshRetrying'])}</p>{/if}
202
521
  {#if view}
203
522
  {#if view.availability !== 'available'}<p role="alert">{t(M[`ingestion.${view.availability}`])}</p>
204
523
  {:else}
205
524
  {@const extracted = view.analysis ? extractedContent(view.analysis.result.output) : []}
206
525
  <h2>{view.entry.label}</h2><p>{t(M[`ingestion.${view.entry.state}`])}</p>
207
526
  <div class="review-grid"><EvidenceViewer evidence={view.evidence} /><div class="details">
527
+ {#if view.instruction}
528
+ {@const applied = view.instruction.applied}{@const current = view.instruction.current}
529
+ {#if inFlight(view) && current}<p role="status" data-testid="reinterpreting">{t(M['ingestion.reinterpreting'])}</p>{/if}
530
+ {#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}
531
+ {#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}
532
+ {#if current && current.id !== applied?.id}<p data-testid="pending-instruction">{t(M['ingestion.pendingInstruction'])}: <q>{current.text}</q></p>{/if}
533
+ {#if view.instruction.dropped}<p role="status" data-testid="dropped-instruction">{t(M['ingestion.droppedInstruction'])}: <q>{view.instruction.dropped.text}</q></p>{/if}
534
+ {/if}
535
+ {#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}
536
+ {#if host.instruct && instructionBinding(view)}
537
+ <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>
538
+ {/if}
208
539
  {#if extracted.length}
209
540
  <h2>{t(M['ingestion.extracted'])}</h2>
210
541
  {#each extracted as segment}
@@ -224,7 +555,17 @@ onMount(() => {
224
555
  {#if suggestion.alternatives.length}<h4>{t(M['ingestion.alternatives'])}</h4><ul>{#each suggestion.alternatives as alternative}<li>{alternative}</li>{/each}</ul>{/if}
225
556
  {#if suggestion.missingFields.length}<h4>{t(M['ingestion.missing'])}</h4><ul>{#each suggestion.missingFields as missing}<li>{missing}</li>{/each}</ul>{/if}
226
557
  <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>
558
+ {#if suggestion.disposition === 'needs_review' && previewable(suggestion)}
559
+ {@const handler = offeredHandler(suggestion)}
560
+ {@const key = draftKey(index)}
561
+ <p>{t(M['ingestion.completeSuggestion'])}</p>
562
+ <Textarea aria-label={t(M['ingestion.suggestionArgs'])} bind:value={drafts[key]} rows={6} />
563
+ {#if handler}<CandidatePicker {host} {itemId} catalog={handler} {deny} onchoose={chooseFor(index)} />{/if}
564
+ {#if edited(index)}<p>{t(M['ingestion.reviewAgain'])}</p>{/if}
565
+ {#if invalidDrafts[key] && !parseable(drafts[key])}<p role="alert">{t(M['ingestion.invalid'])}</p>{/if}
566
+ {/if}
567
+ {#if previewRejected === index}<p role="alert">{t(M['ingestion.previewRejected'])}</p>{/if}
568
+ <Button disabled={!canPreview(index)} onclick={() => preview(index)}>{t(M['ingestion.preview'])}</Button>
228
569
  </article>
229
570
  {/each}
230
571
  {#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}
@@ -2,6 +2,8 @@ import type { IntakeReviewHost } from '../../review-dto.js';
2
2
  export interface Props {
3
3
  host: IntakeReviewHost;
4
4
  itemId: string;
5
+ /** Reload interval while the host reports the item as processing. */
6
+ pollMs?: number;
5
7
  }
6
8
  declare const ReviewView: import("svelte").Component<Props, {}, "">;
7
9
  type ReviewView = ReturnType<typeof ReviewView>;
@@ -1 +1 @@
1
- {"version":3,"file":"ReviewView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ReviewView.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,gBAAgB,EAAkB,MAAM,qBAAqB,CAAC;AAM5E,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;CAChB;AAkPD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ReviewView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ReviewView.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,gBAAgB,EAAkB,MAAM,qBAAqB,CAAC;AAa5E,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA+jBD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -33,6 +33,9 @@ export declare const M: {
33
33
  readonly 'ingestion.proposals': "ingestion.proposals";
34
34
  readonly 'ingestion.preview': "ingestion.preview";
35
35
  readonly 'ingestion.args': "ingestion.args";
36
+ readonly 'ingestion.suggestionArgs': "ingestion.suggestionArgs";
37
+ readonly 'ingestion.previewRejected': "ingestion.previewRejected";
38
+ readonly 'ingestion.completeSuggestion': "ingestion.completeSuggestion";
36
39
  readonly 'ingestion.effects': "ingestion.effects";
37
40
  readonly 'ingestion.approve': "ingestion.approve";
38
41
  readonly 'ingestion.reject': "ingestion.reject";
@@ -49,6 +52,20 @@ export declare const M: {
49
52
  readonly 'ingestion.choose': "ingestion.choose";
50
53
  readonly 'ingestion.split': "ingestion.split";
51
54
  readonly 'ingestion.saveSplit': "ingestion.saveSplit";
55
+ readonly 'ingestion.instructions': "ingestion.instructions";
56
+ readonly 'ingestion.reinterpret': "ingestion.reinterpret";
57
+ readonly 'ingestion.reinterpreting': "ingestion.reinterpreting";
58
+ readonly 'ingestion.appliedInstruction': "ingestion.appliedInstruction";
59
+ readonly 'ingestion.pendingInstruction': "ingestion.pendingInstruction";
60
+ readonly 'ingestion.refreshRetrying': "ingestion.refreshRetrying";
61
+ readonly 'ingestion.instructionNotRecorded': "ingestion.instructionNotRecorded";
62
+ readonly 'ingestion.instructionLength': "ingestion.instructionLength";
63
+ readonly 'ingestion.instructionInvalid': "ingestion.instructionInvalid";
64
+ readonly 'ingestion.droppedInstruction': "ingestion.droppedInstruction";
65
+ readonly 'ingestion.reinterpretNotStarted': "ingestion.reinterpretNotStarted";
66
+ readonly 'ingestion.instructionBusy': "ingestion.instructionBusy";
67
+ readonly 'ingestion.reinterpretFailed': "ingestion.reinterpretFailed";
68
+ readonly 'ingestion.instructionStale': "ingestion.instructionStale";
52
69
  readonly 'ingestion.result': "ingestion.result";
53
70
  readonly 'ingestion.stale': "ingestion.stale";
54
71
  readonly 'ingestion.revoked': "ingestion.revoked";
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuEZ,CAAC"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkGZ,CAAC"}
@@ -34,6 +34,9 @@ export const M = defineMessages({
34
34
  'ingestion.proposals': 'Proposed actions',
35
35
  'ingestion.preview': 'Prepare review',
36
36
  'ingestion.args': 'Exact arguments (JSON)',
37
+ 'ingestion.suggestionArgs': 'Suggested arguments (JSON)',
38
+ 'ingestion.previewRejected': 'These arguments did not pass the handler validation. Correct them and prepare review again.',
39
+ 'ingestion.completeSuggestion': 'This suggestion needs review. Complete or confirm its arguments, then prepare review.',
37
40
  'ingestion.effects': 'Proposed effects',
38
41
  'ingestion.approve': 'Approve',
39
42
  'ingestion.reject': 'Reject',
@@ -50,6 +53,20 @@ export const M = defineMessages({
50
53
  'ingestion.choose': 'Choose destination',
51
54
  'ingestion.split': 'Logical page groups (JSON)',
52
55
  'ingestion.saveSplit': 'Save split and reprocess',
56
+ 'ingestion.instructions': 'Instructions for the assistant',
57
+ 'ingestion.reinterpret': 'Re-read with these instructions',
58
+ 'ingestion.reinterpreting': 'Reinterpreting…',
59
+ 'ingestion.appliedInstruction': 'Suggestions were produced under this instruction',
60
+ 'ingestion.pendingInstruction': 'Latest instruction, not yet applied',
61
+ 'ingestion.refreshRetrying': "Couldn't refresh; retrying.",
62
+ 'ingestion.instructionNotRecorded': 'This item changed while you were writing; your instruction was not recorded.',
63
+ 'ingestion.instructionLength': '{count} / {max} characters',
64
+ 'ingestion.instructionInvalid': 'Enter an instruction of 1 to {max} characters. It was not recorded.',
65
+ 'ingestion.droppedInstruction': "A reprocess replaced this item's extraction, so this instruction no longer applies. Re-record it to use it",
66
+ 'ingestion.reinterpretNotStarted': 'The reinterpretation did not start. Re-read again to retry.',
67
+ 'ingestion.instructionBusy': 'This item changed or is still being re-read. It has been refreshed; try again when it finishes.',
68
+ 'ingestion.reinterpretFailed': 'The reinterpretation did not complete. Adjust the instruction or re-read again.',
69
+ 'ingestion.instructionStale': 'Superseded: the current suggestions do not reflect the latest instruction.',
53
70
  'ingestion.result': 'Result',
54
71
  'ingestion.stale': 'Stale',
55
72
  'ingestion.revoked': 'Access revoked',
@@ -1,4 +1,4 @@
1
- export type { Assignment, EvidenceView, InboxEntry, InboxState, IntakeReviewHost, ItemReviewView, LogicalSplitInput, ReviewAction, ReviewPage, } from '../review-dto.js';
1
+ export type { Assignment, EvidenceView, InboxEntry, InboxEvidence, InboxState, IntakeReviewHost, ItemReviewView, LogicalSplitInput, PreviewRejection, ReviewAction, ReviewerInstruction, ReviewerInstructionInput, ReviewerInstructionView, ReviewPage, } from '../review-dto.js';
2
2
  export { default as EvidenceViewer } from './components/EvidenceViewer.svelte';
3
3
  export { default as IntakeInbox } from './components/IntakeInbox.svelte';
4
4
  export { default as IntakeReview } from './components/IntakeReview.svelte';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/svelte/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,UAAU,EACV,YAAY,EACZ,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/svelte/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,UAAU,EACV,YAAY,EACZ,UAAU,EACV,aAAa,EACb,UAAU,EACV,gBAAgB,EAChB,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,mBAAmB,EACnB,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC"}
@@ -2,6 +2,8 @@ import type { EvidenceLocation } from '../extraction-types.js';
2
2
  /** Only host-authorized same-origin HTTP URLs may reach original media elements. */
3
3
  export declare function evidenceUrl(value?: string, origin?: string): string | undefined;
4
4
  export declare function argumentsObject(value: string): Record<string, unknown>;
5
+ /** Required top-level handler arguments missing from a JSON draft; unparsable drafts report none. */
6
+ export declare function missingRequired(schema: Record<string, unknown>, draft: string): string[];
5
7
  export declare function pageGroups(value: string): number[][];
6
8
  /** Project useful extraction content; never present internal configuration/provenance JSON. */
7
9
  export declare function extractedContent(output: Record<string, unknown>): Array<{