@anthusai/papyrus 1.11.0 → 1.12.0

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 (34) hide show
  1. package/amplify/data/resource.js +67 -0
  2. package/amplify/data/resource.ts +72 -0
  3. package/amplify/data/schema.js +67 -0
  4. package/amplify/data/schema.ts +72 -0
  5. package/amplify/functions/category-action/handler.ts +172 -2
  6. package/amplify/functions/content-actions/requirements.txt +1 -1
  7. package/amplify/functions/content-actions/resource.js +1 -1
  8. package/amplify/functions/content-actions/resource.ts +1 -1
  9. package/amplify/functions/renditions/requirements.txt +1 -1
  10. package/amplify/functions/shared/curation-delegation.ts +67 -0
  11. package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
  12. package/amplify/publication/renditions-function.js +1 -1
  13. package/amplify/publication/renditions-function.ts +1 -1
  14. package/components/administration-delegations-panel.tsx +204 -0
  15. package/components/curation-delegation-client.ts +81 -0
  16. package/components/news-desk-taxonomy-client.ts +7 -0
  17. package/components/newsroom-organizer.css +22 -0
  18. package/components/newsroom-references-view.tsx +56 -21
  19. package/components/prospectus-account-chooser.tsx +14 -19
  20. package/components/reference-cyclotron-status.tsx +5 -5
  21. package/components/reference-cyclotron-suggestion.tsx +143 -0
  22. package/components/reference-live-agreement.tsx +62 -0
  23. package/components/topic-steering-workspace.tsx +49 -3
  24. package/lib/content-source-context.ts +9 -1
  25. package/lib/curation-delegation-helpers.ts +59 -0
  26. package/lib/cyclotron-suggestions.ts +263 -0
  27. package/lib/graphql-content-repository.ts +42 -7
  28. package/lib/local-preview-seed.ts +141 -3
  29. package/lib/local-preview-tenant.ts +27 -0
  30. package/lib/prospectus-tenant.ts +22 -0
  31. package/lib/relevance-decisions.ts +29 -0
  32. package/lib/tenant-keys.ts +32 -0
  33. package/package.json +10 -1
  34. package/routes.manifest.json +1 -1
