@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +64 -3
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1589 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +487 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
5
+ import {
6
+ listPapyrusPrivateChatMessages,
7
+ openPapyrusPrivateChat,
8
+ sendPapyrusPrivateChatMessage,
9
+ type PapyrusPrivateChatChannel,
10
+ type PapyrusPrivateChatMessage,
11
+ } from "../lib/papyrus-private-chat-client";
12
+
13
+ const REFRESH_MS = 2_000;
14
+
15
+ function toConsoleMessages(messages: readonly PapyrusPrivateChatMessage[]): ConsoleConversationMessage[] {
16
+ return messages.map((message) => ({
17
+ id: message.message_id,
18
+ role: message.author_kind === "human" ? "USER" : "ASSISTANT",
19
+ text: message.body,
20
+ createdAt: message.created_at,
21
+ }));
22
+ }
23
+
24
+ function failureMessage(error: unknown): string {
25
+ return error instanceof Error && error.message.trim() ? error.message : "Private chat is unavailable.";
26
+ }
27
+
28
+ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
29
+ const [channel, setChannel] = useState<PapyrusPrivateChatChannel | null>(null);
30
+ const [messages, setMessages] = useState<PapyrusPrivateChatMessage[]>([]);
31
+ const [loading, setLoading] = useState(true);
32
+ const [sending, setSending] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const retryAttempt = useRef<{ text: string; key: string } | null>(null);
35
+ const isWaitingForReply = messages.length > 0 && messages[messages.length - 1].author_kind === "human" &&
36
+ messages[messages.length - 1].addressed_to_bot_id !== null;
37
+ const conversationMessages = useMemo(() => {
38
+ const current = toConsoleMessages(messages);
39
+ if (!isWaitingForReply) return current;
40
+ const last = messages[messages.length - 1];
41
+ return [...current, {
42
+ id: `pending-reply:${last.message_id}`,
43
+ role: "ASSISTANT",
44
+ text: "",
45
+ pending: true,
46
+ }];
47
+ }, [isWaitingForReply, messages]);
48
+
49
+ const refreshMessages = useCallback(async (channelId: string) => {
50
+ const next = await listPapyrusPrivateChatMessages(channelId);
51
+ setMessages(next);
52
+ }, []);
53
+
54
+ const openConversation = useCallback(async () => {
55
+ setLoading(true);
56
+ setError(null);
57
+ try {
58
+ const nextChannel = await openPapyrusPrivateChat();
59
+ setChannel(nextChannel);
60
+ await refreshMessages(nextChannel.channel_id);
61
+ } catch (nextError) {
62
+ setError(failureMessage(nextError));
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ }, [refreshMessages]);
67
+
68
+ useEffect(() => {
69
+ void openConversation();
70
+ }, [openConversation]);
71
+
72
+ useEffect(() => {
73
+ if (!channel || !isWaitingForReply) return;
74
+ let active = true;
75
+ let timer: ReturnType<typeof setTimeout> | null = null;
76
+ const poll = async () => {
77
+ try {
78
+ await refreshMessages(channel.channel_id);
79
+ } catch (nextError) {
80
+ if (active) setError(failureMessage(nextError));
81
+ return;
82
+ }
83
+ if (active) timer = setTimeout(poll, REFRESH_MS);
84
+ };
85
+ timer = setTimeout(poll, REFRESH_MS);
86
+ return () => {
87
+ active = false;
88
+ if (timer) clearTimeout(timer);
89
+ };
90
+ }, [channel, isWaitingForReply, refreshMessages]);
91
+
92
+ const sendMessage = useCallback(async (text: string) => {
93
+ if (!channel || !text.trim() || sending) return;
94
+ const attempt = retryAttempt.current?.text === text
95
+ ? retryAttempt.current
96
+ : { text, key: globalThis.crypto.randomUUID() };
97
+ retryAttempt.current = attempt;
98
+ setSending(true);
99
+ setError(null);
100
+ try {
101
+ const result = await sendPapyrusPrivateChatMessage(channel.channel_id, text, attempt.key);
102
+ setMessages((current) => current.some((message) => message.message_id === result.message.message_id)
103
+ ? current
104
+ : [...current, result.message]);
105
+ retryAttempt.current = null;
106
+ } catch (nextError) {
107
+ setError(failureMessage(nextError));
108
+ } finally {
109
+ setSending(false);
110
+ }
111
+ }, [channel, sending]);
112
+
113
+ return <div className="papyrus-console__panel">
114
+ <header className="papyrus-console__header">
115
+ <p className="papyrus-console__header-label">Private chat</p>
116
+ <button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">×</button>
117
+ </header>
118
+ {channel ? <p className="papyrus-console__empty" role="status">Private conversation for your selected Account.</p> : null}
119
+ {!channel && !loading ? <div className="papyrus-console__empty" role="alert">
120
+ <strong>Private chat could not be opened.</strong>
121
+ <span>{error}</span>
122
+ <button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
123
+ </div> : <PapyrusConsoleConversation
124
+ messages={conversationMessages}
125
+ isSending={sending || loading || !channel}
126
+ isRunning={isWaitingForReply}
127
+ onSendMessage={sendMessage}
128
+ error={error}
129
+ emptyState={loading ? <p className="papyrus-console__empty">Opening private conversation…</p> : (
130
+ <p className="papyrus-console__empty">This conversation is private to your selected Account and editor session.</p>
131
+ )}
132
+ />}
133
+ </div>;
134
+ }
@@ -5,6 +5,9 @@ import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/revie
5
5
  import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
