@happyvertical/smrt-ingestion 0.57.21 → 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 (83) hide show
  1. package/FEEDBACK.md +104 -0
  2. package/PROPOSALS.md +19 -0
  3. package/README.md +126 -0
  4. package/REVIEW.md +272 -4
  5. package/agents/execution.md +72 -0
  6. package/dist/chunks/{execution-9CfjxX8I.js → execution-BzVQax0n.js} +421 -23
  7. package/dist/chunks/execution-BzVQax0n.js.map +1 -0
  8. package/dist/chunks/{feedback-CuUzWKzP.js → feedback-D3UTF8fm.js} +119 -21
  9. package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
  10. package/dist/chunks/{extraction-CJ-zrbg0.js → proposal-contracts-77Uo1B1j.js} +39 -39
  11. package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
  12. package/dist/chunks/{reviewer-instruction-Db3jDN9a.js → reviewer-instruction-CEc3AOuU.js} +2 -2
  13. package/dist/chunks/{reviewer-instruction-Db3jDN9a.js.map → reviewer-instruction-CEc3AOuU.js.map} +1 -1
  14. package/dist/dto.d.ts +69 -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.d.ts +58 -3
  21. package/dist/execution.d.ts.map +1 -1
  22. package/dist/execution.js +2 -2
  23. package/dist/feedback-dto.d.ts +12 -4
  24. package/dist/feedback-dto.d.ts.map +1 -1
  25. package/dist/feedback-internal.d.ts +9 -0
  26. package/dist/feedback-internal.d.ts.map +1 -1
  27. package/dist/feedback.d.ts +3 -0
  28. package/dist/feedback.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/manifest.json +321 -3
  31. package/dist/models.d.ts +15 -0
  32. package/dist/models.d.ts.map +1 -1
  33. package/dist/models.js +73 -2
  34. package/dist/models.js.map +1 -1
  35. package/dist/policy.d.ts +3 -0
  36. package/dist/policy.d.ts.map +1 -1
  37. package/dist/proposal-errors.d.ts +4 -0
  38. package/dist/proposal-errors.d.ts.map +1 -1
  39. package/dist/proposals.d.ts +1 -0
  40. package/dist/proposals.d.ts.map +1 -1
  41. package/dist/proposals.js +11 -6
  42. package/dist/proposals.js.map +1 -1
  43. package/dist/redaction.d.ts +9 -0
  44. package/dist/redaction.d.ts.map +1 -0
  45. package/dist/review-dto.d.ts +14 -4
  46. package/dist/review-dto.d.ts.map +1 -1
  47. package/dist/review-host.d.ts.map +1 -1
  48. package/dist/server.d.ts +82 -10
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +366 -47
  51. package/dist/server.js.map +1 -1
  52. package/dist/smrt-knowledge.json +124 -18
  53. package/dist/svelte/components/ActionReview.svelte +39 -9
  54. package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
  55. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  56. package/dist/svelte/components/EvidenceViewer.svelte +60 -4
  57. package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
  58. package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
  59. package/dist/svelte/components/InboxView.svelte +160 -18
  60. package/dist/svelte/components/InboxView.svelte.d.ts +28 -1
  61. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  62. package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
  63. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
  64. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
  65. package/dist/svelte/components/ReviewView.svelte +264 -23
  66. package/dist/svelte/components/ReviewView.svelte.d.ts +18 -1
  67. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  68. package/dist/svelte/components/TriageView.svelte +669 -0
  69. package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
  70. package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
  71. package/dist/svelte/i18n.d.ts +39 -0
  72. package/dist/svelte/i18n.d.ts.map +1 -1
  73. package/dist/svelte/i18n.js +39 -0
  74. package/dist/svelte/index.d.ts +1 -0
  75. package/dist/svelte/index.d.ts.map +1 -1
  76. package/dist/svelte/index.js +1 -0
  77. package/dist/svelte/view-utils.d.ts +65 -0
  78. package/dist/svelte/view-utils.d.ts.map +1 -1
  79. package/dist/svelte/view-utils.js +145 -0
  80. package/package.json +13 -13
  81. package/dist/chunks/execution-9CfjxX8I.js.map +0 -1
  82. package/dist/chunks/extraction-CJ-zrbg0.js.map +0 -1
  83. package/dist/chunks/feedback-CuUzWKzP.js.map +0 -1
