@anthusai/papyrus 2.0.0-next.6 → 2.0.0-next.61

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 (164) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2028 -1979
  4. package/amplify/data/schema.ts +72 -14
  5. package/amplify/functions/assignment-action/data-access.js +90 -0
  6. package/amplify/functions/assignment-action/data-access.ts +89 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +61 -2
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  15. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +63 -4
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/newsletter/config.js +245 -0
  40. package/amplify/plugins/newsletter/config.ts +305 -0
  41. package/amplify/plugins/newsletter/function/handler.py +6 -0
  42. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  43. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  44. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  45. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  46. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  47. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  48. package/amplify/plugins/newsletter/schema.js +56 -0
  49. package/amplify/plugins/newsletter/schema.ts +61 -0
  50. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  51. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  52. package/amplify/plugins/newsletter/sending.js +140 -0
  53. package/amplify/plugins/newsletter/sending.ts +186 -0
  54. package/amplify/plugins/reader-steering/config.js +173 -0
  55. package/amplify/plugins/reader-steering/config.ts +268 -0
  56. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  57. package/amplify/plugins/reader-steering/function/handler.py +1363 -0
  58. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  59. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  60. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  61. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  62. package/amplify/plugins/reader-steering/papyrus.plugin.js +176 -0
  63. package/amplify/plugins/reader-steering/papyrus.plugin.ts +202 -0
  64. package/amplify/plugins/reader-steering/schema.js +140 -0
  65. package/amplify/plugins/reader-steering/schema.ts +142 -0
  66. package/amplify/plugins/registry.js +4 -2
  67. package/amplify/plugins/registry.ts +4 -2
  68. package/amplify/plugins/types.ts +5 -0
  69. package/amplify/plugins/wire.js +1 -0
  70. package/amplify/plugins/wire.ts +1 -0
  71. package/amplify/publication/projection-lease.js +30 -0
  72. package/amplify/publication/projection-lease.ts +39 -0
  73. package/amplify/publication/redirect-and-index.js +105 -0
  74. package/amplify/publication/redirect-rules.js +73 -0
  75. package/amplify/publication/redirect-rules.ts +93 -0
  76. package/amplify/publication/s3-projection.js +7 -1
  77. package/amplify/publication/s3-projection.ts +9 -1
  78. package/amplify/site-backend-config.ts +17 -0
  79. package/amplify/site-backend.js +38 -1
  80. package/amplify/site-backend.ts +46 -1
  81. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  82. package/app/api/newsletter/confirm/route.ts +13 -0
  83. package/app/api/newsletter/preferences/route.ts +13 -0
  84. package/app/api/newsletter/state/route.ts +9 -0
  85. package/app/api/newsletter/subscribe/route.ts +9 -0
  86. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  87. package/app/api/newsroom/curation/context/route.ts +9 -0
  88. package/app/api/newsroom/curation/priming/route.ts +9 -0
  89. package/app/api/newsroom/curation/publication/route.ts +9 -0
  90. package/app/api/reader/comment/delete/route.ts +9 -0
  91. package/app/api/reader/comment/edit/route.ts +9 -0
  92. package/app/api/reader/comment/route.ts +9 -0
  93. package/app/api/reader/handle/route.ts +9 -0
  94. package/app/api/reader/me/route.ts +9 -0
  95. package/app/api/reader/rating/route.ts +9 -0
  96. package/app/api/reader/report/route.ts +9 -0
  97. package/app/api/reader/retired/route.ts +9 -0
  98. package/app/api/reader/submit/route.ts +9 -0
  99. package/app/api/reader/vote/route.ts +9 -0
  100. package/bin/papyrus-infra.mjs +20 -1
  101. package/components/editorial-checks-admin-panel.tsx +36 -11
  102. package/components/moderation-panel.tsx +248 -0
  103. package/components/monitoring-overview-view.tsx +241 -0
  104. package/components/monitoring-panel.tsx +40 -0
  105. package/components/newsletter-panel.tsx +474 -0
  106. package/components/newsletter-sections.tsx +163 -0
  107. package/components/newsroom-article-editor.tsx +2 -4
  108. package/components/newsroom-curation-bootstrap.tsx +45 -0
  109. package/components/newsroom-organizer.css +31 -0
  110. package/components/newsroom-references-view.tsx +14 -14
  111. package/components/publication-cyclotron-panel.tsx +15 -10
  112. package/components/reference-add-entry.tsx +75 -0
  113. package/components/reference-curation-controls.tsx +31 -22
  114. package/components/reference-cyclotron-priming.tsx +58 -19
  115. package/components/reference-cyclotron-suggestion.tsx +1 -1
  116. package/components/reference-priming.tsx +14 -10
  117. package/components/reference-section-suggestion.tsx +1 -1
  118. package/components/topic-steering-workspace.tsx +64 -20
  119. package/components/use-editorial-checks.ts +12 -10
  120. package/infra/amplify-app-shell.js +90 -16
  121. package/infra/amplify-app-shell.ts +102 -18
  122. package/infra/index.js +1 -0
  123. package/infra/secure-string-parameter-handler.js +41 -0
  124. package/infra/secure-string-parameter-handler.ts +41 -0
  125. package/infra/secure-string-parameter.js +79 -0
  126. package/infra/secure-string-parameter.ts +91 -0
  127. package/infra/site-config.js +86 -6
  128. package/infra/site-config.ts +129 -6
  129. package/lib/category-repository.ts +11 -9
  130. package/lib/curation-routes.ts +345 -0
  131. package/lib/editorial-checks-backend.ts +1 -1
  132. package/lib/editorial-checks-client.ts +19 -1
  133. package/lib/editorial-checks-demo.ts +2 -2
  134. package/lib/editorial-checks-settings.ts +3 -0
  135. package/lib/editorial-checks.ts +8 -11
  136. package/lib/editorial-preview.ts +5 -5
  137. package/lib/monitoring-client.ts +29 -0
  138. package/lib/monitoring-overview-fixture.ts +51 -0
  139. package/lib/monitoring-overview.ts +145 -0
  140. package/lib/newsletter-api.ts +418 -0
  141. package/lib/newsletter-client.ts +46 -0
  142. package/lib/newsletter-fixture.ts +184 -0
  143. package/lib/newsletter-model.ts +202 -0
  144. package/lib/prospectus-session.ts +15 -14
  145. package/lib/prospectus-sign-in-routes.ts +84 -19
  146. package/lib/prospectus-site-identity.ts +24 -1
  147. package/lib/reader-api.ts +145 -0
  148. package/lib/reader-moderation.ts +220 -0
  149. package/lib/reader-retirement-route.ts +89 -0
  150. package/lib/reader-subject.md +29 -0
  151. package/lib/reader-subject.ts +53 -0
  152. package/lib/relevance-status.ts +2 -2
  153. package/lib/semantic-graph.ts +2 -0
  154. package/lib/site-brand-tenant.ts +11 -6
  155. package/lib/site-brand.ts +6 -3
  156. package/lib/site-origin.ts +67 -0
  157. package/middleware.ts +14 -3
  158. package/package.json +41 -3
  159. package/routes.manifest.json +204 -2
  160. package/amplify/plugins/editorial-checks/function/handler.py +0 -11
  161. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  162. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
  163. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
  164. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterSubscribe } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleNewsletterSubscribe(request);