6
6
  import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
7
7
 
8
+ /** Steering is offered only when the Cyclotron contract says so; absent means unknown, so off. */
9
+ export function steeringAvailable(status:PublicationStatus):boolean{return status.features?.steering===true;}
10
+
8
11
  function requestId(){
9
12
  const bytes=crypto.getRandomValues(new Uint8Array(16));
10
13
  return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
@@ -28,28 +31,30 @@ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationP
28
31
  const signature=JSON.stringify(review);const previous=requests.current[key];
29
32
  const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
30
33
  const payload=publicationReviewPayload(status,row,classifier,review,id);
31
- if(!onReview)throw new Error('This host has not enabled human review');
34
+ if(!onReview)throw new Error('Reviewing is not turned on here.');
32
35
  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.'});}
36
+ if(current.current===context){setMessage({context,key,error:false,text:'Your choice was saved.'});setEditing(old=>({...old,[key]:false}));}
37
+ }catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Your review may not have been saved; check before trying again.'});}
35
38
  finally{if(current.current===context)setBusy(null);}
36
39
  }
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>
40
+ return <section data-publication-scope={context} aria-label="Suggestions and reviews" className="space-y-4">
41
+ <header><h2 className="text-lg font-semibold">Suggestions · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Suggestions and reviews are kept by this site.':'Suggestions and reviews are kept by the shared review service.'}</p></header>
39
42
  {status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
40
43
  <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}
44
+ {onSteer?(steeringAvailable(status)
45
+ ?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>
46
+ :<div><button type="button" disabled aria-describedby={`steer-unavailable:${output.id}`}>Test a rule change · Not available yet</button><p id={`steer-unavailable:${output.id}`} className="text-sm text-muted-foreground">Rule changes cannot be tested on this service yet.</p></div>):null}
42
47
  {history.history.map(row=>{
43
48
  const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
44
49
  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
50
  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>
51
+ {historical?<p className="text-sm">An earlier copy of this source · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Earlier feedback changed; check this item before reviewing again.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
47
52
  <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}
53
+ {!saved||editing[key]?<p className="text-sm" role="status">{historical?'This earlier copy is read-only.':!onReview?'Reviewing is not available right now.':busy===key?'Saving your review… your choice is kept.':'Choose an option to enable Submit review. An explanation is optional.'}</p>:null}
49
54
  <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
55
  {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>
56
+ {saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Change my choice</button>:null}
57
+ <details><summary>Where this suggestion came from</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
58
  </article>;
54
59
  })}
55
60
  </section>)}
@@ -61,7 +61,7 @@ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boole
61
61
 
62
62
  export function isUnauthenticatedError(error: unknown): boolean {
63
63
  const message = error instanceof Error ? error.message : String(error);
64
- return /unauth|not authenticated|no current user|not signed in/i.test(message);
64
+ return /unauth|not.?authorized|not authenticated|no current user|not signed in|session expired|token expired|invalid.*token|\b401\b/i.test(message);
65
65
  }
66
66
 
