@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80

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 (211) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +151 -61
  4. package/amplify/data/resource.ts +157 -61
  5. package/amplify/data/schema.js +2096 -1980
  6. package/amplify/data/schema.ts +198 -69
  7. package/amplify/functions/assignment-action/data-access.js +14 -15
  8. package/amplify/functions/assignment-action/data-access.ts +13 -14
  9. package/amplify/functions/assignment-action/handler.ts +1 -1
  10. package/amplify/functions/automation-view/handler.py +17 -0
  11. package/amplify/functions/automation-view/resource.js +24 -0
  12. package/amplify/functions/automation-view/resource.ts +34 -0
  13. package/amplify/functions/automation-view/schema.js +26 -0
  14. package/amplify/functions/automation-view/schema.ts +28 -0
  15. package/amplify/functions/automation-view/wire.js +56 -0
  16. package/amplify/functions/automation-view/wire.ts +76 -0
  17. package/amplify/functions/content-actions/handler.py +61 -2
  18. package/amplify/functions/content-actions/requirements.txt +1 -0
  19. package/amplify/functions/content-actions/resource.js +1 -1
  20. package/amplify/functions/content-actions/resource.ts +1 -1
  21. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  22. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  24. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  25. package/amplify/functions/editorial-checks/schema.js +122 -0
  26. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  27. package/amplify/functions/editorial-checks/wire.js +53 -0
  28. package/amplify/functions/editorial-checks/wire.ts +61 -0
  29. package/amplify/functions/monitoring-view/handler.py +11 -0
  30. package/amplify/functions/monitoring-view/resource.js +24 -0
  31. package/amplify/functions/monitoring-view/resource.ts +34 -0
  32. package/amplify/functions/monitoring-view/schema.js +17 -0
  33. package/amplify/functions/monitoring-view/schema.ts +19 -0
  34. package/amplify/functions/monitoring-view/wire.js +37 -0
  35. package/amplify/functions/monitoring-view/wire.ts +47 -0
  36. package/amplify/functions/public-forms/core.js +247 -0
  37. package/amplify/functions/public-forms/core.ts +310 -0
  38. package/amplify/functions/public-forms/handler.ts +41 -0
  39. package/amplify/functions/public-forms/origins.js +35 -0
  40. package/amplify/functions/public-forms/origins.ts +43 -0
  41. package/amplify/functions/public-forms/resource.js +9 -0
  42. package/amplify/functions/public-forms/resource.ts +10 -0
  43. package/amplify/functions/public-forms/wire.js +25 -0
  44. package/amplify/functions/public-forms/wire.ts +32 -0
  45. package/amplify/functions/shared/editorial-context.ts +63 -4
  46. package/amplify/functions/shared/python-bundle.js +20 -14
  47. package/amplify/functions/shared/python-bundle.ts +25 -18
  48. package/amplify/plugins/newsletter/config.js +245 -0
  49. package/amplify/plugins/newsletter/config.ts +305 -0
  50. package/amplify/plugins/newsletter/function/handler.py +6 -0
  51. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  52. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  53. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  54. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  55. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  56. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  57. package/amplify/plugins/newsletter/schema.js +57 -0
  58. package/amplify/plugins/newsletter/schema.ts +62 -0
  59. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  60. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  61. package/amplify/plugins/newsletter/sending.js +140 -0
  62. package/amplify/plugins/newsletter/sending.ts +186 -0
  63. package/amplify/plugins/reader-steering/config.js +182 -0
  64. package/amplify/plugins/reader-steering/config.ts +290 -0
  65. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  66. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  67. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  68. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  69. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  70. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  71. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  72. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  73. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  74. package/amplify/plugins/reader-steering/schema.js +205 -0
  75. package/amplify/plugins/reader-steering/schema.ts +207 -0
  76. package/amplify/plugins/registry.js +4 -2
  77. package/amplify/plugins/registry.ts +4 -2
  78. package/amplify/plugins/signup/schema.js +2 -1
  79. package/amplify/plugins/signup/schema.ts +2 -1
  80. package/amplify/plugins/types.ts +5 -0
  81. package/amplify/plugins/wire.js +1 -0
  82. package/amplify/plugins/wire.ts +1 -0
  83. package/amplify/publication/projection-lease.js +30 -0
  84. package/amplify/publication/projection-lease.ts +39 -0
  85. package/amplify/publication/redirect-and-index.js +105 -0
  86. package/amplify/publication/redirect-rules.js +73 -0
  87. package/amplify/publication/redirect-rules.ts +93 -0
  88. package/amplify/publication/s3-projection.js +7 -1
  89. package/amplify/publication/s3-projection.ts +9 -1
  90. package/amplify/site-backend-config.ts +18 -1
  91. package/amplify/site-backend-names.js +4 -2
  92. package/amplify/site-backend-names.ts +4 -2
  93. package/amplify/site-backend.js +41 -2
  94. package/amplify/site-backend.ts +46 -2
  95. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  96. package/app/api/newsletter/confirm/route.ts +13 -0
  97. package/app/api/newsletter/preferences/route.ts +13 -0
  98. package/app/api/newsletter/state/route.ts +9 -0
  99. package/app/api/newsletter/subscribe/route.ts +9 -0
  100. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  101. package/app/api/newsroom/curation/context/route.ts +9 -0
  102. package/app/api/newsroom/curation/priming/route.ts +9 -0
  103. package/app/api/newsroom/curation/publication/route.ts +9 -0
  104. package/app/api/reader/comment/delete/route.ts +9 -0
  105. package/app/api/reader/comment/edit/route.ts +9 -0
  106. package/app/api/reader/comment/route.ts +9 -0
  107. package/app/api/reader/flag/route.ts +9 -0
  108. package/app/api/reader/handle/route.ts +9 -0
  109. package/app/api/reader/me/route.ts +9 -0
  110. package/app/api/reader/rating/route.ts +9 -0
  111. package/app/api/reader/report/route.ts +9 -0
  112. package/app/api/reader/retired/route.ts +9 -0
  113. package/app/api/reader/submit/route.ts +9 -0
  114. package/app/api/reader/vote/route.ts +9 -0
  115. package/bin/papyrus-infra.mjs +20 -1
  116. package/components/automation-panel.tsx +49 -0
  117. package/components/automation-run-now.tsx +97 -0
  118. package/components/automation-view.tsx +192 -0
  119. package/components/editorial-checks-admin-panel.tsx +36 -11
  120. package/components/moderation-panel.tsx +248 -0
  121. package/components/monitoring-overview-view.tsx +241 -0
  122. package/components/monitoring-panel.tsx +40 -0
  123. package/components/news-desk-client-provider.tsx +18 -18
  124. package/components/news-desk-taxonomy-client.ts +28 -8
  125. package/components/newsletter-panel.tsx +474 -0
  126. package/components/newsletter-sections.tsx +163 -0
  127. package/components/newsroom-access-gate.tsx +15 -1
  128. package/components/newsroom-article-editor.tsx +6 -6
  129. package/components/newsroom-articles-view.tsx +6 -6
  130. package/components/newsroom-board-view.tsx +8 -3
  131. package/components/newsroom-curation-bootstrap.tsx +45 -0
  132. package/components/newsroom-ops-overview.tsx +22 -92
  133. package/components/newsroom-ops-shell.tsx +2 -2
  134. package/components/newsroom-organizer.css +33 -0
  135. package/components/newsroom-overview-skeleton.tsx +25 -0
  136. package/components/newsroom-references-view.tsx +192 -49
  137. package/components/notification-center.tsx +273 -0
  138. package/components/publication-cyclotron-panel.tsx +15 -10
  139. package/components/reader-auth-state.ts +1 -1
  140. package/components/reference-add-entry.tsx +75 -0
  141. package/components/reference-curation-controls.tsx +31 -22
  142. package/components/reference-cyclotron-priming.tsx +58 -19
  143. package/components/reference-cyclotron-suggestion.tsx +10 -6
  144. package/components/reference-priming.tsx +14 -10
  145. package/components/reference-relevance-review.tsx +3 -3
  146. package/components/reference-section-suggestion.tsx +1 -1
  147. package/components/topic-steering-workspace.tsx +223 -144
  148. package/components/use-editorial-checks.ts +12 -10
  149. package/infra/amplify-app-shell.js +94 -16
  150. package/infra/amplify-app-shell.ts +106 -18
  151. package/infra/cloud-email-config.js +32 -0
  152. package/infra/cloud-email-config.ts +39 -0
  153. package/infra/cloud-email.js +57 -0
  154. package/infra/cloud-email.test.js +69 -0
  155. package/infra/cloud-email.test.ts +66 -0
  156. package/infra/cloud-email.ts +60 -0
  157. package/infra/index.js +1 -0
  158. package/infra/secure-string-parameter-handler.js +41 -0
  159. package/infra/secure-string-parameter-handler.ts +41 -0
  160. package/infra/secure-string-parameter.js +79 -0
  161. package/infra/secure-string-parameter.ts +91 -0
  162. package/infra/site-config.js +88 -6
  163. package/infra/site-config.ts +134 -6
  164. package/lib/automation-client.ts +54 -0
  165. package/lib/automation-fixture.ts +93 -0
  166. package/lib/automation-overview.ts +232 -0
  167. package/lib/category-repository.ts +11 -9
  168. package/lib/curation-routes.ts +345 -0
  169. package/lib/cyclotron-suggestions.ts +4 -4
  170. package/lib/editorial-checks-backend.ts +1 -1
  171. package/lib/editorial-checks-client.ts +19 -1
  172. package/lib/editorial-checks-demo.ts +2 -2
  173. package/lib/editorial-checks-settings.ts +3 -0
  174. package/lib/editorial-checks.ts +8 -11
  175. package/lib/editorial-preview.ts +5 -5
  176. package/lib/monitoring-client.ts +29 -0
  177. package/lib/monitoring-overview-fixture.ts +51 -0
  178. package/lib/monitoring-overview.ts +145 -0
  179. package/lib/news-desk-session.ts +12 -21
  180. package/lib/newsletter-api.ts +418 -0
  181. package/lib/newsletter-client.ts +46 -0
  182. package/lib/newsletter-fixture.ts +184 -0
  183. package/lib/newsletter-model.ts +202 -0
  184. package/lib/newsroom-organizer-nav.ts +2 -1
  185. package/lib/prospectus-session.ts +15 -14
  186. package/lib/prospectus-sign-in-routes.ts +84 -19
  187. package/lib/prospectus-site-identity.ts +24 -1
  188. package/lib/reader-api.ts +145 -0
  189. package/lib/reader-moderation.ts +220 -0
  190. package/lib/reader-retirement-route.ts +89 -0
  191. package/lib/reader-subject.md +29 -0
  192. package/lib/reader-subject.ts +53 -0
  193. package/lib/reference-review-details.ts +71 -0
  194. package/lib/reference-review-request.ts +14 -0
  195. package/lib/relevance-decisions.ts +42 -10
  196. package/lib/relevance-status.ts +2 -2
  197. package/lib/review-client.ts +99 -0
  198. package/lib/review-fixture.ts +45 -0
  199. package/lib/review-requests.ts +238 -0
  200. package/lib/semantic-graph.ts +2 -0
  201. package/lib/site-brand-tenant.ts +11 -6
  202. package/lib/site-brand.ts +6 -3
  203. package/lib/site-origin.ts +67 -0
  204. package/lib/use-reference-review-data.ts +44 -0
  205. package/middleware.ts +14 -3
  206. package/package.json +56 -3
  207. package/routes.manifest.json +215 -2
  208. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  209. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  210. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  211. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -1,7 +1,7 @@