9
+ }
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleNewsletterUnsubscribe } from "../../../../lib/newsletter-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleNewsletterUnsubscribe(request);
9
+ }
10
+
11
+ export function POST(request: NextRequest) {
12
+ return handleNewsletterUnsubscribe(request);
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleCurationRoute(request, "context");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleCurationRoute(request, "priming");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleCurationRoute } from "../../../../../lib/curation-routes";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleCurationRoute(request, "publication");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment-delete");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment-edit");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "comment");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "handle");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleReaderRequest(request, "me");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "rating");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "report");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRetiredSubjects } from "../../../../lib/reader-retirement-route";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function GET(request: NextRequest) {
8
+ return handleReaderRetiredSubjects(request);
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "submit");
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "vote");
9
+ }
@@ -19,6 +19,7 @@ const USAGE = [
19
19
  " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
20
20
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
21
21
  " papyrus-infra deploy-edge --stack NAME --template FILE [--adoption-template FILE --adopt-resources FILE] [--parameter KEY=VALUE]... [--delete-function NAME] [--only-branch main] [--only-app-id ID] [--region R]",
22
+ " papyrus-infra compile-edge-function --static-dir web [--out FILE]",
22
23
  " papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
23
24
  ].join("\n");
24
25
 
@@ -36,11 +37,29 @@ const resolveEnvironmentOrExit = () => {
36
37
  }