67
67
  function readIdentityLabel(claims: {
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
5
+ import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
6
+ import { ReferencePriming } from "./reference-priming";
7
+ import type { PrimingRequestView } from "../lib/relevance-status";
8
+ import { Button } from "./ui/button";
9
+
10
+ export type ReferenceUrlOption = {
11
+ labels: readonly string[];
12
+ requests: PrimingRequestView[];
13
+ onRequest: (input: PrimingFormInput) => Promise<void> | void;
14
+ };
15
+
16
+ type Option = "url" | "file";
17
+
18
+ /**
19
+ * The one place an editor adds a reference. "Paste a URL" is always offered when
20
+ * the host can take one. "Import a file" appears only when a curation session
21
+ * (or the host) supplies the import transport, so without one it never renders.
22
+ */
23
+ export function ReferenceAddEntry({
24
+ url,
25
+ importOptions,
26
+ onImported,
27
+ }: {
28
+ url?: ReferenceUrlOption | null;
29
+ importOptions?: PrimingPanelProps | null;
30
+ /** Called after an import is saved, so the host can reload what depends on it. */
31
+ onImported?: () => void;
32
+ }) {
33
+ const [chosen, setChosen] = useState<Option>("url");
34
+ const [fileOpened, setFileOpened] = useState(false);
35
+ const [saved, setSaved] = useState("");
36
+ if (!url && !importOptions) return null;
37
+
38
+ const option: Option = importOptions && (!url || chosen === "file") ? "file" : "url";
39
+ const choose = (next: Option) => {
40
+ setChosen(next);
41
+ if (next === "file") setFileOpened(true);
42
+ };
43
+
44
+ return (
45
+ <section aria-label="Add a reference" className="shrink-0 space-y-3" data-add-reference>
46
+ <div className="flex flex-wrap items-center justify-between gap-2">
47
+ <h3 className="font-semibold">Add a reference</h3>
48
+ {url && importOptions ? (
49
+ <div aria-label="How to add a reference" className="flex gap-2" role="group">
50
+ <Button aria-pressed={option === "url"} onClick={() => choose("url")} size="sm" type="button" variant={option === "url" ? "secondary" : "outline"}>Paste a URL</Button>
51
+ <Button aria-pressed={option === "file"} onClick={() => choose("file")} size="sm" type="button" variant={option === "file" ? "secondary" : "outline"}>Import a file</Button>
52
+ </div>
53
+ ) : null}
54
+ </div>
55
+ {url ? (
56
+ <div hidden={option !== "url"}>
57
+ <ReferenceCyclotronPriming labels={url.labels} onRequestPriming={url.onRequest} requests={url.requests} />
58
+ </div>
59
+ ) : null}
60
+ {importOptions && (fileOpened || !url) ? (
61
+ <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1" hidden={option !== "file"}>
62
+ <ReferencePriming
63
+ {...importOptions}
64
+ onAccepted={(draft) => {
65
+ setSaved(`Added ${draft.rows.length} reference${draft.rows.length === 1 ? "" : "s"}.`);
66
+ importOptions.onAccepted?.(draft);
67
+ onImported?.();
68
+ }}
69
+ />
70
+ {saved ? <p role="status">{saved}</p> : null}
71
+ </div>
72
+ ) : null}
73
+ </section>
74
+ );
75
+ }
@@ -3,21 +3,31 @@
3
3
  import {useCallback,useEffect,useRef,useState} from 'react';
4
4
  import {useNewsroomCuration} from './newsroom-curation-provider';
5
5
  import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
6
- import {ReferencePriming} from './reference-priming';
6
+ import type {PrimingPanelProps} from 'cyclotron/components/priming-panel';
7
7
  import {Button} from './ui/button';
8
8
  import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
9
9
  import type {CurationSession} from '../lib/curation-client';
10
10
 
11
- export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
11
+ /** `refreshSignal` changes when references were just added, so the panel reloads by itself. */
12
+ export function ReferenceCurationControls({disabled=false,refreshSignal=0}:{disabled?:boolean;refreshSignal?:number}){
12
13
  const session=useNewsroomCuration();
13
- return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
14
+ return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled} refreshSignal={refreshSignal}/>:null;
14
15
  }
15
16
 
16
- function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
17
+ /**
18
+ * The import option of the Add a reference area, or null. Fail-closed: it exists
19
+ * only when a curation session supplies priming and the person may review.
20
+ */
21
+ export function useReferenceImport(disabled=false):{panel:PrimingPanelProps;sessionKey:string}|null{
22
+ const session=useNewsroomCuration();
23
+ if(!session||disabled||!session.bootstrap.priming||!session.bootstrap.capabilities.includes('review'))return null;
24
+ return {sessionKey:session.key,panel:{client:session.priming,listId:session.bootstrap.priming.listId,classifiers:session.bootstrap.priming.classifiers}};
25
+ }
26
+
27
+ function Controls({session,disabled,refreshSignal}:{session:CurationSession;disabled:boolean;refreshSignal:number}){
17
28
  const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
18
29
  const [error,setError]=useState('');
19
30
  const [loading,setLoading]=useState(true);
20
- const [primingMessage,setPrimingMessage]=useState('');
21
31
  const [steering,setSteering]=useState<string|null>(null);
22
32
  const [rubric,setRubric]=useState('');
23
33
  const [explanation,setExplanation]=useState('');
@@ -30,14 +40,16 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
30
40
  const refresh=useCallback(async()=>{
31
41
  setLoading(true);setError('');
32
42
  try{setSnapshot(await session.snapshot());}
33
- catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
43
+ catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}
34
44
  finally{setLoading(false);}
35
45
  },[session]);
36
46
  useEffect(()=>{
37
47
  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);});
