@anthusai/papyrus 1.3.0 → 1.4.0-next.10

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 (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
@@ -4,15 +4,24 @@ import {
4
4
  type PrimingProofReport,
5
5
  } from "../lib/priming-proof-report";
6
6
 
7
- function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
7
+ function ProofMethod({ heldOutSize, teachingSource, design }: { heldOutSize: number | null; teachingSource: string | null; design: string | null }) {
8
8
  return (
9
9
  <section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
10
10
  <h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
11
- <p>
12
- Editors already decided which candidate sources belong in this publication. A fixed, balanced sample of those
13
- decisions{heldOutSize === null ? "" : ` (${heldOutSize} sources)`} is frozen as the held-out set and never used
14
- for teaching. A classifier that always gives the same answer agrees on half of it.
15
- </p>
11
+ {design === null ? (
12
+ <p>
13
+ Editors already decided which candidate sources belong in this publication. A fixed, balanced sample of those
14
+ decisions{heldOutSize === null ? "" : ` (${heldOutSize} sources)`} is frozen as the held-out set and never used
15
+ for teaching. A classifier that always gives the same answer agrees on half of it.
16
+ </p>
17
+ ) : (
18
+ <p>
19
+ Editors already decided which candidate sources belong in this publication
20
+ {heldOutSize === null ? "" : `, ${heldOutSize} of them`}. Design: {design}. No source is ever judged by a
21
+ classifier that was taught from that same source. The two outcomes are not equally common, so read agreement
22
+ beside precision and recall: a classifier that always gives the majority answer would still agree often.
23
+ </p>
24
+ )}
16
25
  <p>
17
26
  Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
18
27
  check after Papyrus extracts them, and mark publish or reject.
@@ -33,7 +42,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
33
42
  <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
34
43
  <p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
35
44
  </header>
36
- <ProofMethod heldOutSize={null} teachingSource={null} />
45
+ <ProofMethod heldOutSize={null} teachingSource={null} design={null} />
37
46
  </main>
38
47
  );
39
48
  }
@@ -84,7 +93,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
84
93
  </table>
85
94
  </div>
86
95
  </section>
87
- <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
96
+ <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} design={report.heldOut.design ?? null} />
88
97
  <p className="break-all text-xs text-muted-foreground">
89
98
  Held-out fingerprint {report.heldOut.fingerprint}
90
99
  {report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Check } from "lucide-react";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