37
38
  };
38
39
  const command = args[0];
39
- if (command !== "synth" && command !== "print-buildspec" && command !== "deploy" && command !== "deploy-edge") {
40
+ if (command !== "synth" && command !== "print-buildspec" && command !== "deploy" && command !== "deploy-edge" && command !== "compile-edge-function") {
40
41
  console.error(USAGE);
41
42
  process.exit(command ? 1 : 0);
42
43
  }
43
44
 
45
+ if (command === "compile-edge-function") {
46
+ // The viewer-request CloudFront Function with the site's projection.json rules compiled in: for an edge stack that is
47
+ // not deployed by the CMS backend, so that no site writes its own redirect function.
48
+ const staticDir = opt("static-dir");
49
+ if (!staticDir) {
50
+ console.error(USAGE);
51
+ process.exit(1);
52
+ }
53
+ const { compileRedirectFunctionSource, readEdgeConfig } = await import("../amplify/publication/redirect-rules.js");
54
+ const functionSource = fs.readFileSync(new URL("../amplify/publication/redirect-and-index.js", import.meta.url), "utf8");
55
+ const config = readEdgeConfig(staticDir);
56
+ const compiled = compileRedirectFunctionSource(functionSource, config.rules, { negotiateMarkdown: config.negotiateMarkdown, directoryTwins: config.directoryTwins });
57
+ const out = opt("out");
58
+ if (out) fs.writeFileSync(path.resolve(out), compiled);
59
+ else process.stdout.write(compiled);
60
+ process.exit(0);
61
+ }
62
+
44
63
  if (command === "deploy-edge") {
45
64
  const buildAppId = process.env.AWS_APP_ID;
46
65
  const buildBranch = process.env.AWS_BRANCH;
@@ -12,6 +12,7 @@ import {
12
12
  draftQuestions,
13
13
  importStarterPack,
14
14
  listPolicies,
15
+ listStarterPacks,
15
16
  loadSettings,
16
17
  recalibrate,
17
18
  savePolicy,
@@ -19,9 +20,9 @@ import {
19
20
  tryPolicy,
20
21
  type EditorialPolicyRecord,
21
22
  type EditorialSettings,
23
+ type StarterPackSummary,
22
24
  } from "../lib/editorial-checks-client";
23
25
 
24
- const STARTER_PACK = "ai-writing-tells";
25
26
  const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
26
27
 
27
28
  type Draft = {
@@ -63,8 +64,8 @@ function lines(text: string): string[] {
63
64
  return text.split("\n").map((line) => line.trim()).filter(Boolean);
64
65
  }
65
66
 
66
- /** The policy as Limatus sees it (its key is the policy id). Throws on invalid detector JSON. */
67
- function limatusPolicy(draft: Draft): Record<string, unknown> {
67
+ /** The policy as Humanus sees it (its key is the policy id). Throws on invalid detector JSON. */
68
+ function humanusPolicy(draft: Draft): Record<string, unknown> {
68
69
  const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
69
70
  if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
70
71
  const violates = lines(draft.violatesText);
@@ -87,6 +88,8 @@ export function EditorialChecksAdminPanel() {
87
88
  const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
88
89
  const [settingsExist, setSettingsExist] = useState(false);
89
90
  const [draft, setDraft] = useState<Draft | null>(null);
91
+ const [starterPacks, setStarterPacks] = useState<StarterPackSummary[]>([]);
92
+ const [starterPack, setStarterPack] = useState<string>("");
90
93
  const [message, setMessage] = useState<string | null>(null);
91
94
  const [working, setWorking] = useState<string | null>(null);
92
95
  const [tryText, setTryText] = useState("");
@@ -94,7 +97,9 @@ export function EditorialChecksAdminPanel() {
94
97
 
95
98
  const refresh = useCallback(async () => {
96
99
  try {
97
- const [loadedPolicies, loadedSettings] = await Promise.all([listPolicies(), loadSettings()]);
100
+ const [loadedPolicies, loadedSettings, catalog] = await Promise.all([listPolicies(), loadSettings(), listStarterPacks()]);
101
+ setStarterPacks(catalog.packs);
102
+ setStarterPack((current) => current || catalog.packs[0]?.name || "");
98
103
  setPolicies(loadedPolicies);
99
104
  setSettings(loadedSettings.settings);
100
105
  setSettingsExist(loadedSettings.exists);
@@ -128,7 +133,7 @@ export function EditorialChecksAdminPanel() {
128
133
  const key = draft.record.policyKey.trim();
129
134
  if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
130
135
  if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
131
- const policy = limatusPolicy(draft);
136
+ const policy = humanusPolicy(draft);
132
137
  const record: EditorialPolicyRecord = {
133
138
  ...draft.record,
134
139
  id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
@@ -157,19 +162,38 @@ export function EditorialChecksAdminPanel() {
157
162
  <h4>Policies</h4>
158
163
  <div className="flex flex-wrap gap-2">
159
164
  <Button onClick={() => { setDraft(draftFrom(emptyPolicy(), true)); setTryResult(null); }} size="sm" type="button">New policy</Button>
165
+ <select aria-label="Starter pack" onChange={(event) => setStarterPack(event.target.value)} value={starterPack}>
166
+ {starterPacks.map((pack) => (
167
+ <option key={pack.name} value={pack.name}>
168
+ {pack.title} ({pack.importedCount} of {pack.policyCount} added)
169
+ </option>
170
+ ))}
171
+ </select>
160
172
  <Button
161
- disabled={working !== null}
173
+ disabled={working !== null || !starterPack}
162
174
  onClick={() => run("import", async () => {
163
- const outcome = await importStarterPack(STARTER_PACK);
175
+ const outcome = await importStarterPack(starterPack);
164
176
  await refresh();
165
- return `Starter pack: ${outcome.created} policies added, ${outcome.skipped} already present.`;
177
+ const label = starterPacks.find((pack) => pack.name === starterPack)?.title ?? starterPack;
178
+ return `${label}: ${outcome.created} policies added, ${outcome.skipped} already present.`;
166
179
  })}
167
180
  size="sm"
168
181
  type="button"
169
182
  variant="outline"
170
183
  >
171
- Add AI-writing tells starter pack
184
+ Add starter pack
172
185
  </Button>
186
+ </div>
187
+ {(() => {
188
+ const selected = starterPacks.find((pack) => pack.name === starterPack);
189
+ return selected ? (
190
+ <p className="text-sm text-muted-foreground">
191
+ {selected.title}{selected.version ? ` ${selected.version}` : ""}: {selected.policyCount} policies
192
+ {selected.isDefault ? " (added to every new site)" : ""}. {selected.guidance}
193
+ </p>
194
+ ) : null;
195
+ })()}
196
+ <div className="flex flex-wrap gap-2">
173
197
  <Button
174
198
  disabled={working !== null}
175
199
  onClick={() => run("recalibrate", async () => {
@@ -243,7 +267,7 @@ export function EditorialChecksAdminPanel() {
243
267
  <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
244
268
  <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
245
269
  <label>
246
- Detectors (JSON; see <code>limatus schema policy-pack-v1</code>)
270
+ Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
247
271
  <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
248
272
  </label>
249
273
  <div className="flex flex-wrap gap-2">
@@ -284,7 +308,7 @@ export function EditorialChecksAdminPanel() {
284
308
  <Button
285
309
  disabled={working !== null || !tryText.trim()}
286
310
  onClick={() => run("try", async () => {
287
- setTryResult(await tryPolicy(limatusPolicy(draft), tryText));
311
+ setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
288
312
  })}
289
313
  size="sm"
290
314
  type="button"
@@ -320,6 +344,7 @@ export function EditorialChecksAdminPanel() {
320
344
  });
321
345
  }}
322
346
  >
347
+ <label><input checked={settings.enabled} data-editorial-enabled onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} type="checkbox" /> Run editorial checks for this publication (turning this off stops checks and rewrites; policies are kept)</label>
323
348
  <label><input checked={settings.lanes.rules} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, rules: event.target.checked } })} type="checkbox" /> Rule checks</label>
324
349
  <label><input checked={settings.lanes.classifier} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, classifier: event.target.checked } })} type="checkbox" /> Judgment checks (decision model)</label>
325
350
  <label><input checked={settings.checkWhileTyping} onChange={(event) => setSettings({ ...settings, checkWhileTyping: event.target.checked })} type="checkbox" /> Check while editors type (after a short pause)</label>
@@ -0,0 +1,248 @@
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 { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
7
+ import {
8
+ buildActionLog,
9
+ buildHidden,
10
+ buildQueue,
11
+ moderationModeText,
12
+ receiptMessage,
13
+ shortReader,
14
+ type ActionEntry,
15
+ type ActionRow,
16
+ type CommentRow,
17
+ type ModerationActionName,
18
+ type ModerationRow,
19
+ type ModerationTarget,
20
+ type Receipt,
21
+ type ReportRow,
22
+ type SubmissionRow,
23
+ } from "../lib/reader-moderation";
24
+
25
+ const SUBMISSION_FIELDS = "id canonicalUrl domain title kind note readerId state moderation reportCount signals itemId createdAt";
26
+ const COMMENT_FIELDS = "id subjectKey readerId body parentId state moderation reportCount signals createdAt";
27
+
28
+ const QUEUE_SUBMISSIONS = `query Queue($nextToken: String) { listReaderSubmissionsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
29
+ const QUEUE_COMMENTS = `query Queue($nextToken: String) { listReaderCommentsByModerationAndCreatedAt(moderation: "new", sortDirection: DESC, limit: 100, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
30
+ const HIDDEN_SUBMISSIONS = `query Hidden($nextToken: String) { listReaderSubmissions(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${SUBMISSION_FIELDS} } nextToken } }`;
31
+ const HIDDEN_COMMENTS = `query Hidden($nextToken: String) { listReaderComments(filter: { state: { eq: "hidden" } }, limit: 500, nextToken: $nextToken) { items { ${COMMENT_FIELDS} } nextToken } }`;
32
+ const OPEN_REPORTS = `query Reports($nextToken: String) { listReaderReports(filter: { state: { eq: "open" } }, limit: 500, nextToken: $nextToken) { items { id targetType target reporterReaderId reason state createdAt } nextToken } }`;
33
+ const ACTIONS = `query Actions($nextToken: String) { listModerationActions(limit: 500, nextToken: $nextToken) { items { id by actorSub action targetType target reason fromState toState undoOf undoneBy at } nextToken } }`;
34
+ const HANDLES = `query Handles($nextToken: String) { listReaderHandles(limit: 1000, nextToken: $nextToken) { items { id readerId } nextToken } }`;
35
+ const MODERATE = `mutation Moderate($action: String!, $targetType: String!, $target: String!, $reason: String, $undoOf: String) { moderateReader(action: $action, targetType: $targetType, target: $target, reason: $reason, undoOf: $undoOf) { status id error message } }`;
36
+
37
+ type GraphResult = { data?: Record<string, { items: unknown[]; nextToken?: string | null } | Receipt | null>; errors?: { message?: string }[] };
38
+ const MAXIMUM_PAGES = 20;
39
+
40
+ async function loadAll<T>(query: string, field: string): Promise<T[]> {
41
+ const client = generateClient({ authMode: "userPool" });
42
+ const rows: T[] = [];
43
+ let nextToken: string | null | undefined;
44
+ for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
45
+ const result = (await client.graphql({ query, variables: { nextToken } })) as GraphResult;
46
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load moderation data.");
47
+ const connection = result.data?.[field] as { items: T[]; nextToken?: string | null } | undefined;
48
+ rows.push(...(connection?.items ?? []).filter(Boolean));
49
+ nextToken = connection?.nextToken;
50
+ if (!nextToken) break;
51
+ }
52
+ return rows;
53
+ }
54
+
55
+ async function moderate(variables: Record<string, string | undefined>): Promise<Receipt> {
56
+ const client = generateClient({ authMode: "userPool" });
57
+ const result = (await client.graphql({ query: MODERATE, variables })) as GraphResult;
58
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
59
+ return (result.data?.moderateReader as Receipt | null) ?? { status: "refused", message: "The action did not answer." };
60
+ }
61
+
62
+ export type ModerationViewProps = {
63
+ mode: unknown;
64
+ queue: ModerationRow[];
65
+ hidden: ModerationRow[];
66
+ log: ActionEntry[];
67
+ tab: "queue" | "hidden" | "log";
68
+ busy: boolean;
69
+ status: string;
70
+ reason: string;
71
+ onTab: (tab: "queue" | "hidden" | "log") => void;
72
+ onReason: (reason: string) => void;
73
+ onAction: (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => void;
74
+ onRefresh: () => void;
75
+ };
76
+
77
+ function Row({ row, busy, onAction, listed }: { row: ModerationRow; busy: boolean; onAction: ModerationViewProps["onAction"]; listed: "queue" | "hidden" }) {
78
+ const kind = row.kind === "submission" ? "Submission" : "Comment";
79
+ return (
80
+ <li data-moderation-row={row.id}>
81
+ <article aria-label={`${kind} by ${shortReader(row.readerId, row.handle)}`}>
82
+ <p>
83
+ <strong>{kind}</strong> by {shortReader(row.readerId, row.handle)} <time dateTime={row.createdAt}>{row.createdAt}</time> ({row.state}
84
+ {row.reports.length > 0 ? `, ${row.reports.length} ${row.reports.length === 1 ? "report" : "reports"}` : ""})
85
+ </p>
86
+ {row.kind === "submission" ? (
87
+ <p>
88
+ {row.url ? (
89
+ <a href={row.url} target="_blank" rel="noopener noreferrer nofollow">{row.title}</a>
90
+ ) : (
91
+ <span>{row.title}</span>
92
+ )}
93
+ </p>
94
+ ) : null}
95
+ {row.text ? <p style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.text}</p> : null}
96
+ {row.signals.length > 0 ? (
97
+ <ul aria-label="Automatic signals">
98
+ {row.signals.map((signal, index) => (
99
+ <li key={`${signal.detector}-${index}`}>Signal ({signal.severity}): {signal.policy} / {signal.detector}{signal.excerpt ? ` — “${signal.excerpt}”` : ""}</li>
100
+ ))}
101
+ </ul>
102
+ ) : null}
103
+ {row.reports.length > 0 ? (
104
+ <ul aria-label="Reports">
105
+ {row.reports.map((report) => (
106
+ <li key={report.id}>Reported: {report.reason}</li>
107
+ ))}
108
+ </ul>
109
+ ) : null}
110
+ <p>
111
+ {listed === "queue" ? (
112
+ <button type="button" disabled={busy} onClick={() => onAction("approve", row.kind, row.id)}>Approve</button>
113
+ ) : (
114
+ <button type="button" disabled={busy} onClick={() => onAction("unhide", row.kind, row.id)}>Show again</button>
115
+ )}{" "}
116
+ {row.state !== "hidden" ? <button type="button" disabled={busy} onClick={() => onAction("hide", row.kind, row.id)}>Hide</button> : null}{" "}
117
+ {row.canPromote ? <button type="button" disabled={busy} onClick={() => onAction("promote", row.kind, row.id)}>Promote to a draft</button> : null}{" "}
118
+ <button type="button" disabled={busy} onClick={() => onAction("ban", "reader", row.readerId)}>Ban reader</button>{" "}
119
+ <button type="button" disabled={busy} onClick={() => onAction("limit", "reader", row.readerId)}>Limit reader</button>
120
+ </p>
121
+ </article>
122
+ </li>
123
+ );
124
+ }
125
+
126
+ export function ModerationView(props: ModerationViewProps) {
127
+ const { queue, hidden, log, tab, busy } = props;
128
+ return (
129
+ <section aria-labelledby="newsroom-moderation-title" data-news-desk-admin-panel="moderation">
130
+ <h3 id="newsroom-moderation-title">Moderation</h3>
131
+ <p>{moderationModeText(props.mode)} Only editors and admins can see this.</p>
132
+ <p role="tablist" aria-label="Moderation views">
133
+ <button type="button" role="tab" aria-selected={tab === "queue"} onClick={() => props.onTab("queue")}>Queue ({queue.length})</button>{" "}
134
+ <button type="button" role="tab" aria-selected={tab === "hidden"} onClick={() => props.onTab("hidden")}>Hidden ({hidden.length})</button>{" "}
135
+ <button type="button" role="tab" aria-selected={tab === "log"} onClick={() => props.onTab("log")}>Recent actions</button>{" "}
136
+ <button type="button" onClick={props.onRefresh} disabled={busy}>Refresh</button>
137
+ </p>
138
+ <label>
139
+ Reason for the next action (optional){" "}
140
+ <input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
141
+ </label>
142
+ <p role="status" aria-live="polite">{props.status}</p>
143
+ {tab === "queue" ? (
144
+ queue.length === 0 ? <p>Nothing is waiting for a decision.</p> : <ul>{queue.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="queue" />)}</ul>
145
+ ) : null}
146
+ {tab === "hidden" ? (
147
+ hidden.length === 0 ? <p>Nothing is hidden.</p> : <ul>{hidden.map((row) => <Row key={row.id} row={row} busy={busy} onAction={props.onAction} listed="hidden" />)}</ul>
148
+ ) : null}
149
+ {tab === "log" ? (
150
+ log.length === 0 ? <p>No moderation actions yet.</p> : (
151
+ <table>
152
+ <thead>
153
+ <tr><th scope="col">When</th><th scope="col">Action</th><th scope="col">Target</th><th scope="col">By</th><th scope="col">Reason</th><th scope="col"><span>Undo</span></th></tr>
154
+ </thead>
155
+ <tbody>
156
+ {log.map((entry) => (
157
+ <tr key={entry.id}>
158
+ <td>{entry.at}</td>
159
+ <td>{entry.label} ({entry.fromState} to {entry.toState}){entry.undoneBy ? ", undone" : ""}</td>
160
+ <td>{entry.targetType} {entry.target.slice(0, 12)}</td>
161
+ <td>{entry.byLabel}</td>
162
+ <td>{entry.reason}</td>
163
+ <td>
164
+ {entry.undoable ? (
165
+ <button type="button" disabled={busy} onClick={() => props.onAction("undo", entry.targetType as ModerationTarget, entry.target, entry.id)}>Undo</button>
166
+ ) : null}
167
+ </td>
168
+ </tr>
169
+ ))}
170
+ </tbody>
171
+ </table>
172
+ )
173
+ ) : null}
174
+ </section>
175
+ );
176
+ }
177
+
178
+ export function ModerationPanel() {
179
+ const mode = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering")?.moderation, []);
180
+ const [tab, setTab] = useState<"queue" | "hidden" | "log">("queue");
181
+ const [data, setData] = useState<{ queue: ModerationRow[]; hidden: ModerationRow[]; log: ActionEntry[] } | null>(null);
182
+ const [status, setStatus] = useState("Loading...");
183
+ const [busy, setBusy] = useState(false);
184
+ const [reason, setReason] = useState("");
185
+
186
+ const refresh = useCallback(async (quiet = false) => {
187
+ setBusy(true);
188
+ try {
189
+ const [newSubmissions, newComments, hiddenSubmissions, hiddenComments, reports, actions, handleRows] = await Promise.all([
190
+ loadAll<SubmissionRow>(QUEUE_SUBMISSIONS, "listReaderSubmissionsByModerationAndCreatedAt"),
191
+ loadAll<CommentRow>(QUEUE_COMMENTS, "listReaderCommentsByModerationAndCreatedAt"),
192
+ loadAll<SubmissionRow>(HIDDEN_SUBMISSIONS, "listReaderSubmissions"),
193
+ loadAll<CommentRow>(HIDDEN_COMMENTS, "listReaderComments"),
194
+ loadAll<ReportRow>(OPEN_REPORTS, "listReaderReports"),
195
+ loadAll<ActionRow>(ACTIONS, "listModerationActions"),
196
+ loadAll<{ id: string; readerId: string }>(HANDLES, "listReaderHandles"),
197
+ ]);
198
+ const handles = Object.fromEntries(handleRows.map((row) => [row.readerId, row.id]));
199
+ setData({
200
+ queue: buildQueue({ submissions: newSubmissions, comments: newComments, reports, handles }),
201
+ hidden: buildHidden({ submissions: hiddenSubmissions, comments: hiddenComments, reports, handles }),
202
+ log: buildActionLog(actions),
203
+ });
204
+ if (!quiet) setStatus("");
205
+ } catch (error) {
206
+ setStatus(`Error: ${error instanceof Error ? error.message : "Could not load moderation data."}`);
207
+ } finally {
208
+ setBusy(false);
209
+ }
210
+ }, []);
211
+
212
+ useEffect(() => {
213
+ void refresh();
214
+ }, [refresh]);
215
+
216
+ const onAction = useCallback(
217
+ async (action: ModerationActionName, targetType: ModerationTarget, target: string, undoOf?: string) => {
218
+ setBusy(true);
219
+ setStatus("Working...");
220
+ try {
221
+ const receipt = await moderate({ action, targetType, target, ...(reason.trim() ? { reason: reason.trim() } : {}), ...(undoOf ? { undoOf } : {}) });
222
+ setStatus(receiptMessage(receipt));
223
+ if (receipt.status === "ok") setReason("");
224
+ } catch (error) {
225
+ setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
226
+ }
227
+ await refresh(true);
228
+ },
229
+ [reason, refresh],
230
+ );
231
+
232
+ return (
233
+ <ModerationView
234
+ mode={mode}
235
+ queue={data?.queue ?? []}
236
+ hidden={data?.hidden ?? []}
237
+ log={data?.log ?? []}
238
+ tab={tab}
239
+ busy={busy}
240
+ status={status}
241
+ reason={reason}
242
+ onTab={setTab}
243
+ onReason={setReason}
244
+ onAction={(action, targetType, target, undoOf) => void onAction(action, targetType, target, undoOf)}
245
+ onRefresh={() => void refresh()}
246
+ />
247
+ );
248
+ }