@@ -1,20 +1,27 @@
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 { GeneratedSuggestion } from '../../proposal-dto.js';
7
+ import type {
8
+ GeneratedSuggestion,
9
+ ProposalCatalogEntry,
10
+ } from '../../proposal-dto.js';
8
11
  import type { IntakeReviewHost, ItemReviewView } from '../../review-dto.js';
9
12
  import { M } from '../i18n.js';
10
13
  import {
11
14
  argumentsObject,
15
+ draftArguments,
12
16
  evidenceUrl,
13
17
  extractedContent,
18
+ missingArguments,
14
19
  missingRequired,
20
+ observedPages,
15
21
  pageGroups,
22
+ reviewInFlight,
16
23
  } from '../view-utils.js';
17
- import ActionReview from './ActionReview.svelte';
24
+ import ActionReview, { type ReviewOutcome } from './ActionReview.svelte';
18
25
  import CandidatePicker from './CandidatePicker.svelte';
19
26
  import EvidenceViewer from './EvidenceViewer.svelte';
20
27
  export interface Props {
@@ -22,8 +29,37 @@ export interface Props {
22
29
  itemId: string;
23
30
  /** Reload interval while the host reports the item as processing. */
24
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;
25
52
  }
26
- let { host, itemId, pollMs = 2000 }: 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();
27
63
  const { t } = useI18n();
28
64
  let view = $state<ItemReviewView>(),
29
65
  pending = $state(false),
@@ -71,16 +107,83 @@ onDestroy(() => {
71
107
  epoch++;
72
108
  clearTimeout(poll);
73
109
  });
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
- );
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;
83
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
+ );
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>>({});
84
187
  /** Prefer the server's retry binding; otherwise the displayed generation. */
85
188
  function instructionBinding(current: ItemReviewView | undefined) {
86
189
  if (current?.instruction?.binding) return current.instruction.binding;
@@ -121,7 +224,10 @@ function deny() {
121
224
  async function fetchView(
122
225
  ticket: number,
123
226
  pageCount: number,
124
- ): Promise<{ result: ItemReviewView; pages: number } | undefined> {
227
+ ): Promise<
228
+ { result: ItemReviewView; pages: number; issued: number } | undefined
229
+ > {
230
+ const issued = stamp?.() ?? 0;
125
231
  let cursor: string | undefined;
126
232
  let result: ItemReviewView | undefined;
127
233
  const actions: ItemReviewView['reviews']['actions'] = [];
@@ -171,6 +277,7 @@ async function fetchView(
171
277
  availability: result.availability,
172
278
  },
173
279
  pages: refreshedPages,
280
+ issued,
174
281
  };
175
282
  }
176
283
  /** Keep only drafts the reviewer edited; untouched ones follow the new generation. */
@@ -204,6 +311,7 @@ function syncSuggestionDrafts(current: ItemReviewView) {
204
311
  current.availability === 'expired'
205
312
  ) {
206
313
  drafts = {};
314
+ draftBase = {};
207
315
  invalidDrafts = {};
208
316
  } else if (current.availability === 'available') {
209
317
  const attemptId = current.analysis?.attemptId;
@@ -212,11 +320,14 @@ function syncSuggestionDrafts(current: ItemReviewView) {
212
320
  if (!attemptId || suggestion.disposition !== 'needs_review') return;
213
321
  const key = `${attemptId}:${index}`;
214
322
  keys.add(key);
215
- drafts[key] ??= JSON.stringify(suggestion.args, null, 2);
323
+ const generated = JSON.stringify(suggestion.args, null, 2);
324
+ drafts[key] ??= generated;
325
+ draftBase[key] ??= generated;
216
326
  });
217
327
  for (const key of Object.keys(drafts))
218
328
  if (!keys.has(key)) {
219
329
  delete drafts[key];
330
+ delete draftBase[key];
220
331
  delete invalidDrafts[key];
221
332
  }
222
333
  }