1
1
  import type { EditorialCheckResult, EditorialFeedbackInput, EditorialFinding, RewriteResult, Span } from "./editorial-checks";
2
2
  import type { EditorialSettings } from "./editorial-checks-settings";
3
3
 
4
- /** What the article editor needs from editorial checks: the plugin's GraphQL API, or the demo. */
4
+ /** What the article editor needs from editorial checks: the site's GraphQL API, or the demo. */
5
5
  export type EditorialChecksBackend = {
6
6
  loadSettings: () => Promise<{ settings: EditorialSettings; exists: boolean }>;
7
7
  dismissedFindingIds: (itemId: string) => Promise<Set<string>>;
@@ -161,6 +161,24 @@ export async function importStarterPack(name: string): Promise<{ created: number
161
161
  return parsed(data.importEditorialStarterPack);
162
162
  }
163
163
 
164
+ export type StarterPackSummary = {
165
+ name: string;
166
+ title: string;
167
+ version?: string | null;
168
+ language?: string | null;
169
+ guidance?: string | null;
170
+ policyCount: number;
171
+ importedCount: number;
172
+ isDefault: boolean;
173
+ policies: { id: string; title?: string | null; severity?: string | null; tags: string[]; lanes: string[] }[];
174
+ };
175
+
176
+ /** The starter packs the site's Humanus ships, with how many of each pack's policies the site already has. */
177
+ export async function listStarterPacks(): Promise<{ humanusVersion?: string | null; packs: StarterPackSummary[] }> {
178
+ const data = await call<{ listEditorialStarterPacks: unknown }>(`query ListStarterPacks { listEditorialStarterPacks }`);
179
+ return parsed(data.listEditorialStarterPacks);
180
+ }
181
+
164
182
  export async function tryPolicy(policy: Record<string, unknown>, text: string): Promise<EditorialCheckResult> {
165
183
  const data = await call<{ tryEditorialPolicy: unknown }>(
166
184
  `query TryEditorialPolicy($policy: AWSJSON!, $text: String!) { tryEditorialPolicy(policy: $policy, text: $text, format: "markus") }`,
@@ -184,7 +202,7 @@ export async function recalibrate(): Promise<{ updated: number; detectors: { pol
184
202
  return parsed(data.recalibrateEditorialPolicies);
185
203
  }
186
204
 
187
- /** The plugin's GraphQL API, signed in as the editor. */
205
+ /** The site's GraphQL API, signed in as the editor. */
188
206
  export const graphqlEditorialBackend: EditorialChecksBackend = {
189
207
  loadSettings,
190
208
  dismissedFindingIds,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
3
- * The shapes match Limatus results so the editor, the preview marks and the
4
- * rewrite flow behave as they do against the real plugin.
3
+ * The shapes match Humanus results so the editor, the preview marks and the
4
+ * rewrite flow behave as they do against the real backend.
5
5
  */
6
6
  import type { EditorialCheckResult, EditorialFinding, EditorialSegment, RewriteResult, Span } from "./editorial-checks";
7
7
  import type { EditorialChecksBackend } from "./editorial-checks-backend";
@@ -1,5 +1,7 @@
1
1
  /** Runtime settings for editorial checks, stored in EditorialCheckSettings (id "settings"). */
2
2
  export type EditorialSettings = {
3
+ /** Core to every site and on by default; a publication turns its checks off here. */
4
+ enabled: boolean;
3
5
  lanes: { rules: boolean; classifier: boolean };
4
6
  classifierModel: string;
5
7
  rewriteModel: string;
@@ -12,6 +14,7 @@ export type EditorialSettings = {
12
14
  };
13
15
 
14
16
  export const DEFAULT_EDITORIAL_SETTINGS: EditorialSettings = {
17
+ enabled: true,
15
18
  lanes: { rules: true, classifier: true },
16
19
  classifierModel: "jev-latest",
17
20
  rewriteModel: "gpt-6-luna",
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Editorial checks: the Limatus result shapes the newsroom uses, and pure helpers
3
- * over them. Limatus counts offsets in UTF-16 code units, which is exactly how
2
+ * Editorial checks: the Humanus result shapes the newsroom uses, and pure helpers
3
+ * over them. Humanus counts offsets in UTF-16 code units, which is exactly how
4
4
  * JavaScript indexes strings, so spans apply to the body text as they are.
5
5
  */
6
- import { pluginSettings, type PluginOutputs } from "./plugin-settings";
7
-
8
- export const EDITORIAL_CHECKS_PLUGIN = "editorial-checks";
9
6
 
10
7
  export type Span = { start: number; end: number };
11
8
  export type Severity = "info" | "warn" | "error";
@@ -41,7 +38,8 @@ export type EditorialSegment = {
41
38
  text?: string;
42
39
  };
43
40
 
44
- export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial"; reason?: string; detectors?: number };
41
+ /** "skipped": Papyrus did not ask for judgments because the publication has no mission or policy for them to apply. */
42
+ export type LaneStatus = { status: "ran" | "disabled" | "unavailable" | "failed" | "partial" | "skipped"; reason?: string; detectors?: number };
45
43
 
46
44
  export type EditorialCheckResult = {
47
45
  schemaVersion: 1;
@@ -52,7 +50,8 @@ export type EditorialCheckResult = {
52
50
  protected: { kind: string; start: number; end: number }[];
53
51
  findings: EditorialFinding[];
54
52
  coverage: unknown[];
55
- lanes: { rules: LaneStatus; classifier: LaneStatus };
53
+ /** lanes.section is "skipped" when the story's section has no NewsroomSection record to take a mission and policy from. */
54
+ lanes: { rules: LaneStatus; classifier: LaneStatus; section?: LaneStatus };
56
55
  usage?: Record<string, number>;
57
56
  };
58
57
 
@@ -91,10 +90,6 @@ export const UNSAVED_ITEM_ID = "unsaved";
91
90
 
92
91
  const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warn: 1, info: 2 };
93
92
 
94
- export function editorialChecksEnabled(outputs: PluginOutputs | null | undefined): boolean {
95
- return pluginSettings(outputs, EDITORIAL_CHECKS_PLUGIN) !== null;
96
- }
97
-
98
93
  /** Findings worth showing: not dismissed, not covering the whole document, most severe first, then in reading order. */
99
94
  export function visibleFindings(findings: EditorialFinding[], dismissed: ReadonlySet<string>): EditorialFinding[] {
100
95
  return findings
@@ -171,8 +166,10 @@ export function describeProbability(finding: EditorialFinding): string | null {
171
166
 
172
167
  export function laneNotice(result: EditorialCheckResult): string | null {
173
168
  const classifier = result.lanes.classifier;
169
+ if (classifier.status === "skipped") return `Mission-based checks skipped: ${classifier.reason ?? "this publication has no mission yet"}.`;
174
170
  if (classifier.status === "partial") return `Some sentences were not judged: ${classifier.reason ?? "the check ran out of time"}.`;
175
171
  if (classifier.status === "failed") return `The judgment checks failed: ${classifier.reason ?? "unknown error"}. Rule checks still ran.`;
176
172
  if (classifier.status === "unavailable" && (classifier.detectors ?? 0) === 0 && classifier.reason) return `Judgment checks are not configured: ${classifier.reason}.`;
173
+ if (result.lanes.section?.status === "skipped") return `Section-based checks skipped: ${result.lanes.section.reason ?? "no section configuration"}.`;
177
174
  return null;
178
175
  }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Places Limatus findings on the rendered Markus preview.
2
+ * Places Humanus findings on the rendered Markus preview.
3
3
  *
4
- * Limatus reports spans in the markup; the preview renders an IR tree with no
4
+ * Humanus reports spans in the markup; the preview renders an IR tree with no
5
5
  * character offsets. Both sides agree on the words a reader sees, so findings
6
- * are placed by text: each Limatus paragraph is matched, in document order, to
6
+ * are placed by text: each Humanus paragraph is matched, in document order, to
7
7
  * the rendered prose block with the same words, and each finding's plain
8
8
  * excerpt is located inside that block. Whitespace is ignored while matching,
9
- * because rendering and Limatus collapse it differently (citations, breaks).
9
+ * because rendering and Humanus collapse it differently (citations, breaks).
10
10
  *
11
11
  * "Rendered text" is defined by `inlineLeaves`, which the preview renderer also
12
12
  * uses, so offsets computed here are offsets the renderer can split on.
@@ -131,7 +131,7 @@ function blockKey(text: string): string {
131
131
  return compact(text).value.replace(/[*_`~]/g, "");
132
132
  }
133
133
 
134
- /** Match every Limatus paragraph to a rendered block and place its findings and sentences there. */
134
+ /** Match every Humanus paragraph to a rendered block and place its findings and sentences there. */
135
135
  export function planPreview(
136
136
  document: MarkusDocument,
137
137
  result: EditorialCheckResult,
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { MonitoringResult } from "./monitoring-overview";
5
+
6
+ type GraphQLResult = { data?: { monitoringOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
7
+
8
+ /**
9
+ * Reads the monitoring overview through the site's own authenticated API (user pool, editor or admin).
10
+ * The browser holds no table name, key or secret: the function behind the query does the read.
11
+ */
12
+ export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
13
+ const client = generateClient({ authMode: "userPool" });
14
+ let result: GraphQLResult;
15
+ try {
16
+ result = (await client.graphql({
17
+ query: `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`,
18
+ variables: { scope },
19
+ })) as GraphQLResult;
20
+ } catch (error) {
21
+ // Amplify throws the response when AppSync returns errors.
22
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
23
+ }
24
+ const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
25
+ if (refused) return { error: "forbidden" };
26
+ if (result.errors?.length || result.data?.monitoringOverview === undefined || result.data.monitoringOverview === null) return { state: "unavailable" };
27
+ const raw = result.data.monitoringOverview;
28
+ return (typeof raw === "string" ? JSON.parse(raw) : raw) as MonitoringResult;
29
+ }
@@ -0,0 +1,51 @@
1
+ import type { MonitoringOverview } from "./monitoring-overview";
2
+
3
+ /** A realistic overview for tests and the `?demo=1` newsroom. Public Hacker News titles only. */
4
+ export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
5
+ state: "ready",
6
+ asOf: "2026-10-21T02:40:00Z",
7
+ scope: { mode: "publication", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
8
+ feeds: [{ id: "hn", sourceUrl: "https://news.ycombinator.com/rss", intervalSeconds: 900, limit: 30, enabled: true,
9
+ lastStatus: "complete", lastFinishedAt: "2026-10-21T02:33:00Z", nextDueAt: "2026-10-21T02:50:00Z" }],
10
+ runs: [
11
+ { runId: "r3", startedAt: "2026-10-21T02:33:00.000000Z", status: "complete", new: 2, revised: 1, unchanged: 27, itemCount: 30, errorCount: 0, errors: [] },
12
+ { runId: "r1", startedAt: "2026-10-21T02:18:00.000000Z", status: "complete", new: 0, revised: 0, unchanged: 30, itemCount: 30, errorCount: 0, errors: [] },
13
+ { runId: "r0", startedAt: "2026-10-21T02:03:00.000000Z", status: "partial", new: 1, revised: 0, unchanged: 28, itemCount: 30, errorCount: 1, errors: ["ConnectionError: timeout"] },
14
+ ],
15
+ routerRuns: [{ runId: "q2", startedAt: "2026-10-21T02:37:00.000000Z", mode: "live", status: "stopped", stoppedBy: "request_ceiling_reached",
16
+ counts: { examined: 3, decided: 1, delivered: 1, deliveryFailed: 1, routed: 1 }, errorCount: 0 }],
17
+ router: { mode: "live", feedback: true, enabled: true, maxItemsPerRun: 5 },
18
+ usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, ceilings: { decisionsTotal: 300, decisionsPerDay: 100, items: 300 } },
19
+ hiddenNotRelevant: 1,
20
+ items: [
21
+ { sourceId: "104", revision: 1, title: "Fresh Show HN", url: "https://example.org/104", discussionUrl: "https://news.ycombinator.com/item?id=104",
22
+ observedAt: "2026-10-21T02:30:00.000000Z", routing: "collected", routingNote: null, decidedAt: null,
23
+ relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
24
+ { sourceId: "105", revision: 1, title: "Broken request", url: "https://example.org/105", discussionUrl: "https://news.ycombinator.com/item?id=105",
25
+ observedAt: "2026-10-21T01:30:00.000000Z", routing: "error", routingNote: "invalid request: bad title", decidedAt: null,
26
+ relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
27
+ { sourceId: "101", revision: 1, title: "Quantum paper on arXiv", url: "https://example.org/101", discussionUrl: "https://news.ycombinator.com/item?id=101",
28
+ observedAt: "2026-10-21T00:10:00.000000Z", routing: "routed", routingNote: null, decidedAt: "2026-10-21T01:00:00Z",
29
+ relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.91,
30
+ delivery: { state: "delivered", referenceId: "ref-1", referenceLineageId: "lin-1", deliveredAt: "2026-10-21T01:00:05Z" } }] },
31
+ { sourceId: "103", revision: 1, title: "Phishing kit leaks", url: "https://example.org/103", discussionUrl: "https://news.ycombinator.com/item?id=103",
32
+ observedAt: "2026-10-20T23:00:00.000000Z", routing: "decided", routingNote: null, decidedAt: "2026-10-20T23:10:00Z",
33
+ relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.7,
34
+ delivery: { state: "failed", error: "ClientError: throttled", attempts: 2 } }] },
35
+ ],
36
+ };
37
+
38
+ export const FIXTURE_ALL_PIPELINE_OVERVIEW: MonitoringOverview = {
39
+ ...FIXTURE_MONITORING_OVERVIEW,
40
+ scope: { mode: "all", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
41
+ hiddenNotRelevant: 0,
42
+ items: FIXTURE_MONITORING_OVERVIEW.items.map((item) => item.sourceId !== "101" ? item : {
43
+ ...item,
44
+ relevance: [
45
+ ...item.relevance,
46
+ { publicationId: "desk-b", site: "b.example.test", relevant: false, label: "exclude", confidence: 0.8, delivery: { state: "not_applicable" } },
47
+ { publicationId: "desk-c", site: "desk-c", relevant: true, label: "include", confidence: null,
48
+ delivery: { state: "delivered", referenceId: "ref-2", referenceLineageId: "lin-2" } },
49
+ ],
50
+ }),
51
+ };
@@ -0,0 +1,145 @@
1
+ /** Shapes and wording for the newsroom Monitoring page. The function in src/papyrus_content/monitoring_view.py produces them. */
2
+ import { getNewsroomNavHref } from "./newsroom-nav";
3
+
4
+ export type MonitoringDeliveryState = "delivered" | "failed" | "pending" | "not_applicable" | null;
5
+
6
+ export type MonitoringRelevance = {
7
+ publicationId: string;
8
+ site: string | null;
9
+ relevant: boolean | null;
10
+ label: string | null;
11
+ confidence: number | null;
12
+ delivery: {
13
+ state: MonitoringDeliveryState;
14
+ referenceId?: string | null;
15
+ referenceLineageId?: string | null;
16
+ deliveredAt?: string | null;
17
+ error?: string | null;
18
+ attempts?: number | null;
19
+ };
20
+ };
21
+
22
+ export type MonitoringItem = {
23
+ sourceId: string;
24
+ revision: number;
25
+ title: string;
26
+ url: string | null;
27
+ discussionUrl: string | null;
28
+ observedAt: string | null;
29
+ routing: string;
30
+ routingNote: string | null;
31
+ decidedAt: string | null;
32
+ relevance: MonitoringRelevance[];
33
+ };
34
+
35
+ export type MonitoringRun = {
36
+ runId: string;
37
+ startedAt: string;
38
+ status: string;
39
+ new: number;
40
+ revised: number;
41
+ unchanged: number;
42
+ itemCount: number | null;
43
+ errorCount: number;
44
+ errors: (string | null)[];
45
+ };
46
+
47
+ export type MonitoringRouterRun = {
48
+ runId: string;
49
+ startedAt: string;
50
+ mode: string;
51
+ status: string;
52
+ stoppedBy: string | null;
53
+ waitingForClassifier?: string[];
54
+ counts: Record<string, number>;
55
+ errorCount: number;
56
+ };
57
+
58
+ export type MonitoringFeed = {
59
+ id: string;
60
+ sourceUrl: string | null;
61
+ intervalSeconds: number | null;
62
+ limit: number | null;
63
+ enabled: boolean;
64
+ lastStatus: string | null;
65
+ lastFinishedAt: string | null;
66
+ nextDueAt: string | null;
67
+ };
68
+
69
+ export type MonitoringOverview = {
70
+ state: "ready" | "empty";
71
+ asOf: string;
72
+ notice?: "publication_not_routed";
73
+ scope: { mode: "publication" | "all"; publicationId: string | null; site: string | null; canViewAll: boolean };
74
+ feeds: MonitoringFeed[];
75
+ runs: MonitoringRun[];
76
+ routerRuns: MonitoringRouterRun[];
77
+ router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
78
+ usage: {
79
+ day: string;
80
+ decisionsToday: number;
81
+ decisionsTotal: number;
82
+ ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
83
+ } | null;
84
+ items: MonitoringItem[];
85
+ hiddenNotRelevant: number;
86
+ };
87
+
88
+ export type MonitoringResult =
89
+ | MonitoringOverview
90
+ | { state: "not_configured" }
91
+ | { state: "unavailable" }
92
+ | { error: "forbidden" | "bad_request" };
93
+
94
+ const ROUTING_WORDS: Record<string, string> = {
95
+ collected: "Collected, waiting for the router",
96
+ "dry-run-validated": "Checked in a dry run, not sent",
97
+ decided: "Judged, delivery not finished",
98
+ routed: "Routed",
99
+ refused: "Refused by the shared Cyclotron",
100
+ uncertain: "Uncertain, needs a person",
101
+ error: "Error",
102
+ };
103
+
104
+ export function routingLabel(routing: string): string {
105
+ return ROUTING_WORDS[routing] ?? routing;
106
+ }
107
+
108
+ /** Fixed-format UTC time so the page reads the same everywhere. */
109
+ export function formatUtc(iso: string | null | undefined): string {
110
+ if (!iso) return "never";
111
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
112
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
113
+ }
114
+
115
+ export function confidenceText(confidence: number | null): string {
116
+ return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
117
+ }
118
+
119
+ export function usageText(used: number, ceiling: number | undefined): string {
120
+ return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
121
+ }
122
+
123
+ export function everyText(seconds: number | null): string {
124
+ if (!seconds) return "on a schedule";
125
+ return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
126
+ }
127
+
128
+ export function referenceHref(lineageId: string, demo?: boolean): string {
129
+ return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
130
+ }
131
+
132
+ export function describeRelevance(entry: MonitoringRelevance): string {
133
+ if (entry.relevant === null) return "Not judged yet";
134
+ return entry.relevant ? "Relevant" : "Not relevant";
135
+ }
136
+
137
+ export function describeDelivery(entry: MonitoringRelevance): string {
138
+ switch (entry.delivery.state) {
139
+ case "delivered": return "In the References queue";
140
+ case "failed": return "Not in the References queue: delivery failed";
141
+ case "pending": return "Not in the References queue yet";
142
+ case "not_applicable": return "Nothing to deliver";
143
+ default: return "";
144
+ }
145
+ }
@@ -1,5 +1,4 @@
1
1
  import type { CategorySteeringDashboard } from "./category-repository";
2
- import { createEmptyCategorySteeringDashboard } from "./category-dashboard";
3
2
  import type { ReaderAuthSnapshot } from "../components/reader-auth-state";
4
3
 
5
4
  export type NewsDeskShellPhase =
@@ -59,26 +58,6 @@ export function beginDeskLoad(state: NewsDeskShellState, auth: ReaderAuthSnapsho
59
58
  };
60
59
  }
61
60
 
62
- export function beginAuthenticatedDeskHydration(
63
- state: NewsDeskShellState,
64
- auth: ReaderAuthSnapshot,
65
- options: { canManageUsers: boolean },
66
- ): NewsDeskShellState {
67
- const dashboard = state.dashboard ?? {
68
- ...createEmptyCategorySteeringDashboard(),
69
- isPublicSkeleton: false,
70
- canManageUsers: options.canManageUsers,
71
- };
72
- return {
73
- ...state,
74
- phase: state.dashboard ? "refreshing" : "loadingDesk",
75
- auth,
76
- dashboard,
77
- stale: false,
78
- error: null,
79
- };
80
- }
81
-
82
61
  export function resolveSignedOut(state: NewsDeskShellState, auth: ReaderAuthSnapshot): NewsDeskShellState {
83
62
  return {
84
63
  ...state,
@@ -143,6 +122,18 @@ export function resolveDashboardFailure(
143
122
  };
144
123
  }
145
124
 
125
+ /** An unverified session must never leave private records on screen. */
126
+ export function resolveAccessFailure(state: NewsDeskShellState, error: string): NewsDeskShellState {
127
+ return {
128
+ ...state,
129
+ phase: "error",
130
+ auth: { status: "signedOut", label: "Signed out" },
131
+ dashboard: null,
132
+ stale: false,
133
+ error,
134
+ };
135
+ }
136
+
146
137
  export function patchDashboard(
147
138
  state: NewsDeskShellState,
148
139
  patch: (dashboard: CategorySteeringDashboard) => CategorySteeringDashboard,