48
+ void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}}).finally(()=>{if(active)setLoading(false);});
39
49
  return()=>{active=false;};
40
50
  },[session]);
51
+ const lastSignal=useRef(refreshSignal);
52
+ useEffect(()=>{if(lastSignal.current!==refreshSignal){lastSignal.current=refreshSignal;void refresh();}},[refreshSignal,refresh]);
41
53
  async function steer(action:'evaluate'|'promote'){
42
54
  if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
43
55
  setBusy(true);setSteeringMessage('');
@@ -49,29 +61,26 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
49
61
  const receipt=await session.publication.steer(payload);
50
62
  const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
51
63
  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.');
64
+ setSteeringMessage(action==='promote'?'The tested change is now in use.':result?.improved?'Test finished: the change improved the results.':'Test finished: no improvement was found.');
53
65
  await refresh();
54
- }catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
66
+ }catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'The result is unknown; check before trying again.');}
55
67
  finally{setBusy(false);}
56
68
  }
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}
69
+ return <section aria-label="Suggestions for this publication" 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}>
70
+ <div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Suggestions for this publication</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh</Button></div>
71
+ <p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Shared review service':'This site'} · {session.bootstrap.scope.publicationId}</p>
72
+ {loading?<p role="status">Loading suggestions…</p>:null}
61
73
  {error?<p role="alert">{error}</p>:null}