@@ -238,6 +349,7 @@ async function load(pageCount = loadedPages) {
238
349
  if (!fetched) return;
239
350
  if (fetched.result.availability === 'available')
240
351
  loadedPages = fetched.pages;
352
+ viewIssued = fetched.issued;
241
353
  view = fetched.result;
242
354
  syncSuggestionDrafts(view);
243
355
  splits = splitDrafts(view, {});
@@ -260,6 +372,7 @@ async function refresh(ticket: number) {
260
372
  refreshFailed = false;
261
373
  // An explicit host denial fails closed at once.
262
374
  if (fetched.result.availability !== 'available') {
375
+ viewIssued = fetched.issued;
263
376
  view = fetched.result;
264
377
  syncSuggestionDrafts(view);
265
378
  splits = {};
@@ -268,8 +381,14 @@ async function refresh(ticket: number) {
268
381
  if (JSON.stringify(fetched.result) !== JSON.stringify(view)) {
269
382
  loadedPages = fetched.pages;
270
383
  splits = splitDrafts(fetched.result, splits, view);
384
+ viewIssued = fetched.issued;
271
385
  view = fetched.result;
272
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));
273
392
  }
274
393
  settleBusy();
275
394
  } catch {
@@ -287,7 +406,7 @@ async function refresh(ticket: number) {
287
406
  if (alive && ticket === epoch && !pending) schedulePoll(ticket);
288
407
  }
289
408
  }
290
- async function run(operation: () => Promise<unknown>) {
409
+ async function run(operation: () => Promise<unknown>, outcome?: ReviewOutcome) {
291
410
  if (pending) return;
292
411
  const ticket = ++epoch;
293
412
  clearTimeout(poll);
@@ -297,7 +416,18 @@ async function run(operation: () => Promise<unknown>) {
297
416
  splits = {};
298
417
  try {
299
418
  await operation();
300
- 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);
301
431
  } catch {
302
432
  if (alive && ticket === epoch) deny();
303
433
  }
@@ -312,6 +442,7 @@ function offeredHandler(suggestion: GeneratedSuggestion) {
312
442
  // Presentation only: the host rechecks stage, outcome, handler and authority.
313
443
  function previewable(suggestion: GeneratedSuggestion) {
314
444
  return (
445
+ !view?.entry.item.disposition &&
315
446
  !!view?.analysis &&
316
447
  view.generation?.outcome !== 'provider_error' &&
317
448
  ['ready_for_review', 'needs_review'].includes(suggestion.disposition)
@@ -388,6 +519,7 @@ async function preview(index: number) {
388
519
  }
389
520
  // Only a successful preview of this card consumes its draft.
390
521
  delete drafts[key];
522
+ delete draftBase[key];
391
523
  delete invalidDrafts[key];
392
524
  await load();
393
525
  } catch (error) {
@@ -437,6 +569,85 @@ function split(evidenceId: string) {
437
569
  };
438
570
  void run(() => host.split(input));
439
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
+ }
440
651
  function instruct() {
441
652
  const binding = instructionBinding(view);
442
653
  const text = instruction.trim();
@@ -523,7 +734,7 @@ onMount(() => {
523
734
  {:else}
524
735
  {@const extracted = view.analysis ? extractedContent(view.analysis.result.output) : []}
525
736
  <h2>{view.entry.label}</h2><p>{t(M[`ingestion.${view.entry.state}`])}</p>
526
- <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">
527
738
  {#if view.instruction}
528
739
  {@const applied = view.instruction.applied}{@const current = view.instruction.current}
529
740
  {#if inFlight(view) && current}<p role="status" data-testid="reinterpreting">{t(M['ingestion.reinterpreting'])}</p>{/if}
@@ -533,7 +744,7 @@ onMount(() => {
533
744
  {#if view.instruction.dropped}<p role="status" data-testid="dropped-instruction">{t(M['ingestion.droppedInstruction'])}: <q>{view.instruction.dropped.text}</q></p>{/if}
534
745
  {/if}
535
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}
536
- {#if host.instruct && instructionBinding(view)}
747
+ {#if host.instruct && !view.entry.item.disposition && instructionBinding(view)}
537
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>
538
749
  {/if}
539
750
  {#if extracted.length}
@@ -555,6 +766,7 @@ onMount(() => {
555
766
  {#if suggestion.alternatives.length}<h4>{t(M['ingestion.alternatives'])}</h4><ul>{#each suggestion.alternatives as alternative}<li>{alternative}</li>{/each}</ul>{/if}
556
767
  {#if suggestion.missingFields.length}<h4>{t(M['ingestion.missing'])}</h4><ul>{#each suggestion.missingFields as missing}<li>{missing}</li>{/each}</ul>{/if}
557
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>
769
+ {#if !view.entry.item.disposition}
558
770
  {#if suggestion.disposition === 'needs_review' && previewable(suggestion)}
559
771
  {@const handler = offeredHandler(suggestion)}
560
772
  {@const key = draftKey(index)}
@@ -566,14 +778,43 @@ onMount(() => {
566
778
  {/if}
567
779
  {#if previewRejected === index}<p role="alert">{t(M['ingestion.previewRejected'])}</p>{/if}
568
780
  <Button disabled={!canPreview(index)} onclick={() => preview(index)}>{t(M['ingestion.preview'])}</Button>
781
+ {/if}
569
782
  </article>
570
783
  {/each}
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}
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}
572
785
  {#if invalid}<p role="alert">{t(M['ingestion.invalid'])}</p>{/if}
573
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}
574
815
  {#if !view.reviews.actions.length}<p>{t(M['ingestion.noReviews'])}</p>{/if}
575
816
  {#each view.reviews.actions as action (`${action.review.actionId}:${action.review.revision}:${action.review.reviewVersion}`)}
576
- <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} />
577
818
  {/each}
578
819
  {#if view.reviews.nextCursor}<Button onclick={() => load(loadedPages + 1)}>{t(M['ingestion.more'])}</Button>{/if}
579
820
  </div></div>
@@ -582,6 +823,6 @@ onMount(() => {
582
823
  <style>
583
824
  @layer smrt.reset, smrt.structure, smrt.theme;
584
825
  @layer smrt.theme {
585
- 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}}
586
827
  }
587
828
  </style>
@@ -1,9 +1,26 @@
1
- import type { IntakeReviewHost } from '../../review-dto.js';
1
+ import type { IntakeReviewHost, ItemReviewView } from '../../review-dto.js';
2
+ import { type ReviewOutcome } from './ActionReview.svelte';
2
3
  export interface Props {
3
4
  host: IntakeReviewHost;
4
5
  itemId: string;
5
6
  /** Reload interval while the host reports the item as processing. */
6
7
  pollMs?: number;
8
+ /** `split` puts the original beside the form; `stacked` puts it above, one part at a time. */
9
+ layout?: 'split' | 'stacked';
10
+ /**
11
+ * Every displayed view (load, in-place poll patch, denial projection), and
12
+ * `undefined` when a displayed view is cleared for a reload or an operation
13
+ * whose result has not been shown yet. `denied` is true when it was cleared
14
+ * because a callback failed (the view fails closed), not for a reload.
15
+ * Presentation only.
16
+ */
17
+ onview?: (view: ItemReviewView | undefined, denied: boolean, issued: number) => void;
18
+ /** Numbers each load when its request starts; reported as `issued` with the view. */
19
+ stamp?: () => number;
20
+ /** Whether the reviewer has unsent input on this item. */
21
+ ondirty?: (dirty: boolean) => void;
22
+ /** After an approve, apply or resolve and the reload that follows it succeeded. */
23
+ onoutcome?: (outcome: ReviewOutcome, view: ItemReviewView) => void;
7
24
  }
8
25
  declare const ReviewView: import("svelte").Component<Props, {}, "">;
9
26
  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":"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"}
1
+ {"version":3,"file":"ReviewView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ReviewView.svelte.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAa5E,OAAqB,EAAE,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAIzE,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8FAA8F;IAC9F,MAAM,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,CACP,IAAI,EAAE,cAAc,GAAG,SAAS,EAChC,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,KACX,IAAI,CAAC;IACV,qFAAqF;IACrF,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,mFAAmF;IACnF,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;CACpE;AAqxBD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}