@anthusai/papyrus 1.0.0-next.9 → 1.1.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.
- package/amplify/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatProofPercentage,
|
|
3
|
+
primingProofHeadline,
|
|
4
|
+
type PrimingProofReport,
|
|
5
|
+
} from "../lib/priming-proof-report";
|
|
6
|
+
|
|
7
|
+
function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
|
|
8
|
+
return (
|
|
9
|
+
<section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
|
|
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>
|
|
16
|
+
<p>
|
|
17
|
+
Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
|
|
18
|
+
check after Papyrus extracts them, and mark publish or reject.
|
|
19
|
+
{teachingSource === null ? "" : ` This run taught from ${teachingSource}.`} Each point below starts a fresh
|
|
20
|
+
classifier that sees only the first N of those labels, then decides
|
|
21
|
+
every held-out source once. Nothing else changes between points: the same decision model, the publication's
|
|
22
|
+
mission and policies as the only rubric, and no automatic rubric rewriting.
|
|
23
|
+
</p>
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PrimingProofPage({ report }: { report: PrimingProofReport | null }) {
|
|
29
|
+
if (report === null) {
|
|
30
|
+
return (
|
|
31
|
+
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10" data-priming-proof="pending">
|
|
32
|
+
<header className="space-y-2 border-b pb-4">
|
|
33
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
34
|
+
<p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
|
|
35
|
+
</header>
|
|
36
|
+
<ProofMethod heldOutSize={null} teachingSource={null} />
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const { baseline, latest, agreementChangeInPoints } = primingProofHeadline(report);
|
|
41
|
+
const [positiveLabel, negativeLabel] = [report.positiveLabel, report.labels.find((label) => label !== report.positiveLabel)];
|
|
42
|
+
return (
|
|
43
|
+
<main className="mx-auto max-w-3xl space-y-8 px-4 py-10" data-priming-proof="published">
|
|
44
|
+
<header className="space-y-3 border-b pb-4">
|
|
45
|
+
<h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
|
|
46
|
+
<p className="text-lg" data-priming-proof-headline>
|
|
47
|
+
Agreement with editors went from <strong>{formatProofPercentage(baseline.agreement)}</strong> with no labels to{" "}
|
|
48
|
+
<strong>{formatProofPercentage(latest.agreement)}</strong> after {latest.labels} labels
|
|
49
|
+
{agreementChangeInPoints === null ? "" : ` (${agreementChangeInPoints >= 0 ? "+" : ""}${agreementChangeInPoints} points)`}.
|
|
50
|
+
</p>
|
|
51
|
+
<p className="text-sm text-muted-foreground">
|
|
52
|
+
{report.publication} · question: “{report.question}” · held-out set of {report.heldOut.size} sources frozen{" "}
|
|
53
|
+
{report.heldOut.frozenAt.slice(0, 10)} · run {report.ranAt.slice(0, 10)}
|
|
54
|
+
{report.decisionModel ? ` · decision model ${report.decisionModel}` : ""}
|
|
55
|
+
</p>
|
|
56
|
+
</header>
|
|
57
|
+
<section aria-labelledby="priming-proof-points" className="space-y-2">
|
|
58
|
+
<h2 id="priming-proof-points" className="text-base font-semibold uppercase tracking-wide">Before and after</h2>
|
|
59
|
+
<div className="overflow-x-auto">
|
|
60
|
+
<table className="w-full text-left text-sm tabular-nums">
|
|
61
|
+
<thead>
|
|
62
|
+
<tr className="border-b">
|
|
63
|
+
<th scope="col" className="py-2 pr-4">Editor labels</th>
|
|
64
|
+
<th scope="col" className="py-2 pr-4">Agreement</th>
|
|
65
|
+
<th scope="col" className="py-2 pr-4">Precision ({positiveLabel})</th>
|
|
66
|
+
<th scope="col" className="py-2 pr-4">Recall ({positiveLabel})</th>
|
|
67
|
+
<th scope="col" className="py-2">Labels used</th>
|
|
68
|
+
</tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody>
|
|
71
|
+
{report.points.map((point) => (
|
|
72
|
+
<tr key={point.labels} className="border-b" data-priming-proof-point={point.labels}>
|
|
73
|
+
<td className="py-2 pr-4">{point.labels}</td>
|
|
74
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.agreement)}</td>
|
|
75
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.precision)}</td>
|
|
76
|
+
<td className="py-2 pr-4">{formatProofPercentage(point.recall)}</td>
|
|
77
|
+
<td className="py-2">
|
|
78
|
+
{point.labelsByClass[positiveLabel] ?? 0} {positiveLabel}
|
|
79
|
+
{negativeLabel ? `, ${point.labelsByClass[negativeLabel] ?? 0} ${negativeLabel}` : ""}
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
))}
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
<ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
|
|
88
|
+
<p className="break-all text-xs text-muted-foreground">
|
|
89
|
+
Held-out fingerprint {report.heldOut.fingerprint}
|
|
90
|
+
{report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
|
|
91
|
+
</p>
|
|
92
|
+
</main>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useRef,useState} from 'react';
|
|
4
|
+
import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/review-control';
|
|
5
|
+
import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
|
|
6
|
+
import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
|
|
7
|
+
|
|
8
|
+
function requestId(){
|
|
9
|
+
const bytes=crypto.getRandomValues(new Uint8Array(16));
|
|
10
|
+
return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Data-only shared operator panel. The authorized host owns reads, writes and refresh. */
|
|
14
|
+
type PublicationPanelProps={mode:'embedded'|'remote';status:PublicationStatus;history:PublicationHistory;onReview?:(payload:PublicationReviewPayload)=>Promise<void>;onSteer?:(classifier:string)=>void};
|
|
15
|
+
export function PublicationCyclotronPanel(props:PublicationPanelProps){
|
|
16
|
+
assertPublicationScope(props.status,props.history);
|
|
17
|
+
// Remount all drafts, retry identities, pending messages and busy state on scope change.
|
|
18
|
+
return <PublicationControls key={publicationScopeKey(props.status)} {...props}/>;
|
|
19
|
+
}
|
|
20
|
+
function PublicationControls({mode,status,history,onReview,onSteer}:PublicationPanelProps){
|
|
21
|
+
assertPublicationScope(status,history);
|
|
22
|
+
const context=publicationScopeKey(status);const current=useRef(context);current.current=context;
|
|
23
|
+
const [message,setMessage]=useState<{context:string;key:string;error:boolean;text:string}|null>(null);const [busy,setBusy]=useState<string|null>(null);
|
|
24
|
+
const [editing,setEditing]=useState<Record<string,boolean>>({});const requests=useRef<Record<string,{signature:string;id:string}>>({});
|
|
25
|
+
async function submit(row:PublicationHistoryRow,classifier:string,review:ReviewControlReview){
|
|
26
|
+
const key=`${context}:${row.decision.decisionId}:${classifier}:${row.context?.contextId??'legacy'}`;
|
|
27
|
+
try{
|
|
28
|
+
const signature=JSON.stringify(review);const previous=requests.current[key];
|
|
29
|
+
const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
|
|
30
|
+
const payload=publicationReviewPayload(status,row,classifier,review,id);
|
|
31
|
+
if(!onReview)throw new Error('This host has not enabled human review');
|
|
32
|
+
setBusy(key);setMessage(null);await onReview(payload);
|
|
33
|
+
if(current.current===context){setMessage({context,key,error:false,text:'Human label acknowledged.'});setEditing(old=>({...old,[key]:false}));}
|
|
34
|
+
}catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Review failed; its outcome must be checked before retrying.'});}
|
|
35
|
+
finally{if(current.current===context)setBusy(null);}
|
|
36
|
+
}
|
|
37
|
+
return <section data-publication-scope={context} aria-label="Publication Cyclotron controls" className="space-y-4">
|
|
38
|
+
<header><h2 className="text-lg font-semibold">Cyclotron · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Classifier data owned by this deployment.':'Canonical classifier data owned by the shared remote lab.'}</p></header>
|
|
39
|
+
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
|
+
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
+
{onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Evaluate classifier proposal</button>:null}
|
|
42
|
+
{history.history.map(row=>{
|
|
43
|
+
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
|
+
const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
|
|
45
|
+
return <article key={key} className="space-y-2 rounded-md border p-3">
|
|
46
|
+
{historical?<p className="text-sm">Historical source or binding revision · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Canonical feedback changed; provenance reconciliation is required before replay.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
|
|
47
|
+
<p className="whitespace-pre-wrap text-sm">{String(row.item.abstract??row.item.text??'')}</p>
|
|
48
|
+
{!saved||editing[key]?<p className="text-sm" role="status">{historical?'This source revision is read-only.':!onReview?'Human review is unavailable in this session.':busy===key?'Submitting review… Your selection is preserved; wait for acknowledgement.':'Choose a label to enable Submit review. Explanation is optional.'}</p>:null}
|
|
49
|
+
<ReviewControl key={`${key}:${Boolean(editing[key])}`} itemId={`${row.decision.itemId}:${Array.from(new TextEncoder().encode(context+':'+output.id+':'+(row.context?.contextId??'legacy')),byte=>byte.toString(16).padStart(2,'0')).join('')}`} decision={{decisionId:row.decision.decisionId,...prediction,classes:output.classes}} question={output.question} mode="labels" positiveLabel={output.positiveLabel??undefined} disabled={Boolean(busy)||!onReview||historical} value={saved&&!editing[key]?{label:saved.label,reasonCode:saved.reasonCode,explanation:saved.explanation}:undefined} onReview={review=>void submit(row,output.id,{...review,itemId:row.decision.itemId})}/>
|
|
50
|
+
{message?.context===context&&message.key===key?<p role={message.error?'alert':'status'} className="text-sm">{message.text}</p>:null}
|
|
51
|
+
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Correct human label</button>:null}
|
|
52
|
+
<details><summary>Decision provenance and version</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
|
|
53
|
+
</article>;
|
|
54
|
+
})}
|
|
55
|
+
</section>)}
|
|
56
|
+
</section>;
|
|
57
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { signInWithRedirect, signOut } from "aws-amplify/auth";
|
|
4
4
|
import { Hub } from "aws-amplify/utils";
|
|
5
|
-
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
6
6
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
7
7
|
import { loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
|
|
8
8
|
|
|
@@ -12,6 +12,7 @@ type ReaderAuthControlProps = {
|
|
|
12
12
|
postAuthPath?: string;
|
|
13
13
|
showIdentity?: boolean;
|
|
14
14
|
authState?: ReaderAuthSnapshot;
|
|
15
|
+
renderAction?: (action: { disabled: boolean; onClick: () => void; signedIn: boolean }) => ReactNode;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
const POST_AUTH_PATH_KEY = "papyrus:reader-auth:post-auth-path";
|
|
@@ -22,6 +23,7 @@ export function ReaderAuthControl({
|
|
|
22
23
|
postAuthPath,
|
|
23
24
|
showIdentity = false,
|
|
24
25
|
authState: externalAuthState,
|
|
26
|
+
renderAction,
|
|
25
27
|
}: ReaderAuthControlProps) {
|
|
26
28
|
const [authState, setAuthState] = useState<ReaderAuthSnapshot>(externalAuthState ?? { status: "loading", label: "Checking sign-in" });
|
|
27
29
|
const [busy, setBusy] = useState(false);
|
|
@@ -124,19 +126,20 @@ export function ReaderAuthControl({
|
|
|
124
126
|
|
|
125
127
|
const signedIn = authState.status === "signedIn";
|
|
126
128
|
const actionLabel = signedIn ? "LOGOUT" : "LOGIN";
|
|
129
|
+
const Container = renderAction ? "div" : "span";
|
|
127
130
|
return (
|
|
128
|
-
<
|
|
131
|
+
<Container className={className ? `reader-auth ${className}` : "reader-auth"} aria-live="polite" data-footer-utility={dataFooterUtility}>
|
|
129
132
|
{signedIn && showIdentity ? <span className="reader-auth__identity">{authState.label}</span> : null}
|
|
130
133
|
{error ? <span className="reader-auth__error">{error}</span> : null}
|
|
131
|
-
<button
|
|
134
|
+
{renderAction ? renderAction({ disabled: authState.status === "loading" || busy, onClick: signedIn ? signOutCurrentUser : signIn, signedIn }) : <button
|
|
132
135
|
className="reader-auth__button"
|
|
133
136
|
disabled={authState.status === "loading" || busy}
|
|
134
137
|
onClick={signedIn ? signOutCurrentUser : signIn}
|
|
135
138
|
type="button"
|
|
136
139
|
>
|
|
137
140
|
{actionLabel}
|
|
138
|
-
</button>
|
|
139
|
-
</
|
|
141
|
+
</button>}
|
|
142
|
+
</Container>
|
|
140
143
|
);
|
|
141
144
|
}
|
|
142
145
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useCallback,useEffect,useRef,useState} from 'react';
|
|
4
|
+
import {useNewsroomCuration} from './newsroom-curation-provider';
|
|
5
|
+
import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
|
|
6
|
+
import {ReferencePriming} from './reference-priming';
|
|
7
|
+
import {Button} from './ui/button';
|
|
8
|
+
import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
|
|
9
|
+
import type {CurationSession} from '../lib/curation-client';
|
|
10
|
+
|
|
11
|
+
export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
|
|
12
|
+
const session=useNewsroomCuration();
|
|
13
|
+
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
|
|
17
|
+
const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
|
|
18
|
+
const [error,setError]=useState('');
|
|
19
|
+
const [loading,setLoading]=useState(true);
|
|
20
|
+
const [primingMessage,setPrimingMessage]=useState('');
|
|
21
|
+
const [steering,setSteering]=useState<string|null>(null);
|
|
22
|
+
const [rubric,setRubric]=useState('');
|
|
23
|
+
const [explanation,setExplanation]=useState('');
|
|
24
|
+
const [busy,setBusy]=useState(false);
|
|
25
|
+
const [trial,setTrial]=useState<{fingerprint:string;improved:boolean}|null>(null);
|
|
26
|
+
const [steeringMessage,setSteeringMessage]=useState('');
|
|
27
|
+
const steeringRequests=useRef<Record<string,string>>({});
|
|
28
|
+
const canReview=!disabled&&session.bootstrap.capabilities.includes('review');
|
|
29
|
+
const canSteer=!disabled&&session.bootstrap.capabilities.includes('steer');
|
|
30
|
+
const refresh=useCallback(async()=>{
|
|
31
|
+
setLoading(true);setError('');
|
|
32
|
+
try{setSnapshot(await session.snapshot());}
|
|
33
|
+
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
|
|
34
|
+
finally{setLoading(false);}
|
|
35
|
+
},[session]);
|
|
36
|
+
useEffect(()=>{
|
|
37
|
+
let active=true;
|
|
38
|
+
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}}).finally(()=>{if(active)setLoading(false);});
|
|
39
|
+
return()=>{active=false;};
|
|
40
|
+
},[session]);
|
|
41
|
+
async function steer(action:'evaluate'|'promote'){
|
|
42
|
+
if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
|
|
43
|
+
setBusy(true);setSteeringMessage('');
|
|
44
|
+
try{
|
|
45
|
+
const identity=JSON.stringify([action,steering,rubric.trim(),explanation.trim(),trial?.fingerprint??'',snapshot.status.classifiers[steering].cyclotron.fingerprint]);
|
|
46
|
+
const requestId=steeringRequests.current[identity]??=crypto.randomUUID();
|
|
47
|
+
const command=action==='evaluate'?{action,proposal:{rubric:rubric.trim()},explanation,requestId} as const:{action,trialFingerprint:trial?.fingerprint??'',explanation,requestId} as const;
|
|
48
|
+
const payload=publicationSteeringPayload(snapshot.status,steering,session.bootstrap.bindingRef,command);
|
|
49
|
+
const receipt=await session.publication.steer(payload);
|
|
50
|
+
const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
|
|
51
|
+
if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
|
|
52
|
+
setSteeringMessage(action==='promote'?'Evaluated proposal promoted.':result?.improved?'Evaluation complete: the candidate improved the measured result.':'Evaluation complete: no improvement was reported.');
|
|
53
|
+
await refresh();
|
|
54
|
+
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
|
|
55
|
+
finally{setBusy(false);}
|
|
56
|
+
}
|
|
57
|
+
return <section aria-label="Publication curation" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
|
|
58
|
+
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Publication curation</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh curation</Button></div>
|
|
59
|
+
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Remote workspace':'Embedded workspace'} · {session.bootstrap.scope.publicationId}</p>
|
|
60
|
+
{loading?<p role="status">Loading publication monitoring…</p>:null}
|
|
61
|
+
{error?<p role="alert">{error}</p>:null}
|
|
62
|
+
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
63
|
+
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
64
|
+
onSteer={canSteer&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
65
|
+
{steering?<form aria-label="Classifier proposal" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
66
|
+
<h4 className="font-semibold">Evaluate classifier proposal</h4>
|
|
67
|
+
<p className="text-sm">A proposal is evaluated before an explicit promotion. Existing classifications are not relabeled by opening this form.</p>
|
|
68
|
+
<label className="block text-sm">Proposed instructions<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
|
|
69
|
+
<label className="block text-sm">Why make this change?<textarea className="mt-1 block min-h-20 w-full rounded border bg-background p-2" required value={explanation} onChange={event=>setExplanation(event.target.value)}/></label>
|
|
70
|
+
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Evaluate proposal</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Promote evaluated proposal</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close proposal</Button></div>
|
|
71
|
+
{steeringMessage?<p role="status">{steeringMessage}</p>:null}
|
|
72
|
+
</form>:null}
|
|
73
|
+
{session.bootstrap.priming&&canReview?<ReferencePriming client={session.priming} listId={session.bootstrap.priming.listId} classifiers={session.bootstrap.priming.classifiers}
|
|
74
|
+
onAccepted={draft=>{setPrimingMessage(`Accepted ${draft.rows.length} example item${draft.rows.length===1?'':'s'} for this pinned publication. Operation remains available without priming.`);void refresh();}}/>:null}
|
|
75
|
+
{primingMessage?<p role="status">{primingMessage}</p>:null}
|
|
76
|
+
</section>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { CyclotronStatusView } from "cyclotron/components/cyclotron-status";
|
|
5
|
+
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
6
|
+
|
|
7
|
+
const RATE_CHOICES = [1, 0.5, 0.25, 0.1, 0];
|
|
8
|
+
const DAY_CHOICES = [1, 7, 14, 30];
|
|
9
|
+
|
|
10
|
+
export type ReviewRateChoice = { rate: number; days: number } | { clear: true };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The relevance cyclotron's status above the References list: a strip, the
|
|
14
|
+
* detail card on demand, and, for editors who may change it, a form that asks
|
|
15
|
+
* the next sweep for a manual review rate.
|
|
16
|
+
*/
|
|
17
|
+
export function ReferenceCyclotronStatus({
|
|
18
|
+
onRequestRate,
|
|
19
|
+
status,
|
|
20
|
+
staleAfterHours = 24,
|
|
21
|
+
now = new Date(),
|
|
22
|
+
}: {
|
|
23
|
+
onRequestRate?: (choice: ReviewRateChoice) => Promise<void> | void;
|
|
24
|
+
status: CyclotronStatus;
|
|
25
|
+
staleAfterHours?: number;
|
|
26
|
+
now?: Date;
|
|
27
|
+
}) {
|
|
28
|
+
const [open, setOpen] = useState(false);
|
|
29
|
+
const [rate, setRate] = useState(0.5);
|
|
30
|
+
const [days, setDays] = useState(7);
|
|
31
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
32
|
+
const ageHours = Math.max(0, (now.getTime() - Date.parse(status.asOf)) / 3_600_000);
|
|
33
|
+
const stale = Number.isFinite(ageHours) && ageHours > staleAfterHours;
|
|
34
|
+
const override = status.reviewRate.override;
|
|
35
|
+
|
|
36
|
+
async function submit(choice: ReviewRateChoice) {
|
|
37
|
+
if (!onRequestRate) return;
|
|
38
|
+
setMessage("Sending…");
|
|
39
|
+
try {
|
|
40
|
+
await onRequestRate(choice);
|
|
41
|
+
setMessage("The next sweep applies it and the status will show who set it.");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
setMessage(error instanceof Error ? error.message : String(error));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<section aria-label="Relevance cyclotron" className="space-y-2" data-reference-cyclotron-status>
|
|
49
|
+
<CyclotronStatusView status={status} />
|
|
50
|
+
{stale ? (
|
|
51
|
+
<p className="text-xs text-muted-foreground" data-reference-cyclotron-status-stale>
|
|
52
|
+
<span aria-hidden="true">⚠ </span>
|
|
53
|
+
Snapshot from {Math.round(ageHours)} hours ago; the next sweep refreshes it.
|
|
54
|
+
</p>
|
|
55
|
+
) : null}
|
|
56
|
+
<button
|
|
57
|
+
aria-expanded={open}
|
|
58
|
+
className="text-xs text-muted-foreground underline underline-offset-4"
|
|
59
|
+
onClick={() => setOpen((value) => !value)}
|
|
60
|
+
type="button"
|
|
61
|
+
>
|
|
62
|
+
{open ? "Hide what the cyclotron is doing" : "What the cyclotron is doing"}
|
|
63
|
+
</button>
|
|
64
|
+
{open ? (
|
|
65
|
+
<div className="space-y-3">
|
|
66
|
+
<CyclotronStatusView classifierName="Relevant to this publication" status={status} variant="card" />
|
|
67
|
+
{onRequestRate ? (
|
|
68
|
+
<form
|
|
69
|
+
aria-label="Manual review rate"
|
|
70
|
+
className="flex flex-wrap items-end gap-3 text-sm"
|
|
71
|
+
data-reference-review-rate-form
|
|
72
|
+
onSubmit={(event) => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
void submit({ rate, days });
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<label className="flex flex-col gap-1">
|
|
78
|
+
<span className="text-xs text-muted-foreground">Review rate for confident decisions</span>
|
|
79
|
+
<select aria-label="Manual review rate" onChange={(event) => setRate(Number(event.target.value))} value={rate}>
|
|
80
|
+
{RATE_CHOICES.map((choice) => (
|
|
81
|
+
<option key={choice} value={choice}>{Math.round(choice * 100)}%</option>
|
|
82
|
+
))}
|
|
83
|
+
</select>
|
|
84
|
+
</label>
|
|
85
|
+
<label className="flex flex-col gap-1">
|
|
86
|
+
<span className="text-xs text-muted-foreground">For</span>
|
|
87
|
+
<select aria-label="How long the manual rate lasts" onChange={(event) => setDays(Number(event.target.value))} value={days}>
|
|
88
|
+
{DAY_CHOICES.map((choice) => (
|
|
89
|
+
<option key={choice} value={choice}>{choice} day{choice === 1 ? "" : "s"}</option>
|
|
90
|
+
))}
|
|
91
|
+
</select>
|
|
92
|
+
</label>
|
|
93
|
+
<button className="cyclotron-review-secondary" type="submit">Set manual rate</button>
|
|
94
|
+
{override ? (
|
|
95
|
+
<button className="cyclotron-review-secondary" onClick={() => void submit({ clear: true })} type="button">
|
|
96
|
+
Clear manual rate
|
|
97
|
+
</button>
|
|
98
|
+
) : null}
|
|
99
|
+
<p className="basis-full text-xs text-muted-foreground">
|
|
100
|
+
Low-confidence decisions and the random audit share are always reviewed.
|
|
101
|
+
</p>
|
|
102
|
+
{message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
|
|
103
|
+
</form>
|
|
104
|
+
) : null}
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
</section>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
|
|
7
|
+
/** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
|
|
8
|
+
export function ReferencePriming(props: PrimingPanelProps) {
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<section className="shrink-0 space-y-3" aria-label="Representative reference examples">
|
|
12
|
+
<Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
|
|
13
|
+
{open ? "Hide example priming" : "Curate reference examples (optional)"}
|
|
14
|
+
</Button>
|
|
15
|
+
{open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
|
|
16
|
+
</section>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ReviewControl, type ReviewReason } from "cyclotron/components/review-control";
|
|
5
|
+
import { isScopeTrainingNegativeReason, REFERENCE_REJECTION_REASON_CODES } from "../lib/reference-policy";
|
|
6
|
+
import {
|
|
7
|
+
curationForRelevanceReview,
|
|
8
|
+
type RelevanceCuration,
|
|
9
|
+
type RelevanceDecisionView,
|
|
10
|
+
} from "../lib/relevance-decisions";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Papyrus's existing rejection reasons, unchanged. Reasons that are not about
|
|
14
|
+
* relevance (a duplicate, an unavailable source) close the review without
|
|
15
|
+
* teaching the cyclotron.
|
|
16
|
+
*/
|
|
17
|
+
export const RELEVANCE_REVIEW_REASONS: ReviewReason[] = REFERENCE_REJECTION_REASON_CODES.map((code) => ({
|
|
18
|
+
code,
|
|
19
|
+
text: code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " "),
|
|
20
|
+
...(isScopeTrainingNegativeReason(code) ? {} : { noLabel: true }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
export function ReferenceRelevanceReview({
|
|
24
|
+
disabled,
|
|
25
|
+
decision,
|
|
26
|
+
onCurate,
|
|
27
|
+
referenceLineageId,
|
|
28
|
+
}: {
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
decision: RelevanceDecisionView;
|
|
31
|
+
onCurate: (curation: RelevanceCuration) => void;
|
|
32
|
+
referenceLineageId: string;
|
|
33
|
+
}) {
|
|
34
|
+
const [shareable, setShareable] = useState(false);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const classes = decision.classes.length ? decision.classes : [decision.label];
|
|
37
|
+
return (
|
|
38
|
+
<section aria-label="Relevance review" className="space-y-2" data-reference-relevance-review={referenceLineageId}>
|
|
39
|
+
<ReviewControl
|
|
40
|
+
decision={{
|
|
41
|
+
decisionId: decision.decisionId,
|
|
42
|
+
label: decision.label,
|
|
43
|
+
confidence: decision.confidence,
|
|
44
|
+
classes,
|
|
45
|
+
version: decision.version ?? undefined,
|
|
46
|
+
}}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
itemId={referenceLineageId}
|
|
49
|
+
mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
|
|
50
|
+
onReview={(review) => {
|
|
51
|
+
try {
|
|
52
|
+
setError(null);
|
|
53
|
+
onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
|
|
54
|
+
} catch (failure) {
|
|
55
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
positiveLabel={decision.positiveLabel ?? undefined}
|
|
59
|
+
question={decision.question ?? undefined}
|
|
60
|
+
reasons={RELEVANCE_REVIEW_REASONS}
|
|
61
|
+
reviewReason={decision.reviewRecommended ? decision.reviewDetail ?? undefined : undefined}
|
|
62
|
+
/>
|
|
63
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
64
|
+
<input
|
|
65
|
+
checked={shareable}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
onChange={(event) => setShareable(event.target.checked)}
|
|
68
|
+
type="checkbox"
|
|
69
|
+
/>
|
|
70
|
+
My explanation may be quoted publicly (for the Cyclotron example page)
|
|
71
|
+
</label>
|
|
72
|
+
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { toPublicReaderPath } from "../lib/reader-base-path";
|
|
3
4
|
import { signOut } from "aws-amplify/auth";
|
|
4
5
|
import Link from "next/link";
|
|
5
6
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -114,7 +115,7 @@ export function SettingsPage() {
|
|
|
114
115
|
return (
|
|
115
116
|
<main className="settings-page">
|
|
116
117
|
<nav className="edition-progress edition-progress--newsroom settings-progress" aria-label="Settings navigation">
|
|
117
|
-
<Link className="edition-progress__button edition-progress__button--previous" href="/">
|
|
118
|
+
<Link className="edition-progress__button edition-progress__button--previous" href={toPublicReaderPath("/")}>
|
|
118
119
|
<svg aria-hidden="true" className="edition-progress__icon" focusable="false" viewBox="0 0 10 10">
|
|
119
120
|
<path d="M7.5 1 2.5 5 7.5 9Z" fill="currentColor" />
|
|
120
121
|
</svg>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { signupForms, type PluginOutputs } from "../lib/plugin-settings";
|
|
7
|
+
import { signupEntriesToCsv, type SignupEntryRecord } from "../lib/signup-csv";
|
|
8
|
+
|
|
9
|
+
const LIST_QUERY = `query ListSignupEntries($form: String!, $nextToken: String) {
|
|
10
|
+
listSignupEntriesByFormAndSubmittedAt(form: $form, sortDirection: DESC, limit: 200, nextToken: $nextToken) {
|
|
11
|
+
items { id form value submittedAt }
|
|
12
|
+
nextToken
|
|
13
|
+
}
|
|
14
|
+
}`;
|
|
15
|
+
|
|
16
|
+
type ListResult = { data?: { listSignupEntriesByFormAndSubmittedAt?: { items: SignupEntryRecord[]; nextToken?: string | null } }; errors?: { message?: string }[] };
|
|
17
|
+
|
|
18
|
+
async function loadAllEntries(form: string): Promise<SignupEntryRecord[]> {
|
|
19
|
+
const client = generateClient({ authMode: "userPool" });
|
|
20
|
+
const entries: SignupEntryRecord[] = [];
|
|
21
|
+
let nextToken: string | null | undefined;
|
|
22
|
+
do {
|
|
23
|
+
const result = (await client.graphql({ query: LIST_QUERY, variables: { form, nextToken } })) as ListResult;
|
|
24
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load signups.");
|
|
25
|
+
const page = result.data?.listSignupEntriesByFormAndSubmittedAt;
|
|
26
|
+
entries.push(...(page?.items ?? []));
|
|
27
|
+
nextToken = page?.nextToken;
|
|
28
|
+
} while (nextToken);
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SignupEntriesPanel() {
|
|
33
|
+
const forms = useMemo(() => signupForms(amplifyOutputs as PluginOutputs), []);
|
|
34
|
+
const [form, setForm] = useState(forms[0] ?? "");
|
|
35
|
+
const [entries, setEntries] = useState<SignupEntryRecord[] | null>(null);
|
|
36
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const refresh = useCallback(async () => {
|
|
39
|
+
if (!form) return;
|
|
40
|
+
setEntries(null);
|
|
41
|
+
setProblem(null);
|
|
42
|
+
try {
|
|
43
|
+
setEntries(await loadAllEntries(form));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
setProblem(error instanceof Error ? error.message : "Could not load signups.");
|
|
46
|
+
}
|
|
47
|
+
}, [form]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
void refresh();
|
|
51
|
+
}, [refresh]);
|
|
52
|
+
|
|
53
|
+
const exportCsv = () => {
|
|
54
|
+
if (!entries) return;
|
|
55
|
+
const url = URL.createObjectURL(new Blob([signupEntriesToCsv(entries)], { type: "text/csv;charset=utf-8" }));
|
|
56
|
+
const link = document.createElement("a");
|
|
57
|
+
link.href = url;
|
|
58
|
+
link.download = `signups-${form}.csv`;
|
|
59
|
+
link.click();
|
|
60
|
+
URL.revokeObjectURL(url);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section aria-labelledby="newsroom-signups-title" data-news-desk-admin-panel="signups">
|
|
65
|
+
<h3 id="newsroom-signups-title">Signups</h3>
|
|
66
|
+
<p>Addresses people entered in the site's signup forms. Only editors and admins can see them.</p>
|
|
67
|
+
<label>
|
|
68
|
+
Form{" "}
|
|
69
|
+
<select value={form} onChange={(event) => setForm(event.target.value)} disabled={forms.length < 2}>
|
|
70
|
+
{forms.map((name) => (
|
|
71
|
+
<option key={name} value={name}>{name}</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</label>{" "}
|
|
75
|
+
<button type="button" onClick={() => void refresh()}>Refresh</button>{" "}
|
|
76
|
+
<button type="button" onClick={exportCsv} disabled={!entries || entries.length === 0}>Export CSV</button>
|
|
77
|
+
<p role="status" aria-live="polite">
|
|
78
|
+
{problem ? `Error: ${problem}` : entries === null ? "Loading..." : `${entries.length} ${entries.length === 1 ? "entry" : "entries"}`}
|
|
79
|
+
</p>
|
|
80
|
+
{entries && entries.length > 0 ? (
|
|
81
|
+
<table>
|
|
82
|
+
<thead>
|
|
83
|
+
<tr><th scope="col">Submitted</th><th scope="col">Address</th></tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody>
|
|
86
|
+
{entries.map((entry) => (
|
|
87
|
+
<tr key={entry.id}><td>{entry.submittedAt}</td><td>{entry.value}</td></tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
) : null}
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|