@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.
- package/amplify/data/resource.js +134 -3
- package/amplify/data/resource.ts +145 -3
- package/amplify/data/schema.js +131 -0
- package/amplify/data/schema.ts +142 -0
- package/amplify/functions/category-action/handler.ts +272 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +115 -0
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
- package/amplify/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/archive/page.tsx +3 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +8 -1
- package/app/newsroom/articles/page.tsx +10 -0
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/administration-delegations-panel.tsx +156 -0
- package/components/curation-delegation-client.ts +104 -0
- package/components/item-stream-view.tsx +63 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +154 -240
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-article-editor.tsx +138 -40
- package/components/newsroom-assignment-detail.tsx +79 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/newsroom-board-view.tsx +153 -43
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-newsprint-board.css +5 -46
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +36 -13
- package/components/newsroom-organizer.css +269 -13
- package/components/newsroom-references-view.tsx +118 -23
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +131 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +280 -679
- package/components/ui/sheet.tsx +10 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/category-repository.ts +1 -1
- package/lib/chrome-text.ts +504 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -0
- package/lib/content-item-lifecycle.ts +34 -5
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +69 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/front-mosaic.ts +1133 -0
- package/lib/furniture.ts +385 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/item-stream.ts +47 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +40 -11
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/local-preview-seed.ts +286 -0
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-assignments.ts +14 -1
- package/lib/newsroom-board.ts +99 -47
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +9 -16
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/pretext-layout.ts +22 -0
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-account-client.ts +90 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/reader-static-params.ts +2 -1
- package/lib/relevance-decisions.ts +29 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +28 -1
- package/lib/site-env.ts +5 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/lib/site-stack.ts +1 -1
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/text-columns.ts +251 -0
- package/lib/wiki.ts +56 -0
- package/package.json +46 -3
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +34 -15
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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">“{delegated.instruction}”</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
|
+
}
|