@@ -3621,6 +3621,13 @@ export async function loadCyclotronStatus(cyclotronId: string): Promise<import("
3621
3621
  return latestCyclotronStatus(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
3622
3622
  }
3623
3623
 
3624
+ /** The relevance cyclotron's latest cyclotron-shadow-agreement/v1 payload: suggestions compared with later human decisions. */
3625
+ export async function loadCyclotronShadowAgreement(cyclotronId: string): Promise<import("../lib/cyclotron-suggestions").ShadowAgreement | null> {
3626
+ const { latestShadowAgreement, shadowAgreementPayloadId } = await import("../lib/cyclotron-suggestions");
3627
+ const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", shadowAgreementPayloadId(cyclotronId), ["raw_payload"]);
3628
+ return latestShadowAgreement(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
3629
+ }
3630
+
3624
3631
  /** Ask the next decide-relevance sweep to set or clear a manual review rate. */
3625
3632
  export async function requestCyclotronReviewRate(
3626
3633
  cyclotronId: string,
@@ -274,3 +274,25 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
274
274
  .organizer-account-chooser li button small,.organizer-account-chooser-note{font-size:12px;color:var(--org-ink);margin:0}
275
275
  .organizer-account-chooser-heading{margin:0;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
276
276
  .organizer-account-chooser-link{color:var(--org-ink);text-decoration:underline;text-underline-offset:3px}
277
+ [data-reference-cyclotron-status] .cyclotron-status[data-variant]{border-radius:0;background:transparent;border-top:1px solid var(--org-ink);border-bottom:1px solid var(--org-ink);padding-left:0;padding-right:0}
278
+ [data-reference-cyclotron-status] :is(.cyclotron-status-note,.cyclotron-status-definition,.cyclotron-status-header p,.cyclotron-status-override){color:var(--org-ink)}
279
+ [data-reference-cyclotron-status] .cyclotron-status[data-variant="strip"]>span+span::before{color:var(--org-ink)}
280
+ [data-reference-cyclotron-status] .cyclotron-status-bar{border-radius:0;background:transparent;border:1px solid var(--org-ink)}
281
+ [data-reference-cyclotron-status] .cyclotron-status-bar>span{opacity:1}
282
+ [data-reference-cyclotron-status] :is(.cyclotron-review-secondary,.cyclotron-review-choice,.cyclotron-review-submit){border-radius:0;opacity:1}
283
+ .news-desk-columns--administration .news-desk-delegation-note{margin:0;color:var(--administration-strong);max-width:68ch}
284
+ .news-desk-columns--administration .news-desk-delegation-form{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr) auto;gap:12px;align-items:end}
285
+ .news-desk-columns--administration .news-desk-delegation-error{margin:0;padding:8px 0 8px 12px;border-left:3px solid var(--org-ink);color:var(--administration-strong);font-weight:600}
286
+ .news-desk-columns--administration .news-desk-delegation-token{display:grid;gap:12px;min-width:0;padding:16px;border:2px solid var(--org-ink)}
287
+ .news-desk-columns--administration .news-desk-delegation-token h3{margin:0;font-size:20px}
288
+ .news-desk-columns--administration .news-desk-delegation-token p{margin:0}
289
+ .news-desk-columns--administration .news-desk-delegation-token__row{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch}
290
+ .news-desk-columns--administration .news-desk-delegation-token__row code{flex:1 1 280px;min-width:0;padding:8px 10px;border:1px solid var(--org-line);font:500 13px/1.5 ui-monospace,Menlo,monospace;overflow-wrap:anywhere;color:var(--administration-strong)}
291
+ .news-desk-columns--administration .news-desk-delegation-token__row button,.news-desk-columns--administration .news-desk-delegation-list button{min-height:36px;padding:4px 12px;border:1px solid var(--org-line);background:transparent;color:var(--administration-strong);font:600 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.05em;text-transform:uppercase;cursor:pointer}
292
+ .news-desk-columns--administration .news-desk-delegation-list{list-style:none;margin:0;padding:0;display:grid}
293
+ .news-desk-columns--administration .news-desk-delegation-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px 16px;align-items:center;padding:12px 0;border-top:1px solid var(--org-line)}
294
+ .news-desk-columns--administration .news-desk-delegation-list li>div{display:grid;gap:2px}
295
+ .news-desk-columns--administration .news-desk-delegation-list li[data-delegation-live=true] .news-desk-delegation-state{font-weight:700;text-decoration:underline;text-underline-offset:4px}
296
+ .news-desk-columns--administration .news-desk-delegation-state{font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;color:var(--administration-strong)}
297
+ .news-desk-columns--administration .news-desk-delegation-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
298
+ @container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-form{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-list li{grid-template-columns:minmax(0,1fr)}}
@@ -49,6 +49,15 @@ import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
49
49
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
50
50
  import { ReferenceSourcePreview } from "./reference-source-preview";
51
51
  import { ReferenceRelevanceReview } from "./reference-relevance-review";
52
+ import {
53
+ CountsAsTrainingLabelNote,
54
+ DelegatedCurationNote,
55
+ ReferenceSuggestionBlock,
56
+ ReferenceSuggestionProvenance,
57
+ ReferenceSuggestionRowLine,
58
+ } from "./reference-cyclotron-suggestion";
59
+ import { ReferenceLiveAgreement } from "./reference-live-agreement";
60
+ import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
52
61
  import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
53
62
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
54
63
  import type { PrimingRequestView } from "../lib/relevance-status";
@@ -75,6 +84,12 @@ type NewsroomReferencesViewProps = {
75
84
  demo?: boolean;
76
85
  /** The relevance cyclotron's current decisions, keyed by reference lineage. */
77
86
  decisions?: Map<string, RelevanceDecisionView>;
87
+ /** Reference lineage ids whose latest curation Message was made by a person. */
88
+ humanCuratedReferenceLineageIds?: Set<string>;
89
+ /** Delegated curations by reference lineage. */
90
+ delegatedCurations?: Map<string, DelegatedCuration>;
91
+ /** The live-agreement payload; the strip is hidden without it. */
92
+ shadowAgreement?: ShadowAgreement | null;
78
93
  disabled?: boolean;
79
94
  onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
80
95
  initialReferenceLineageId?: string | null;
@@ -212,17 +227,14 @@ function useReferencePreviewAttachments(
212
227
  return previewAttachments;
213
228
  }
214
229
 
215
- function decisionSummary(decision: RelevanceDecisionView): string {
216
- const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
217
- return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
218
- }
219
-
220
230
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
221
231
  return Boolean(decision?.reviewRecommended)
222
232
  && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
223
233
  }
224
234
 
225
235
  function ReferenceDetailPanel({
236
+ countsAsTrainingLabel,
237
+ delegated,
226
238
  decision,
227
239
  demo,
228
240
  disabled,
@@ -232,6 +244,8 @@ function ReferenceDetailPanel({
232
244
  reference,
233
245
  onClose,
234
246
  }: {
247
+ countsAsTrainingLabel?: boolean;
248
+ delegated?: DelegatedCuration;
235
249
  decision?: RelevanceDecisionView;
236
250
  demo?: boolean;
237
251
  disabled?: boolean;
@@ -267,25 +281,39 @@ function ReferenceDetailPanel({
267
281
  ) : null}
268
282
  </div>
269
283
 
270
- {decision && status === "pending" && onRelevanceReview ? (
271
- <ReferenceRelevanceReview
272
- decision={decision}
273
- disabled={disabled}
274
- onCurate={onRelevanceReview}
275
- referenceLineageId={lineageId}
276
- />
277
- ) : decision ? (
278
- <p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
279
- Cyclotron decision: {decisionSummary(decision)}
280
- </p>
284
+ {decision ? (
285
+ <div className="space-y-3" data-reference-relevance-decision={lineageId}>
286
+ <ReferenceSuggestionBlock
287
+ decision={decision}
288
+ disabled={disabled}
289
+ onCurate={onRelevanceReview}
290
+ referenceLineageId={lineageId}
291
+ settled={status !== "pending"}
292
+ statusLabel={referenceStatusLabel(status)}
293
+ />
294
+ <ReferenceSuggestionProvenance decision={decision} />
295
+ {status === "pending" && onRelevanceReview ? (
296
+ <details className="text-sm text-foreground">
297
+ <summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">Review with a reason</summary>
298
+ <div className="mt-2">
299
+ <ReferenceRelevanceReview
300
+ decision={decision}
301
+ disabled={disabled}
302
+ onCurate={onRelevanceReview}
303
+ referenceLineageId={lineageId}
304
+ />
305
+ </div>
306
+ </details>
307
+ ) : null}
308
+ </div>
281
309
  ) : null}
310
+ {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
282
311
 
283
312
  <div
284
313
  className="flex flex-wrap items-center gap-2"
285
314
  data-news-desk-reference-curation-cluster
286
315
  data-reference-curation-status={status}
287
316
  >
288
- {decision && status === "pending" && onRelevanceReview ? null : <>
289
317
  <Button
290
318
  aria-label="Accept reference"
291
319
  aria-pressed={status === "accepted"}
@@ -312,7 +340,6 @@ function ReferenceDetailPanel({
312
340
  <ThumbsDownIcon className="size-4" />
313
341
  Reject
314
342
  </Button>
315
- </>}
316
343
  <Button
317
344
  aria-label="Archive reference"
318
345
  disabled={archiveDisabled}
@@ -409,6 +436,9 @@ export function NewsroomReferencesView({
409
436
  cyclotronStatus,
410
437
  cyclotronPriming,
411
438
  decisions,
439
+ humanCuratedReferenceLineageIds,
440
+ delegatedCurations,
441
+ shadowAgreement = null,
412
442
  onRequestReviewRate,
413
443
  demo = false,
414
444
  disabled = false,
@@ -523,6 +553,7 @@ export function NewsroomReferencesView({
523
553
  Review source intake before it becomes accepted evidence.
524
554
  </p>
525
555
  </div>
556
+ <ReferenceLiveAgreement agreement={shadowAgreement} />
526
557
  <ReferenceCurationControls disabled={disabled} />
527
558
  {priming && !disabled ? <ReferencePriming {...priming} /> : null}
528
559
  {cyclotronPriming && !disabled ? (
@@ -595,12 +626,12 @@ export function NewsroomReferencesView({
595
626
  </div>
596
627
  <p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
597
628
  {decision ? (
598
- <p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
599
- <span>Decision: {decisionSummary(decision)}</span>
629
+ <div className="flex flex-wrap items-center gap-2" data-reference-relevance-row={lineageId}>
630
+ <ReferenceSuggestionRowLine decision={decision} referenceLineageId={lineageId} />
600
631
  {needsReview(reference, decision) ? (
601
632
  <Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
602
633
  ) : null}
603
- </p>
634
+ </div>
604
635
  ) : null}
605
636
  </button>
606
637
  );
@@ -650,6 +681,8 @@ export function NewsroomReferencesView({
650
681
  <ScrollArea className="min-h-0 flex-1">
651
682
  {selectedReference ? (
652
683
  <ReferenceDetailPanel
684
+ countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
685
+ delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
653
686
  decision={decisions?.get(referenceLineageId(selectedReference))}
654
687
  demo={demo}
655
688
  disabled={disabled}
@@ -681,6 +714,8 @@ export function NewsroomReferencesView({
681
714
  {selectedReference ? (
682
715
  <ScrollArea className="max-h-[75dvh]">
683
716
  <ReferenceDetailPanel
717
+ countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
718
+ delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
684
719
  decision={decisions?.get(referenceLineageId(selectedReference))}
685
720
  demo={demo}
686
721
  disabled={disabled}
@@ -3,26 +3,20 @@
3
3
  import { useEffect, useState } from "react";
4
4
  import { Check } from "lucide-react";
5
5
  import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
6
7
  import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
7
8
 
8
- let previewState: ProspectusAccountState | null = null;
9
-
10
- const PREVIEW_ACCOUNT_STATE: ProspectusAccountState = {
11
- status: "signedIn",
12
- publishingAccounts: [
13
- { accountId: "preview-papyrus", name: "Papyrus", role: "publisher" },
14
- { accountId: "preview-pilobolus", name: "Pilobolus", role: "editor" },
15
- ],
16
- selectedAccountId: "preview-papyrus",
17
- cyclotronLabAccess: true,
18
- };
9
+ function previewAccountState(): ProspectusAccountState {
10
+ return {
11
+ status: "signedIn",
12
+ publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
13
+ selectedAccountId: getLocalPreviewTenantId(),
14
+ cyclotronLabAccess: true,
15
+ };
16
+ }
19
17
 
20
18
  const ACCOUNT_STATE_EVENT = "papyrus:prospectus-account-state";
21
19
 
22
- function announceAccountState(state: ProspectusAccountState) {
23
- window.dispatchEvent(new CustomEvent<ProspectusAccountState>(ACCOUNT_STATE_EVENT, { detail: state }));
24
- }
25
-
26
20
  function summaryOf(state: ProspectusAccountState): string | null {
27
21
  if (state.status !== "signedIn") return null;
28
22
  const selected = state.publishingAccounts.find(account => account.accountId === state.selectedAccountId);
@@ -37,7 +31,7 @@ export function useProspectusAccountSummary(): string | null {
37
31
  useEffect(() => {
38
32
  let cancelled = false;
39
33
  const returnPath = `${window.location.pathname}${window.location.search}`;
40
- (isLocalPreviewEnabled() ? Promise.resolve(previewState ?? PREVIEW_ACCOUNT_STATE) : loadProspectusAccountState(returnPath)).then(loaded => {
34
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath)).then(loaded => {
41
35
  if (!cancelled) setSummary(summaryOf(loaded));
42
36
  });
43
37
  const onChange = (event: Event) => setSummary(summaryOf((event as CustomEvent<ProspectusAccountState>).detail));
@@ -59,7 +53,7 @@ export function ProspectusAccountChooser() {
59
53
  useEffect(() => {
60
54
  let cancelled = false;
61
55
  const returnPath = `${window.location.pathname}${window.location.search}`;
62
- (isLocalPreviewEnabled() ? Promise.resolve(previewState ?? PREVIEW_ACCOUNT_STATE) : loadProspectusAccountState(returnPath))
56
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath))
63
57
  .then(loaded => { if (!cancelled) setState(loaded); });
64
58
  return () => { cancelled = true; };
65
59
  }, []);
@@ -73,8 +67,9 @@ export function ProspectusAccountChooser() {
73
67
  if (accountId === state.selectedAccountId || pending) return;
74
68
  setPending(accountId); setError(null);
75
69
  if (isLocalPreviewEnabled()) {
76
- const next = { ...state, selectedAccountId: accountId };
77
- previewState = next; setState(next); announceAccountState(next); setPending(null); return;
70
+ setLocalPreviewTenantId(accountId);
71
+ window.location.reload();
72
+ return;
78
73
  }
79
74
  const result = await selectProspectusAccount(accountId);
80
75
  setPending(null);
@@ -48,14 +48,14 @@ export function ReferenceCyclotronStatus({
48
48
  <section aria-label="Relevance cyclotron" className="space-y-2" data-reference-cyclotron-status>
49
49
  <CyclotronStatusView status={status} />
50
50
  {stale ? (
51
- <p className="text-xs text-muted-foreground" data-reference-cyclotron-status-stale>
51
+ <p className="text-xs text-foreground" data-reference-cyclotron-status-stale>
52
52
  <span aria-hidden="true">⚠ </span>
53
53
  Snapshot from {Math.round(ageHours)} hours ago; the next sweep refreshes it.
54
54
  </p>
55
55
  ) : null}
56
56
  <button
57
57
  aria-expanded={open}
58
- className="text-xs text-muted-foreground underline underline-offset-4"
58
+ className="text-xs text-foreground underline underline-offset-4"
59
59
  onClick={() => setOpen((value) => !value)}
60
60
  type="button"
61
61
  >
@@ -75,7 +75,7 @@ export function ReferenceCyclotronStatus({
75
75
  }}
76
76
  >
77
77
  <label className="flex flex-col gap-1">
78
- <span className="text-xs text-muted-foreground">Review rate for confident decisions</span>
78
+ <span className="text-xs text-foreground">Review rate for confident decisions</span>
79
79
  <select aria-label="Manual review rate" onChange={(event) => setRate(Number(event.target.value))} value={rate}>
80
80
  {RATE_CHOICES.map((choice) => (
81
81
  <option key={choice} value={choice}>{Math.round(choice * 100)}%</option>
@@ -83,7 +83,7 @@ export function ReferenceCyclotronStatus({
83
83
  </select>
84
84
  </label>
85
85
  <label className="flex flex-col gap-1">
86
- <span className="text-xs text-muted-foreground">For</span>
86
+ <span className="text-xs text-foreground">For</span>
87
87
  <select aria-label="How long the manual rate lasts" onChange={(event) => setDays(Number(event.target.value))} value={days}>
88
88
  {DAY_CHOICES.map((choice) => (
89
89
  <option key={choice} value={choice}>{choice} day{choice === 1 ? "" : "s"}</option>
@@ -96,7 +96,7 @@ export function ReferenceCyclotronStatus({
96
96
  Clear manual rate
97
97
  </button>
98
98
  ) : null}
99
- <p className="basis-full text-xs text-muted-foreground">
99
+ <p className="basis-full text-xs text-foreground">
100
100
  Low-confidence decisions and the random audit share are always reviewed.
101
101
  </p>
102
102
  {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import {
4
+ curationForSuggestionChoice,
5
+ type DelegatedCuration,
6
+ provenanceSummary,
7
+ suggestionConfidenceWords,
8
+ type SuggestionChoice,
9
+ } from "../lib/cyclotron-suggestions";
10
+ import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
11
+
12
+ const RULE_BUTTON_CLASS =
13
+ "border border-foreground px-3 py-1 text-sm font-semibold text-foreground underline-offset-4 hover:underline disabled:cursor-not-allowed disabled:opacity-100 disabled:line-through";
14
+
15
+ /** UTC and fixed-format so server and browser render the same words. */
16
+ export function formatSuggestionTime(instant: string | null): string {
17
+ if (!instant) return "time not recorded";
18
+ const parsed = new Date(instant);
19
+ if (Number.isNaN(parsed.getTime())) return "time not recorded";
20
+ return `${parsed.toISOString().slice(0, 16).replace("T", " ")} UTC`;
21
+ }
22
+
23
+ /** The one-line suggestion in a queue row. Never worded as a decision. */
24
+ export function ReferenceSuggestionRowLine({
25
+ decision,
26
+ referenceLineageId,
27
+ }: {
28
+ decision: RelevanceDecisionView;
29
+ referenceLineageId: string;
30
+ }) {
31
+ return (
32
+ <p className="mt-1 text-xs text-foreground" data-reference-suggestion-row={referenceLineageId}>
33
+ <span className="font-semibold">Suggestion:</span> {decision.label}, {suggestionConfidenceWords(decision)}
34
+ </p>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The detail's Suggestion block. Agree and Overrule go through the ordinary
40
+ * accept and reject actions; nothing changes until an editor clicks.
41
+ */
42
+ export function ReferenceSuggestionBlock({
43
+ decision,
44
+ disabled,
45
+ onCurate,
46
+ referenceLineageId,
47
+ statusLabel,
48
+ settled,
49
+ }: {
50
+ decision: RelevanceDecisionView;
51
+ disabled?: boolean;
52
+ onCurate?: (curation: RelevanceCuration) => void;
53
+ referenceLineageId: string;
54
+ statusLabel: string;
55
+ settled: boolean;
56
+ }) {
57
+ const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
58
+ const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
59
+ return (
60
+ <section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
61
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
62
+ <p className="text-sm text-foreground">
63
+ The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
64
+ </p>
65
+ <p className="text-xs text-foreground" data-reference-suggestion-notice>
66
+ A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
67
+ </p>
68
+ {!settled && onCurate ? (
69
+ <div className="flex flex-wrap gap-2">
70
+ <button
71
+ className={RULE_BUTTON_CLASS}
72
+ data-reference-suggestion-agree
73
+ disabled={disabled}
74
+ onClick={() => choose("agree")}
75
+ type="button"
76
+ >
77
+ Agree ({suggestsAccept ? "accept" : "reject"})
78
+ </button>
79
+ <button
80
+ className={RULE_BUTTON_CLASS}
81
+ data-reference-suggestion-overrule
82
+ disabled={disabled}
83
+ onClick={() => choose("overrule")}
84
+ type="button"
85
+ >
86
+ Overrule ({suggestsAccept ? "reject" : "accept"})
87
+ </button>
88
+ </div>
89
+ ) : null}
90
+ </section>
91
+ );
92
+ }
93
+
94
+ /** The expandable "How this suggestion was made" block, from the recorded provenance. */
95
+ export function ReferenceSuggestionProvenance({ decision }: { decision: RelevanceDecisionView }) {
96
+ const summary = provenanceSummary(decision);
97
+ return (
98
+ <details className="text-sm text-foreground" data-reference-suggestion-provenance>
99
+ <summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
100
+ {summary ? (
101
+ <ul className="mt-2 space-y-1">
102
+ <li>{summary.teachingLabelsSentence}.</li>
103
+ <li>{summary.primingExamplesSentence}.</li>
104
+ <li>Doctrine fingerprint {summary.doctrineShortHash ?? "not recorded"}.</li>
105
+ <li>Model {summary.modelVersion ?? "version not recorded"}.</li>
106
+ <li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
107
+ </ul>
108
+ ) : (
109
+ <p className="mt-2">This suggestion was made before the cyclotron recorded how. Only its model version and confidence are known.</p>
110
+ )}
111
+ </details>
112
+ );
113
+ }
114
+
115
+ /** Shown only when the latest curation of the reference was made by a signed-in person. */
116
+ export function CountsAsTrainingLabelNote({ referenceLineageId }: { referenceLineageId: string }) {
117
+ return (
118
+ <p className="border-l-2 border-foreground pl-2 text-xs font-semibold text-foreground" data-reference-training-label={referenceLineageId}>
119
+ Counts as a training label: an editor made this decision, so the cyclotron learns from it.
120
+ </p>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Shown on a record an agent decided for a person under a delegation. The
126
+ * backend stamps it human, so it counts as that person's training label.
127
+ */
128
+ export function DelegatedCurationNote({ delegated, referenceLineageId }: { delegated: DelegatedCuration; referenceLineageId: string }) {
129
+ return (
130
+ <section aria-label="Delegated decision" className="space-y-1 border-l-2 border-foreground pl-2 text-sm text-foreground" data-reference-delegated-curation={referenceLineageId}>
131
+ <p className="font-semibold">
132
+ Decided by {delegated.agent} on behalf of {delegated.person ?? "a person"}
133
+ </p>
134
+ {delegated.instruction ? <blockquote className="italic">&ldquo;{delegated.instruction}&rdquo;</blockquote> : null}
135
+ <p className="text-xs">Delegation {delegated.delegationShortId || "id not recorded"}.</p>
136
+ <p className="text-xs font-semibold" data-reference-training-label={referenceLineageId}>
137
+ {delegated.countsAsTrainingLabel
138
+ ? `Counts as a training label: the record is stamped as ${delegated.person ?? "the person"}'s own decision, so the cyclotron learns from it.`
139
+ : "Does not count as a training label."}
140
+ </p>
141
+ </section>
142
+ );
143
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import {
4
+ agreementStripState,
5
+ formatPercentage,
6
+ dailyCapWords,
7
+ loopStateWords,
8
+ type AgreementStripMeasurement,
9
+ type ShadowAgreement,
10
+ } from "../lib/cyclotron-suggestions";
11
+ import { formatSuggestionTime } from "./reference-cyclotron-suggestion";
12
+
13
+ function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasurement }) {
14
+ const figures: Array<[string, string]> = [
15
+ ["Agreement", formatPercentage(measurement.agreement)],
16
+ ["Precision", formatPercentage(measurement.precision)],
17
+ ["Recall", formatPercentage(measurement.recall)],
18
+ ["Comparisons", String(measurement.comparisons)],
19
+ ];
20
+ return (
21
+ <dl className="flex flex-wrap gap-x-6 gap-y-1">
22
+ {figures.map(([name, value]) => (
23
+ <div key={name}>
24
+ <dt className="text-xs font-semibold uppercase tracking-wide text-foreground">{name}</dt>
25
+ <dd className="text-base font-semibold text-foreground">{value}</dd>
26
+ </div>
27
+ ))}
28
+ </dl>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Live agreement at the top of References: how often the cyclotron's
34
+ * suggestions matched what editors then decided. Hidden without a payload.
35
+ */
36
+ export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreement | null }) {
37
+ const state = agreementStripState(agreement);
38
+ if (state.kind === "hidden") return null;
39
+ return (
40
+ <section aria-label="Live agreement" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
41
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Live agreement</h3>
42
+ {state.loop ? (
43
+ <p className="text-sm text-foreground" data-reference-live-agreement-loop>
44
+ <span className="font-semibold">{loopStateWords(state.loop)}</span>
45
+ {" · "}Last sweep {formatSuggestionTime(state.loop.lastSweepAt)}
46
+ {" · "}{dailyCapWords(state.loop)}
47
+ </p>
48
+ ) : null}
49
+ {state.stopReasonSentence ? (
50
+ <p className="text-sm font-semibold text-foreground" data-reference-live-agreement-stopped>{state.stopReasonSentence}</p>
51
+ ) : null}
52
+ {state.kind === "not_enough" ? (
53
+ <p className="text-sm text-foreground" data-reference-live-agreement-not-enough>
54
+ Not enough comparisons yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
55
+ </p>
56
+ ) : <MeasurementFigures measurement={state.measurement} />}
57
+ <p className="text-xs text-foreground">
58
+ Suggestions compared with the decisions editors made afterwards. Computed {formatSuggestionTime(agreement?.computedAt ?? null)}.
59
+ </p>
60
+ </section>
61
+ );
62
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { AdministrationDelegationsPanel } from "./administration-delegations-panel";
3
4
  import { WikiConceptsView } from "./wiki-concepts-view";
4
5
  import { Hub } from "aws-amplify/utils";
5
6
  import { fetchAuthSession } from "aws-amplify/auth";
@@ -54,6 +55,7 @@ import {
54
55
  type KnowledgeQueryResponse,
55
56
  type NewsroomRecordPage,
56
57
  loadCyclotronStatus,
58
+ loadCyclotronShadowAgreement,
57
59
  requestCyclotronPriming,
58
60
  loadCyclotronPrimingRequests,
59
61
  requestCyclotronReviewRate,
@@ -156,7 +158,10 @@ import {
156
158
  REFERENCE_REJECTION_REASON_CODES,
157
159
  referenceCurationStatusForAction,
158
160
  } from "../lib/reference-policy";
161
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
159
162
  import { currentRelevanceDecisions } from "../lib/relevance-decisions";
163
+ import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
164
+ import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
160
165
  import {
161
166
  buildPrimingRequest,
162
167
  buildReviewRateRequest,
@@ -238,7 +243,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
238
243
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
239
244
  type AssignmentDeskViewMode = "queue" | "budget";
240
245
  type UserRoleAction = "grant" | "revoke";
241
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "signups";
246
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups";
242
247
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
243
248
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
244
249
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -784,7 +789,22 @@ function NewsDeskDashboard({
784
789
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
785
790
  const [messages, setMessages] = useState(dashboard.messages);
786
791
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
787
- const relevanceDecisions = useMemo(() => currentRelevanceDecisions(semanticRelations), [semanticRelations]);
792
+ const cyclotronPreviewFixtures = useMemo(
793
+ () => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
794
+ [references],
795
+ );
796
+ const relevanceDecisions = useMemo(
797
+ () => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
798
+ [cyclotronPreviewFixtures, semanticRelations],
799
+ );
800
+ const delegatedCurations = useMemo(
801
+ () => delegatedCurationsByReference(cyclotronPreviewFixtures ? [...messages, ...cyclotronPreviewFixtures.messages] : messages),
802
+ [cyclotronPreviewFixtures, messages],
803
+ );
804
+ const humanCuratedReferenceLineageIds = useMemo(
805
+ () => referencesWithHumanLatestCuration(cyclotronPreviewFixtures ? [...messages, ...cyclotronPreviewFixtures.messages] : messages),
806
+ [cyclotronPreviewFixtures, messages],
807
+ );
788
808
  const relevanceCyclotronId = useMemo(
789
809
  () => [...relevanceDecisions.values()].find((decision) => decision.cyclotronId)?.cyclotronId ?? null,
790
810
  [relevanceDecisions],
@@ -819,6 +839,19 @@ function NewsDeskDashboard({
819
839
  .catch(() => { if (!cancelled) setCyclotronStatus(null); });
820
840
  return () => { cancelled = true; };
821
841
  }, [dashboard.isDemo, relevanceCyclotronId]);
842
+ const [shadowAgreement, setShadowAgreement] = useState<ShadowAgreement | null>(null);
843
+ useEffect(() => {
844
+ if (cyclotronPreviewFixtures) {
845
+ setShadowAgreement(cyclotronPreviewFixtures.shadowAgreement);
846
+ return;
847
+ }
848
+ if (!relevanceCyclotronId || dashboard.isDemo) return;
849
+ let cancelled = false;
850
+ loadCyclotronShadowAgreement(relevanceCyclotronId)
851
+ .then((loaded) => { if (!cancelled) setShadowAgreement(loaded); })
852
+ .catch(() => { if (!cancelled) setShadowAgreement(null); });
853
+ return () => { cancelled = true; };
854
+ }, [cyclotronPreviewFixtures, dashboard.isDemo, relevanceCyclotronId]);
822
855
  const [semanticNodes, setSemanticNodes] = useState(dashboard.semanticNodes);
823
856
  const [assignments, setAssignments] = useState(dashboard.assignments);
824
857
  const [assignmentEvents, setAssignmentEvents] = useState(dashboard.assignmentEvents);
@@ -3217,6 +3250,9 @@ function NewsDeskDashboard({
3217
3250
  },
3218
3251
  }}
3219
3252
  decisions={relevanceDecisions}
3253
+ humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3254
+ delegatedCurations={delegatedCurations}
3255
+ shadowAgreement={shadowAgreement}
3220
3256
  onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
3221
3257
  const request = buildReviewRateRequest(choice, authState.label);
3222
3258
  if (dashboard.isDemo) return;
@@ -4463,6 +4499,15 @@ function AdministrationDeskView({
4463
4499
  <strong>Procedures</strong>
4464
4500
  <span>Procedure registry and versioned Tactus code</span>
4465
4501
  </Link>
4502
+ <Link
4503
+ href={administrationPanelHref("delegations", isDemo)}
4504
+ data-news-desk-admin-nav="delegations"
4505
+ data-active={administrationPanel === "delegations" || undefined}
4506
+ onClick={() => onPanelChange("delegations")}
4507
+ >
4508
+ <strong>Agent delegations</strong>
4509
+ <span>Let an agent curate References for you</span>
4510
+ </Link>
4466
4511
  {signupPluginEnabled() ? (
4467
4512
  <Link
4468
4513
  href={administrationPanelHref("signups", isDemo)}
@@ -4477,6 +4522,7 @@ function AdministrationDeskView({
4477
4522
  </nav>}
4478
4523
  <div className="news-desk-settings-panel">
4479
4524
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4525
+ {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4480
4526
  {administrationPanel === "users" ? (
4481
4527
  <AdministrationUsersPanel
4482
4528
  canManageUsers={canManageUsers}
@@ -13181,7 +13227,7 @@ function signupPluginEnabled(): boolean {
13181
13227
  }
13182
13228
 
13183
13229
  function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
13184
- if (value === "policies" || value === "sections" || value === "procedures") return value;
13230
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
13185
13231
  if (value === "signups" && signupPluginEnabled()) return value;
13186
13232
  return "users";
13187
13233
  }