7
+ import { createProspectusAccount, loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
8
+
9
+ /** Local preview: add ?no-accounts=1 to the address to see a signed-in person with no publishing account. */
10
+ function previewHasNoAccounts(): boolean {
11
+ return typeof window !== "undefined" && new URLSearchParams(window.location.search).has("no-accounts");
12
+ }
13
+
14
+ function previewAccountState(): ProspectusAccountState {
15
+ if (previewHasNoAccounts()) return { status: "signedIn", publishingAccounts: [], selectedAccountId: null, cyclotronLabAccess: false };
16
+ return {
17
+ status: "signedIn",
18
+ publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
19
+ selectedAccountId: getLocalPreviewTenantId(),
20
+ cyclotronLabAccess: true,
21
+ };
22
+ }
23
+
24
+ const ACCOUNT_STATE_EVENT = "papyrus:prospectus-account-state";
25
+
26
+ function summaryOf(state: ProspectusAccountState): string | null {
27
+ if (state.status !== "signedIn") return null;
28
+ const selected = state.publishingAccounts.find(account => account.accountId === state.selectedAccountId);
29
+ return selected ? `${selected.name ?? selected.accountId} · ${roleLabel(selected.role)}` : "Choose an account";
30
+ }
31
+
32
+ const roleLabel = (role: string) => role.replace(/_/g, " ");
33
+
34
+ /** The signed-in person's current publishing account as "Name · role", or null when the site has no Prospectus accounts. */
35
+ export function useProspectusAccountSummary(): string | null {
36
+ const [summary, setSummary] = useState<string | null>(null);
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+ const returnPath = `${window.location.pathname}${window.location.search}`;
40
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath)).then(loaded => {
41
+ if (!cancelled) setSummary(summaryOf(loaded));
42
+ });
43
+ const onChange = (event: Event) => setSummary(summaryOf((event as CustomEvent<ProspectusAccountState>).detail));
44
+ window.addEventListener(ACCOUNT_STATE_EVENT, onChange);
45
+ return () => { cancelled = true; window.removeEventListener(ACCOUNT_STATE_EVENT, onChange); };
46
+ }, []);
47
+ return summary;
48
+ }
49
+
50
+ /**
51
+ * The publishing account the signed-in person is acting as. It appears only on sites that have Prospectus
52
+ * accounts configured; a site with its own user pool never shows it. Choosing an account records the choice
53
+ * in the sign-in session and reloads so every page reads the new account.
54
+ */
55
+ export function ProspectusAccountChooser() {
56
+ const [state, setState] = useState<ProspectusAccountState>({ status: "unavailable" });
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [pending, setPending] = useState<string | null>(null);
59
+ const [creating, setCreating] = useState(false);
60
+ const [newName, setNewName] = useState("");
61
+ useEffect(() => {
62
+ let cancelled = false;
63
+ const returnPath = `${window.location.pathname}${window.location.search}`;
64
+ (isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath))
65
+ .then(loaded => { if (!cancelled) setState(loaded); });
66
+ return () => { cancelled = true; };
67
+ }, []);
68
+ if (state.status === "unavailable") return null;
69
+ if (state.status === "signedOut") {
70
+ return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-out">
71
+ <a className="organizer-account-chooser-link" href={state.signInUrl}>Sign in with Prospectus</a>
72
+ </div>;
73
+ }
74
+ const choose = async (accountId: string) => {
75
+ if (accountId === state.selectedAccountId || pending) return;
76
+ setPending(accountId); setError(null);
77
+ if (isLocalPreviewEnabled()) {
78
+ setLocalPreviewTenantId(accountId);
79
+ window.location.reload();
80
+ return;
81
+ }
82
+ const result = await selectProspectusAccount(accountId);
83
+ setPending(null);
84
+ if ("error" in result) { setError(result.error); return; }
85
+ window.location.reload();
86
+ };
87
+ const create = async () => {
88
+ if (pending) return;
89
+ setPending("create"); setError(null);
90
+ if (isLocalPreviewEnabled()) {
91
+ const name = newName.trim() || "New publishing account";
92
+ setState({ status: "signedIn", publishingAccounts: [{ accountId: "preview-created-account", name, role: "owner" }], selectedAccountId: "preview-created-account", cyclotronLabAccess: false });
93
+ setPending(null); setCreating(false);
94
+ return;
95
+ }
96
+ const result = await createProspectusAccount(newName);
97
+ setPending(null);
98
+ if ("error" in result) { setError(result.error); return; }
99
+ window.location.reload();
100
+ };
101
+ return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-in">
102
+ <p className="organizer-account-chooser-heading">Publishing account</p>
103
+ {state.publishingAccounts.length === 0
104
+ ? <p className="organizer-account-chooser-note">No publishing accounts yet.</p>
105
+ : <ul role="listbox" aria-label="Publishing account">
106
+ {state.publishingAccounts.map(account => {
107
+ const selected = account.accountId === state.selectedAccountId;
108
+ return <li key={account.accountId} role="presentation">
109
+ <button aria-selected={selected} data-selected={selected || undefined} disabled={pending !== null} onClick={() => { void choose(account.accountId); }} role="option" type="button">
110
+ <span><strong>{account.name ?? account.accountId}</strong><small>{roleLabel(account.role)}</small></span>
111
+ {selected ? <Check size={14} aria-label="Selected" /> : null}
112
+ </button>
113
+ </li>;
114
+ })}
115
+ </ul>}
116
+ {state.publishingAccounts.length === 0 ? (
117
+ creating
118
+ ? <form className="organizer-account-create" data-prospectus-create-account onSubmit={event => { event.preventDefault(); void create(); }}>
119
+ <label>Account name<input autoFocus disabled={pending !== null} maxLength={80} onChange={event => setNewName(event.target.value)} placeholder="Optional" type="text" value={newName} /></label>
120
+ <span>
121
+ <button data-prospectus-create-account-submit disabled={pending !== null} type="submit">{pending === "create" ? "Creating…" : "Create account"}</button>
122
+ <button disabled={pending !== null} onClick={() => setCreating(false)} type="button">Cancel</button>
123
+ </span>
124
+ </form>
125
+ : <button className="organizer-account-create-start" data-prospectus-create-account-start onClick={() => setCreating(true)} type="button">Create publishing account</button>
126
+ ) : null}
127
+ {!state.selectedAccountId && state.publishingAccounts.length > 0 ? <p className="organizer-account-chooser-note">Choose the account you are working as.</p> : null}
128
+ {state.cyclotronLabAccess ? <p className="organizer-account-chooser-note" data-cyclotron-lab-access>Cyclotron lab access is on for you.</p> : null}
129
+ {error ? <p className="organizer-account-chooser-note" role="alert">{error}</p> : null}
130
+ </div>;
131
+ }
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { primingStateText, type PrimingRequestView } from "../lib/relevance-status";
5
+
6
+ export type PrimingFormInput = { url: string; label: string; explanation: string };
7
+
8
+ /**
9
+ * A compact form that primes the running relevance cyclotron with a pasted
10
+ * URL. The next sweep reads the page, adds it to the cyclotron's example list
11
+ * and records the outcome, which this list shows beside each saved request.
12
+ */
13
+ export function ReferenceCyclotronPriming({
14
+ labels,
15
+ requests,
16
+ onRequestPriming,
17
+ }: {
18
+ labels: readonly string[];
19
+ requests: PrimingRequestView[];
20
+ onRequestPriming: (input: PrimingFormInput) => Promise<void> | void;
21
+ }) {
22
+ const [url, setUrl] = useState("");
23
+ const [label, setLabel] = useState(labels[0] ?? "");
24
+ const [explanation, setExplanation] = useState("");
25
+ const [message, setMessage] = useState<string | null>(null);
26
+
27
+ async function submit() {
28
+ setMessage("Saving…");
29
+ try {
30
+ await onRequestPriming({ url, label, explanation });
31
+ setUrl("");
32
+ setExplanation("");
33
+ setMessage("Saved. The next sweep reads the page and adds it to the cyclotron.");
34
+ } catch (error) {
35
+ setMessage(error instanceof Error ? error.message : String(error));
36
+ }
37
+ }
38
+
39
+ return (
40
+ <section aria-label="Prime the relevance cyclotron" className="space-y-2 text-sm" data-reference-cyclotron-priming>
41
+ <form
42
+ className="flex flex-wrap items-end gap-3"
43
+ onSubmit={(event) => {
44
+ event.preventDefault();
45
+ void submit();
46
+ }}
47
+ >
48
+ <label className="flex min-w-64 flex-1 flex-col gap-1">
49
+ <span className="text-xs text-muted-foreground">Prime with a URL</span>
50
+ <input
51
+ aria-label="Priming URL"
52
+ onChange={(event) => setUrl(event.target.value)}
53
+ placeholder="https://"
54
+ required
55
+ type="url"
56
+ value={url}
57
+ />
58
+ </label>
59
+ <label className="flex flex-col gap-1">
60
+ <span className="text-xs text-muted-foreground">Label</span>
61
+ <select aria-label="Priming label" onChange={(event) => setLabel(event.target.value)} value={label}>
62
+ {labels.map((choice) => (
63
+ <option key={choice} value={choice}>{choice}</option>
64
+ ))}
65
+ </select>
66
+ </label>
67
+ <label className="flex min-w-48 flex-1 flex-col gap-1">
68
+ <span className="text-xs text-muted-foreground">Why (optional)</span>
69
+ <input aria-label="Priming explanation" onChange={(event) => setExplanation(event.target.value)} type="text" value={explanation} />
70
+ </label>
71
+ <button className="cyclotron-review-secondary" type="submit">Save</button>
72
+ {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
73
+ </form>
74
+ {requests.length ? (
75
+ <ul aria-label="Saved priming requests" className="space-y-1 text-xs text-muted-foreground">
76
+ {requests.slice(0, 10).map((view) => (
77
+ <li data-priming-request-state={view.outcome?.state ?? "waiting"} key={view.request.requestId}>
78
+ <span className="font-medium">{view.request.label}</span> {view.request.url} · {primingStateText(view)}
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ ) : null}
83
+ </section>
84
+ );
85
+ }
@@ -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
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { formatPercentage } from "../lib/cyclotron-suggestions";
4
+ import { sectionAgreementIsMeasured, type SectionAgreement, type SectionSuggestion, type SiteSectionAgreement } from "../lib/site-section-suggestions";
5
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT } from "../lib/cyclotron-suggestions";
6
+
7
+ export type SectionChoice = { id: string; title: string };
8
+
9
+ function titleOf(sections: SectionChoice[], sectionId: string): string {
10
+ return sections.find((section) => section.id === sectionId)?.title ?? sectionId;
11
+ }
12
+
13
+ function agreementWords(agreement: SectionAgreement | undefined): string {
14
+ if (!agreement) return "no editor decisions compared yet";
15
+ if (!sectionAgreementIsMeasured(agreement)) {
16
+ return `${agreement.comparisons} of ${MINIMUM_COMPARISONS_FOR_AGREEMENT} editor decisions compared, too few to measure`;
17
+ }
18
+ return `agreement ${formatPercentage(agreement.agreement)}, ${agreement.comparisons} editor decisions compared`;
19
+ }
20
+
21
+ /**
22
+ * The suggested Section for a pending reference, how likely the other Sections
23
+ * are, and how often this Section's suggestions matched editors. Advisory: no
24
+ * Section is chosen until an editor picks one.
25
+ */
26
+ export function SectionSuggestionBlock({
27
+ agreement,
28
+ referenceLineageId,
29
+ sections,
30
+ suggestion,
31
+ }: {
32
+ agreement: SiteSectionAgreement | null;
33
+ referenceLineageId: string;
34
+ sections: SectionChoice[];
35
+ suggestion: SectionSuggestion;
36
+ }) {
37
+ const figures = agreement?.sections.find((entry) => entry.sectionId === suggestion.sectionId);
38
+ const others = suggestion.probabilities.filter((entry) => entry.sectionId !== suggestion.sectionId).slice(0, 3);
39
+ return (
40
+ <section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
41
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
42
+ <p className="text-sm text-foreground">
43
+ The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
44
+ {suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
45
+ </p>
46
+ {others.length ? (
47
+ <p className="text-xs text-foreground" data-reference-section-alternatives>
48
+ Next most likely: {others.map((entry) => `${titleOf(sections, entry.sectionId)} ${formatPercentage(entry.probability)}`).join(", ")}.
49
+ </p>
50
+ ) : null}
51
+ <p className="text-xs text-foreground" data-reference-section-agreement>
52
+ For {titleOf(sections, suggestion.sectionId)}: {agreementWords(figures)}.
53
+ </p>
54
+ <p className="text-xs text-foreground" data-reference-section-notice>
55
+ A suggestion never places a reference. The Section you choose when you accept does.
56
+ </p>
57
+ </section>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Picks the Section an accepted reference belongs to. The choice is sent with
63
+ * the accept and is what the cyclotron learns from; nothing is preselected.
64
+ */
65
+ export function SectionPicker({
66
+ agreement,
67
+ chosenSectionId,
68
+ disabled,
69
+ onChoose,
70
+ referenceLineageId,
71
+ sections,
72
+ suggestedSectionId,
73
+ }: {
74
+ agreement: SiteSectionAgreement | null;
75
+ chosenSectionId: string | null;
76
+ disabled?: boolean;
77
+ onChoose: (sectionId: string | null) => void;
78
+ referenceLineageId: string;
79
+ sections: SectionChoice[];
80
+ suggestedSectionId: string | null;
81
+ }) {
82
+ return (
83
+ <fieldset className="space-y-1 border-l-2 border-foreground pl-3" data-reference-section-picker={referenceLineageId} disabled={disabled}>
84
+ <legend className="text-xs font-semibold uppercase tracking-wide text-foreground">Section when you accept</legend>
85
+ <label className="flex items-center gap-2 text-sm text-foreground">
86
+ <input checked={chosenSectionId === null} name={`section-${referenceLineageId}`} onChange={() => onChoose(null)} type="radio" />
87
+ <span className={chosenSectionId === null ? "font-semibold" : undefined}>No Section</span>
88
+ </label>
89
+ {sections.map((section) => {
90
+ const figures = agreement?.sections.find((entry) => entry.sectionId === section.id);
91
+ const chosen = chosenSectionId === section.id;
92
+ return (
93
+ <label className="flex flex-wrap items-center gap-x-2 text-sm text-foreground" key={section.id}>
94
+ <input checked={chosen} name={`section-${referenceLineageId}`} onChange={() => onChoose(section.id)} type="radio" value={section.id} />
95
+ <span className={chosen ? "font-semibold underline underline-offset-4" : undefined}>{section.title}</span>
96
+ {section.id === suggestedSectionId ? <span className="text-xs">suggested</span> : null}
97
+ {sectionAgreementIsMeasured(figures) ? <span className="text-xs">agreement {formatPercentage(figures?.agreement ?? null)}</span> : null}
98
+ </label>
99
+ );
100
+ })}
101
+ <p className="text-xs text-foreground">The Section you choose is a training label for the cyclotron.</p>
102
+ </fieldset>
103
+ );
104
+ }