62
74
  {snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
63
75
  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>
76
+ onSteer={canSteer&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
77
+ {steering?<form aria-label="Rule change" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
78
+ <h4 className="font-semibold">Test a rule change</h4>
79
+ <p className="text-sm">A change is tested before it is adopted. Opening this form does not change any existing suggestion.</p>
80
+ <label className="block text-sm">Proposed rule<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
81
  <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>
82
+ <div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Test change</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Adopt tested change</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close</Button></div>
71
83
  {steeringMessage?<p role="status">{steeringMessage}</p>:null}
72
84
  </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
85
  </section>;
77
86
  }
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useId, useRef, useState, type KeyboardEvent } from "react";
4
4
  import { primingStateText, type PrimingRequestView } from "../lib/relevance-status";
5
5
 
6
6
  export type PrimingFormInput = { url: string; label: string; explanation: string };
7
7
 
8
8
  /**
9
- * A compact "Add a reference" form for a pasted URL. The page is read in the
9
+ * The "Paste a URL" option of the Add a reference area. The page is read in the
10
10
  * background and the outcome is shown beside each added reference.
11
11
  */
12
12
  export function ReferenceCyclotronPriming({
@@ -23,6 +23,19 @@ export function ReferenceCyclotronPriming({
23
23
  const [explanation, setExplanation] = useState("");
24
24
  const [message, setMessage] = useState<string | null>(null);
25
25
 
26
+ const ratingLabelId = useId();
27
+ const buttons = useRef<Array<HTMLButtonElement | null>>([]);
28
+
29
+ function moveSelection(event: KeyboardEvent<HTMLButtonElement>, index: number) {
30
+ const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
31
+ const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
32
+ if (!forward && !backward) return;
33
+ event.preventDefault();
34
+ const next = (index + (forward ? 1 : -1) + labels.length) % labels.length;
35
+ setLabel(labels[next]);
36
+ buttons.current[next]?.focus();
37
+ }
38
+
26
39
  async function submit() {
27
40
  setMessage("Adding…");
28
41
  try {
@@ -36,16 +49,16 @@ export function ReferenceCyclotronPriming({
36
49
  }
37
50
 
38
51
  return (
39
- <section aria-label="Add a reference" className="space-y-2 text-sm" data-reference-cyclotron-priming>
52
+ <div className="space-y-2 text-sm" data-reference-cyclotron-priming>
40
53
  <form
41
- className="flex flex-wrap items-end gap-3"
54
+ className="flex flex-col gap-3"
42
55
  onSubmit={(event) => {
43
56
  event.preventDefault();
44
57
  void submit();
45
58
  }}
46
59
  >
47
- <label className="flex min-w-64 flex-1 flex-col gap-1">
48
- <span className="text-xs text-muted-foreground">Add a reference by URL</span>
60
+ <label className="flex flex-col gap-1">
61
+ <span className="text-xs text-muted-foreground">Web page address</span>
49
62
  <input
50
63
  aria-label="Reference URL"
51
64
  onChange={(event) => setUrl(event.target.value)}
@@ -55,20 +68,46 @@ export function ReferenceCyclotronPriming({
55
68
  value={url}
56
69
  />
57
70
  </label>
71
+ <div className="flex flex-col gap-1">
72
+ <span className="text-xs text-muted-foreground" id={ratingLabelId}>Should Papyrus include or exclude pages like this?</span>
73
+ <div aria-labelledby={ratingLabelId} className="flex flex-wrap gap-2" role="radiogroup">
74
+ {labels.map((choice, index) => {
75
+ const checked = choice === label;
76
+ return (
77
+ <button
78
+ aria-checked={checked}
79
+ className={`min-w-32 flex-1 rounded border px-4 py-3 text-base font-semibold capitalize ${checked ? "border-foreground bg-foreground text-background" : "border-border bg-transparent"}`}
80
+ data-rating-choice={choice}
81
+ key={choice}
82
+ onClick={() => setLabel(choice)}
83
+ onKeyDown={(event) => moveSelection(event, index)}
84
+ ref={(node) => { buttons.current[index] = node; }}
85
+ role="radio"
86
+ tabIndex={checked || (!labels.includes(label) && index === 0) ? 0 : -1}
87
+ type="button"
88
+ >
89
+ {choice}
90
+ </button>
91
+ );
92
+ })}
93
+ </div>
94
+ </div>
58
95
  <label className="flex flex-col gap-1">
59
- <span className="text-xs text-muted-foreground">Include or exclude</span>
60
- <select aria-label="Include or exclude" onChange={(event) => setLabel(event.target.value)} value={label}>
61
- {labels.map((choice) => (
62
- <option key={choice} value={choice}>{choice}</option>
63
- ))}
64
- </select>
65
- </label>
66
- <label className="flex min-w-48 flex-1 flex-col gap-1">
67
- <span className="text-xs text-muted-foreground">Why (optional)</span>
68
- <input aria-label="Reason" onChange={(event) => setExplanation(event.target.value)} type="text" value={explanation} />
96
+ <span className="text-sm font-medium">Why?</span>
97
+ <textarea
98
+ aria-label="Reason"
99
+ className="w-full"
100
+ onChange={(event) => setExplanation(event.target.value)}
101
+ placeholder="Why? A sentence or two helps Papyrus learn what you want."
102
+ rows={4}
103
+ value={explanation}
104
+ />
105
+ <span className="text-xs text-muted-foreground">Optional, but a short reason helps Papyrus learn what you want.</span>
69
106
  </label>
70
- <button className="cyclotron-review-secondary" type="submit">Add reference</button>
71
- {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
107
+ <div>
108
+ <button className="cyclotron-review-secondary" type="submit">Add reference</button>
109
+ </div>
110
+ {message ? <p className="text-xs" role="status">{message}</p> : null}
72
111
  </form>
73
112
  {requests.length ? (
74
113
  <ul aria-label="Added references" className="space-y-1 text-xs text-muted-foreground">
@@ -79,6 +118,6 @@ export function ReferenceCyclotronPriming({
79
118
  ))}
80
119
  </ul>
81
120
  ) : null}
82
- </section>
121
+ </div>
83
122
  );
84
123
  }
@@ -57,11 +57,12 @@ export function ReferenceSuggestionBlock({
57
57
  const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
58
58
  const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
59
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>
60
+ <section aria-label="Publication prediction" 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">Publication prediction</h3>
62
62
  <p className="text-sm text-foreground">
63
- The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
63
+ Papyrus suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
64
64
  </p>
65
+ {decision.reviewDetail ? <p className="text-xs text-foreground">Why review was requested: {decision.reviewDetail}</p> : null}
65
66
  <p className="text-xs text-foreground" data-reference-suggestion-notice>
66
67
  A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
67
68
  </p>
@@ -99,14 +100,17 @@ export function ReferenceSuggestionProvenance({ decision }: { decision: Relevanc
99
100
  <summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
100
101
  {summary ? (
101
102
  <ul className="mt-2 space-y-1">
102
- <li>{summary.teachingLabelsSentence}.</li>
103
- <li>{summary.primingExamplesSentence}.</li>
103
+ {summary.teachingLabelsSentence ? <li>{summary.teachingLabelsSentence}.</li> : null}
104
+ {summary.primingExamplesSentence ? <li>{summary.primingExamplesSentence}.</li> : null}
105
+ {decision.provenance?.system ? <li>Source: {decision.provenance.system === "cyclotron-lab" ? "Shared publication classifier" : decision.provenance.system}.</li> : null}
106
+ {decision.provenance?.output ? <li>Publication output: {decision.provenance.output}.</li> : null}
107
+ {decision.classifierId ? <li>Classifier: {decision.classifierId}.</li> : null}
104
108
  <li>Guidelines code {summary.doctrineShortHash ?? "not recorded"}.</li>
105
109
  <li>Suggestion {summary.modelVersion ?? "revision not recorded"}.</li>
106
110
  <li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
107
111
  </ul>
108
112
  ) : (
109
- <p className="mt-2">This suggestion was made before the details were recorded. Only its revision and confidence are known.</p>
113
+ <p className="mt-2">Additional provenance was not recorded for this prediction.</p>
110
114
  )}
111
115
  </details>
112
116
  );
@@ -1,18 +1,22 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
3
  import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
5
- import { Button } from "./ui/button";
6
4
 
7
- /** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
5
+ /**
6
+ * The "Import a file" option of the Add a reference area. Papyrus supplies the
7
+ * transport and configuration; Cyclotron owns the preview and label controls.
8
+ * It is only rendered by `ReferenceAddEntry`, never as a section of its own.
9
+ */
8
10
  export function ReferencePriming(props: PrimingPanelProps) {
9
- const [open, setOpen] = useState(false);
10
11
  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 references" : "Add example references (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>
12
+ <PrimingPanel
13
+ {...props}
14
+ renderItem={props.renderItem ?? ((item) => (
15
+ <div className="space-y-2">
16
+ <p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p>
17
+ <p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p>
18
+ </div>
19
+ ))}
20
+ />
17
21
  );
18
22
  }
@@ -28,7 +28,7 @@ export function ReferenceRelevanceReview({
28
28
  }: {
29
29
  disabled?: boolean;
30
30
  decision: RelevanceDecisionView;
31
- onCurate: (curation: RelevanceCuration) => void;
31
+ onCurate: (curation: RelevanceCuration) => Promise<void> | void;
32
32
  referenceLineageId: string;
33
33
  }) {
34
34
  const [shareable, setShareable] = useState(false);
@@ -47,10 +47,10 @@ export function ReferenceRelevanceReview({
47
47
  disabled={disabled}
48
48
  itemId={referenceLineageId}
49
49
  mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
50
- onReview={(review) => {
50
+ onReview={async (review) => {
51
51
  try {
52
52
  setError(null);
53
- onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
53
+ await onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
54
54
  } catch (failure) {
55
55
  setError(failure instanceof Error ? failure.message : String(failure));
56
56
  }
@@ -40,7 +40,7 @@ export function SectionSuggestionBlock({
40
40
  <section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
41
41
  <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
42
42
  <p className="text-sm text-foreground">
43
- The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
43
+ Papyrus suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
44
44
  {suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
45
45
  </p>
46
46
  {others.length ? (