@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.40

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 (118) hide show
  1. package/amplify/data/resource.js +22 -3
  2. package/amplify/data/resource.ts +24 -3
  3. package/amplify/data/schema.js +2029 -1967
  4. package/amplify/data/schema.ts +72 -2
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/content-actions/handler.py +33 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -0
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/editorial-checks/handler.py +14 -0
  12. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  13. package/amplify/functions/editorial-checks/resource.js +43 -0
  14. package/amplify/functions/editorial-checks/resource.ts +60 -0
  15. package/amplify/functions/editorial-checks/schema.js +121 -0
  16. package/amplify/functions/editorial-checks/schema.ts +123 -0
  17. package/amplify/functions/editorial-checks/wire.js +53 -0
  18. package/amplify/functions/editorial-checks/wire.ts +61 -0
  19. package/amplify/functions/monitoring-view/handler.py +11 -0
  20. package/amplify/functions/monitoring-view/resource.js +24 -0
  21. package/amplify/functions/monitoring-view/resource.ts +34 -0
  22. package/amplify/functions/monitoring-view/schema.js +16 -0
  23. package/amplify/functions/monitoring-view/schema.ts +18 -0
  24. package/amplify/functions/monitoring-view/wire.js +37 -0
  25. package/amplify/functions/monitoring-view/wire.ts +47 -0
  26. package/amplify/functions/public-forms/core.js +247 -0
  27. package/amplify/functions/public-forms/core.ts +310 -0
  28. package/amplify/functions/public-forms/handler.ts +41 -0
  29. package/amplify/functions/public-forms/origins.js +35 -0
  30. package/amplify/functions/public-forms/origins.ts +43 -0
  31. package/amplify/functions/public-forms/resource.js +9 -0
  32. package/amplify/functions/public-forms/resource.ts +10 -0
  33. package/amplify/functions/public-forms/wire.js +25 -0
  34. package/amplify/functions/public-forms/wire.ts +32 -0
  35. package/amplify/functions/shared/python-bundle.js +20 -14
  36. package/amplify/functions/shared/python-bundle.ts +25 -18
  37. package/amplify/publication/redirect-and-index.js +105 -0
  38. package/amplify/publication/redirect-rules.js +73 -0
  39. package/amplify/publication/redirect-rules.ts +93 -0
  40. package/amplify/publication/s3-projection.js +7 -1
  41. package/amplify/publication/s3-projection.ts +9 -1
  42. package/amplify/site-backend-config.ts +17 -0
  43. package/amplify/site-backend.js +36 -1
  44. package/amplify/site-backend.ts +44 -1
  45. package/app/api/newsroom/curation/context/route.ts +9 -0
  46. package/app/api/newsroom/curation/priming/route.ts +9 -0
  47. package/app/api/newsroom/curation/publication/route.ts +9 -0
  48. package/bin/papyrus-infra.mjs +20 -1
  49. package/components/editorial-checks-admin-panel.tsx +362 -0
  50. package/components/editorial-finding-card.tsx +162 -0
  51. package/components/editorial-findings-panel.tsx +91 -0
  52. package/components/editorial-preview-popover.tsx +103 -0
  53. package/components/markus-body-editor.tsx +213 -0
  54. package/components/markus-ir-preview.tsx +147 -6
  55. package/components/monitoring-overview-view.tsx +241 -0
  56. package/components/monitoring-panel.tsx +40 -0
  57. package/components/newsroom-article-editor.tsx +136 -11
  58. package/components/newsroom-board-view.tsx +6 -7
  59. package/components/newsroom-curation-bootstrap.tsx +45 -0
  60. package/components/newsroom-ops-shell.tsx +7 -3
  61. package/components/newsroom-organizer.css +74 -61
  62. package/components/newsroom-references-view.tsx +14 -14
  63. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  64. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  65. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  66. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  67. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  68. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  69. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  70. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  71. package/components/newsroom-theme.css +39 -0
  72. package/components/newsroom-theme.tsx +33 -0
  73. package/components/publication-cyclotron-panel.tsx +15 -10
  74. package/components/reference-add-entry.tsx +75 -0
  75. package/components/reference-curation-controls.tsx +31 -22
  76. package/components/reference-cyclotron-priming.tsx +4 -4
  77. package/components/reference-cyclotron-suggestion.tsx +1 -1
  78. package/components/reference-priming.tsx +14 -10
  79. package/components/reference-section-suggestion.tsx +1 -1
  80. package/components/topic-steering-workspace.tsx +27 -2
  81. package/components/use-editorial-checks.ts +133 -0
  82. package/infra/amplify-app-shell.js +76 -16
  83. package/infra/amplify-app-shell.ts +86 -18
  84. package/infra/index.js +1 -0
  85. package/infra/secure-string-parameter-handler.js +41 -0
  86. package/infra/secure-string-parameter-handler.ts +41 -0
  87. package/infra/secure-string-parameter.js +79 -0
  88. package/infra/secure-string-parameter.ts +91 -0
  89. package/infra/site-config.js +75 -6
  90. package/infra/site-config.ts +106 -6
  91. package/lib/category-repository.ts +11 -9
  92. package/lib/curation-routes.ts +345 -0
  93. package/lib/editorial-checks-backend.ts +16 -0
  94. package/lib/editorial-checks-client.ts +212 -0
  95. package/lib/editorial-checks-demo.ts +147 -0
  96. package/lib/editorial-checks-settings.ts +27 -0
  97. package/lib/editorial-checks.ts +171 -0
  98. package/lib/editorial-preview.ts +228 -0
  99. package/lib/monitoring-client.ts +29 -0
  100. package/lib/monitoring-overview-fixture.ts +51 -0
  101. package/lib/monitoring-overview.ts +145 -0
  102. package/lib/newsroom-board.ts +27 -2
  103. package/lib/prospectus-session.ts +14 -14
  104. package/lib/prospectus-sign-in-routes.ts +52 -11
  105. package/lib/relevance-status.ts +2 -2
  106. package/lib/site-brand-tenant.ts +11 -6
  107. package/lib/site-brand.ts +23 -3
  108. package/lib/site-origin.ts +67 -0
  109. package/middleware.ts +14 -3
  110. package/package.json +48 -4
  111. package/routes.manifest.json +34 -1
  112. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  113. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  115. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  118. package/components/newsroom-newsprint-board.css +0 -7
@@ -20,9 +20,14 @@ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm.js";
20
20
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource.js";
21
21
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
22
22
  import { defineSiteData } from "./data/resource.js";
23
+ import { editorialChecks } from "./functions/editorial-checks/resource.js";
24
+ import { monitoringView } from "./functions/monitoring-view/resource.js";
25
+ import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
26
+ import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
23
27
  import { resolvePlugins } from "./plugins/registry.js";
24
28
  import { wirePlugins } from "./plugins/wire.js";
25
29
  import { assignmentAction } from "./functions/assignment-action/resource.js";
30
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access.js";
26
31
  import { categoryAction } from "./functions/category-action/resource.js";
27
32
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
28
33
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource.js";
@@ -30,6 +35,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource.js";
30
35
  import { manageUserRole } from "./functions/manage-user-role/resource.js";
31
36
  import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource.js";
32
37
  import { newsroomSummary } from "./functions/newsroom-summary/resource.js";
38
+ import { derivePublicFormOrigins } from "./functions/public-forms/origins.js";
39
+ import { publicForms } from "./functions/public-forms/resource.js";
40
+ import { wirePublicForms } from "./functions/public-forms/wire.js";
33
41
  import { procedureAction } from "./functions/procedure-action/resource.js";
34
42
  import { readerSettings } from "./functions/reader-settings/resource.js";
35
43
  import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource.js";
@@ -88,12 +96,15 @@ export function defineSiteBackend(papyrusSite) {
88
96
  auth: defineSiteAuth(authConfig),
89
97
  categoryAction,
90
98
  contentActions,
91
- data: defineSiteData(pluginDefinitions),
99
+ editorialChecks,
100
+ data: defineSiteData(pluginDefinitions, site.excludeModelGroups ?? []),
92
101
  knowledgeQuery,
93
102
  manageUserRole,
103
+ monitoringView,
94
104
  modelAttachmentUpload,
95
105
  newsroomSummary,
96
106
  procedureAction,
107
+ publicForms,
97
108
  readerSettings,
98
109
  emailSubmissionProcessor,
99
110
  sesInboundReceive,
@@ -304,10 +315,19 @@ export function defineSiteBackend(papyrusSite) {
304
315
  }
305
316
  grantCorporaRead(backend.knowledgeQuery.resources.lambda);
306
317
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda);
318
+ scopeAssignmentDataAccess(backend.assignmentAction.resources.lambda, backend.data.resources.graphqlApi.arn);
307
319
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda);
308
320
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
309
321
  const contentActionsLambda = backend.contentActions.resources.lambda;
310
322
  grantNewsroomReadWriteDelete(contentActionsLambda);
323
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
324
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
325
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
326
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
327
+ }
328
+ // AppSync validates the token; pending intake additionally binds its actor
329
+ // and publication to this deployment's pool and existing API endpoint.
330
+ contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
311
331
  contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
312
332
  contentActionsLambda.addToRolePolicy(new PolicyStatement({
313
333
  actions: ["appsync:GraphQL"],
@@ -548,8 +568,21 @@ export function defineSiteBackend(papyrusSite) {
548
568
  : []),
549
569
  ],
550
570
  }));
571
+ const publicFormsEndpoint = wirePublicForms(backend, {
572
+ allowedOrigins: derivePublicFormOrigins({
573
+ explicit: site.publicForms?.allowedOrigins,
574
+ hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
575
+ urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
576
+ }),
577
+ honeypotFields: site.publicForms?.honeypotFields,
578
+ });
551
579
  backend.addOutput({
552
580
  custom: {
581
+ publicForms: { endpoint: publicFormsEndpoint },
582
+ // Lets the operator CLI re-project on publish without PAPYRUS_PUBLICATION_KIND / PAPYRUS_CONTENT_ACTIONS_FUNCTION (PPY-0c0b75).
583
+ publication: s3ProjectionPublication !== undefined
584
+ ? { kind: "s3-projection", contentActionsFunction: contentActionsLambda.functionName }
585
+ : null,
553
586
  prospectusSignIn: prospectusGoogleOnlyClient
554
587
  ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
555
588
  : null,
@@ -584,6 +617,8 @@ export function defineSiteBackend(papyrusSite) {
584
617
  : null,
585
618
  },
586
619
  });
620
+ wireEditorialChecks(backend);
621
+ wireMonitoringView(backend);
587
622
  wirePlugins(backend, plugins, contentActionsLambda);
588
623
  return backend;
589
624
  }
@@ -27,9 +27,14 @@ import { addInvalidationPathAlarm } from "./publication/invalidation-alarm";
27
27
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRedirectUrls } from "./auth/resource";
28
28
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
29
29
  import { defineSiteData } from "./data/resource";
30
+ import { editorialChecks } from "./functions/editorial-checks/resource";
31
+ import { monitoringView } from "./functions/monitoring-view/resource";
32
+ import { wireMonitoringView } from "./functions/monitoring-view/wire";
33
+ import { wireEditorialChecks } from "./functions/editorial-checks/wire";
30
34
  import { resolvePlugins } from "./plugins/registry";
31
35
  import { wirePlugins } from "./plugins/wire";
32
36
  import { assignmentAction } from "./functions/assignment-action/resource";
37
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access";
33
38
  import { categoryAction } from "./functions/category-action/resource";
34
39
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
35
40
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource";
@@ -37,6 +42,9 @@ import { knowledgeQuery } from "./functions/knowledge-query/resource";
37
42
  import { manageUserRole } from "./functions/manage-user-role/resource";
38
43
  import { modelAttachmentUpload } from "./functions/model-attachment-upload/resource";
39
44
  import { newsroomSummary } from "./functions/newsroom-summary/resource";
45
+ import { derivePublicFormOrigins } from "./functions/public-forms/origins";
46
+ import { publicForms } from "./functions/public-forms/resource";
47
+ import { wirePublicForms } from "./functions/public-forms/wire";
40
48
  import { procedureAction } from "./functions/procedure-action/resource";
41
49
  import { readerSettings } from "./functions/reader-settings/resource";
42
50
  import { emailSubmissionProcessor } from "./functions/email-submission-processor/resource";
@@ -109,12 +117,15 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
109
117
  auth: defineSiteAuth(authConfig),
110
118
  categoryAction,
111
119
  contentActions,
112
- data: defineSiteData(pluginDefinitions),
120
+ editorialChecks,
121
+ data: defineSiteData(pluginDefinitions, site.excludeModelGroups ?? []),
113
122
  knowledgeQuery,
114
123
  manageUserRole,
124
+ monitoringView,
115
125
  modelAttachmentUpload,
116
126
  newsroomSummary,
117
127
  procedureAction,
128
+ publicForms,
118
129
  readerSettings,
119
130
  emailSubmissionProcessor,
120
131
  sesInboundReceive,
@@ -387,10 +398,25 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
387
398
  }
388
399
  grantCorporaRead(backend.knowledgeQuery.resources.lambda as LambdaFunction);
389
400
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda as LambdaFunction);
401
+ scopeAssignmentDataAccess(
402
+ backend.assignmentAction.resources.lambda as LambdaFunction,
403
+ backend.data.resources.graphqlApi.arn,
404
+ );
390
405
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda as LambdaFunction);
391
406
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
392
407
  const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
393
408
  grantNewsroomReadWriteDelete(contentActionsLambda);
409
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
410
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
411
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
412
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
413
+ }
414
+ // AppSync validates the token; pending intake additionally binds its actor
415
+ // and publication to this deployment's pool and existing API endpoint.
416
+ contentActionsLambda.addEnvironment(
417
+ "PAPYRUS_USER_POOL_ISSUER",
418
+ `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`,
419
+ );
394
420
  contentActionsLambda.addEnvironment(
395
421
  "PAPYRUS_GRAPHQL_ENDPOINT",
396
422
  backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
@@ -685,8 +711,23 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
685
711
  }),
686
712
  );
687
713
 
714
+ const publicFormsEndpoint = wirePublicForms(backend, {
715
+ allowedOrigins: derivePublicFormOrigins({
716
+ explicit: site.publicForms?.allowedOrigins,
717
+ hosts: [activeBrand?.readerDeployment?.domain, ...(publication?.distribution?.aliases ?? [])],
718
+ urls: [site.revalidateBaseUrl, ...siteAuthRedirectUrls(authConfig)],
719
+ }),
720
+ honeypotFields: site.publicForms?.honeypotFields,
721
+ });
722
+
688
723
  backend.addOutput({
689
724
  custom: {
725
+ publicForms: { endpoint: publicFormsEndpoint },
726
+ // Lets the operator CLI re-project on publish without PAPYRUS_PUBLICATION_KIND / PAPYRUS_CONTENT_ACTIONS_FUNCTION (PPY-0c0b75).
727
+ publication:
728
+ s3ProjectionPublication !== undefined
729
+ ? { kind: "s3-projection", contentActionsFunction: contentActionsLambda.functionName }
730
+ : null,
690
731
  prospectusSignIn: prospectusGoogleOnlyClient
691
732
  ? { clientId: prospectusGoogleOnlyClient.ref, callbackPath: PROSPECTUS_SIGN_IN_CALLBACK_PATH }
692
733
  : null,
@@ -721,6 +762,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
721
762
  : null,
722
763
  },
723
764
  });
765
+ wireEditorialChecks(backend);
766
+ wireMonitoringView(backend);
724
767
  wirePlugins(backend, plugins, contentActionsLambda);
725
768
 
726
769
  return backend;
@@ -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
+ }
@@ -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;
@@ -0,0 +1,362 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { Button } from "./ui/button";
5
+ import { Input } from "./ui/input";
6
+ import { Spinner } from "./ui/spinner";
7
+ import { Textarea } from "./ui/textarea";
8
+ import { describeProbability, type EditorialCheckResult } from "../lib/editorial-checks";
9
+ import {
10
+ DEFAULT_EDITORIAL_SETTINGS,
11
+ deletePolicy,
12
+ draftQuestions,
13
+ importStarterPack,
14
+ listPolicies,
15
+ listStarterPacks,
16
+ loadSettings,
17
+ recalibrate,
18
+ savePolicy,
19
+ saveSettings,
20
+ tryPolicy,
21
+ type EditorialPolicyRecord,
22
+ type EditorialSettings,
23
+ type StarterPackSummary,
24
+ } from "../lib/editorial-checks-client";
25
+
26
+ const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
27
+
28
+ type Draft = {
29
+ isNew: boolean;
30
+ record: EditorialPolicyRecord;
31
+ detectorsText: string;
32
+ violatesText: string;
33
+ compliesText: string;
34
+ };
35
+
36
+ function emptyPolicy(): EditorialPolicyRecord {
37
+ return {
38
+ id: "",
39
+ policyKey: "",
40
+ scope: "publication",
41
+ version: 1,
42
+ title: "",
43
+ guidance: "",
44
+ severity: "warn",
45
+ enabled: true,
46
+ detectors: [],
47
+ examples: null,
48
+ rewriteGuidance: null,
49
+ source: "custom",
50
+ };
51
+ }
52
+
53
+ function draftFrom(record: EditorialPolicyRecord, isNew: boolean): Draft {
54
+ return {
55
+ isNew,
56
+ record,
57
+ detectorsText: JSON.stringify(record.detectors, null, 2),
58
+ violatesText: (record.examples?.violates ?? []).join("\n"),
59
+ compliesText: (record.examples?.complies ?? []).join("\n"),
60
+ };
61
+ }
62
+
63
+ function lines(text: string): string[] {
64
+ return text.split("\n").map((line) => line.trim()).filter(Boolean);
65
+ }
66
+
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> {
69
+ const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
70
+ if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
71
+ const violates = lines(draft.violatesText);
72
+ const complies = lines(draft.compliesText);
73
+ return {
74
+ id: draft.record.policyKey || "draft",
75
+ version: draft.record.version,
76
+ title: draft.record.title || "Draft policy",
77
+ guidance: draft.record.guidance || "Draft policy.",
78
+ severity: draft.record.severity,
79
+ enabled: true,
80
+ detectors,
81
+ ...(violates.length || complies.length ? { examples: { violates, complies } } : {}),
82
+ ...(draft.record.rewriteGuidance ? { rewriteGuidance: draft.record.rewriteGuidance } : {}),
83
+ };
84
+ }
85
+
86
+ export function EditorialChecksAdminPanel() {
87
+ const [policies, setPolicies] = useState<EditorialPolicyRecord[] | null>(null);
88
+ const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
89
+ const [settingsExist, setSettingsExist] = useState(false);
90
+ const [draft, setDraft] = useState<Draft | null>(null);
91
+ const [starterPacks, setStarterPacks] = useState<StarterPackSummary[]>([]);
92
+ const [starterPack, setStarterPack] = useState<string>("");
93
+ const [message, setMessage] = useState<string | null>(null);
94
+ const [working, setWorking] = useState<string | null>(null);
95
+ const [tryText, setTryText] = useState("");
96
+ const [tryResult, setTryResult] = useState<EditorialCheckResult | null>(null);
97
+
98
+ const refresh = useCallback(async () => {
99
+ try {
100
+ const [loadedPolicies, loadedSettings, catalog] = await Promise.all([listPolicies(), loadSettings(), listStarterPacks()]);
101
+ setStarterPacks(catalog.packs);
102
+ setStarterPack((current) => current || catalog.packs[0]?.name || "");
103
+ setPolicies(loadedPolicies);
104
+ setSettings(loadedSettings.settings);
105
+ setSettingsExist(loadedSettings.exists);
106
+ } catch (error) {
107
+ setMessage(`Error: ${error instanceof Error ? error.message : "could not load editorial checks"}`);
108
+ }
109
+ }, []);
110
+
111
+ useEffect(() => {
112
+ void refresh();
113
+ }, [refresh]);
114
+
115
+ const run = async (label: string, action: () => Promise<string | void>) => {
116
+ setWorking(label);
117
+ setMessage(null);
118
+ try {
119
+ const outcome = await action();
120
+ if (outcome) setMessage(outcome);
121
+ } catch (error) {
122
+ setMessage(`Error: ${error instanceof Error ? error.message : String(error)}`);
123
+ } finally {
124
+ setWorking(null);
125
+ }
126
+ };
127
+
128
+ const patchDraft = (patch: Partial<EditorialPolicyRecord>) => setDraft((current) => (current ? { ...current, record: { ...current.record, ...patch } } : current));
129
+
130
+ const save = () =>
131
+ run("save", async () => {
132
+ if (!draft) return;
133
+ const key = draft.record.policyKey.trim();
134
+ if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
135
+ if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
136
+ const policy = humanusPolicy(draft);
137
+ const record: EditorialPolicyRecord = {
138
+ ...draft.record,
139
+ id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
140
+ policyKey: key,
141
+ version: draft.isNew ? 1 : draft.record.version + 1,
142
+ detectors: policy.detectors as unknown[],
143
+ examples: (policy.examples as EditorialPolicyRecord["examples"]) ?? null,
144
+ };
145
+ await savePolicy(record, draft.isNew);
146
+ setDraft(null);
147
+ await refresh();
148
+ return `Saved ${record.title} (version ${record.version}).`;
149
+ });
150
+
151
+ return (
152
+ <section aria-labelledby="newsroom-editorial-checks-title" data-news-desk-admin-panel="editorial-checks">
153
+ <h3 id="newsroom-editorial-checks-title">Editorial checks</h3>
154
+ <p>
155
+ Policies the article editor checks drafts against. Rules match words and patterns; judgment questions are answered by a decision model, sentence by sentence.
156
+ Findings are advice: editors can dismiss them, and publishing is never blocked.
157
+ </p>
158
+ <p aria-live="polite" role="status">
159
+ {working ? <><Spinner /> Working…</> : message}
160
+ </p>
161
+
162
+ <h4>Policies</h4>
163
+ <div className="flex flex-wrap gap-2">
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>
172
+ <Button
173
+ disabled={working !== null || !starterPack}
174
+ onClick={() => run("import", async () => {
175
+ const outcome = await importStarterPack(starterPack);
176
+ await refresh();
177
+ const label = starterPacks.find((pack) => pack.name === starterPack)?.title ?? starterPack;
178
+ return `${label}: ${outcome.created} policies added, ${outcome.skipped} already present.`;
179
+ })}
180
+ size="sm"
181
+ type="button"
182
+ variant="outline"
183
+ >
184
+ Add starter pack
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">
197
+ <Button
198
+ disabled={working !== null}
199
+ onClick={() => run("recalibrate", async () => {
200
+ const outcome = await recalibrate();
201
+ await refresh();
202
+ const notes = outcome.detectors.filter((d) => d.note).map((d) => `${d.policyId}/${d.detectorId}: ${d.note}`);
203
+ return `Recalibrated ${outcome.updated} ${outcome.updated === 1 ? "policy" : "policies"} from editors' decisions.${notes.length ? ` ${notes.join("; ")}` : ""}`;
204
+ })}
205
+ size="sm"
206
+ type="button"
207
+ variant="outline"
208
+ >
209
+ Recalibrate from editors&apos; decisions
210
+ </Button>
211
+ </div>
212
+ {policies === null ? <p>Loading…</p> : policies.length === 0 ? <p>No policies yet. Add the starter pack or write one.</p> : (
213
+ <table data-editorial-policies>
214
+ <thead>
215
+ <tr><th scope="col">Policy</th><th scope="col">Applies to</th><th scope="col">Severity</th><th scope="col">Detectors</th><th scope="col">On</th><th scope="col"><span className="sr-only">Actions</span></th></tr>
216
+ </thead>
217
+ <tbody>
218
+ {policies.map((policy) => (
219
+ <tr data-editorial-policy-row={policy.id} key={policy.id}>
220
+ <td>{policy.title}<br /><small>{policy.policyKey} · v{policy.version}</small></td>
221
+ <td>{policy.scope === "publication" ? "Whole publication" : `Desk: ${policy.scope}`}</td>
222
+ <td>{policy.severity}</td>
223
+ <td>{policy.detectors.length === 0 ? "Guidance only" : policy.detectors.length}</td>
224
+ <td>
225
+ <input
226
+ aria-label={`${policy.title} enabled`}
227
+ checked={policy.enabled}
228
+ onChange={(event) => void run("toggle", async () => {
229
+ await savePolicy({ ...policy, enabled: event.target.checked, version: policy.version + 1 }, false);
230
+ await refresh();
231
+ })}
232
+ type="checkbox"
233
+ />
234
+ </td>
235
+ <td>
236
+ <button onClick={() => { setDraft(draftFrom(policy, false)); setTryResult(null); }} type="button">Edit</button>
237
+ </td>
238
+ </tr>
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ )}
243
+
244
+ {draft ? (
245
+ <form
246
+ className="space-y-2"
247
+ data-editorial-policy-form
248
+ onSubmit={(event) => { event.preventDefault(); void save(); }}
249
+ >
250
+ <h4>{draft.isNew ? "New policy" : `Edit ${draft.record.title}`}</h4>
251
+ <label>Key <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ policyKey: event.target.value })} value={draft.record.policyKey} /></label>
252
+ <label>Title <Input onChange={(event) => patchDraft({ title: event.target.value })} value={draft.record.title} /></label>
253
+ <label>
254
+ Applies to (publication, or a desk key){" "}
255
+ <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ scope: event.target.value.trim() || "publication" })} value={draft.record.scope} />
256
+ </label>
257
+ <label>
258
+ Severity{" "}
259
+ <select onChange={(event) => patchDraft({ severity: event.target.value as EditorialPolicyRecord["severity"] })} value={draft.record.severity}>
260
+ <option value="error">Must fix</option>
261
+ <option value="warn">Should fix</option>
262
+ <option value="info">Consider</option>
263
+ </select>
264
+ </label>
265
+ <label>Guidance (what the policy asks, in plain words) <Textarea onChange={(event) => patchDraft({ guidance: event.target.value })} rows={3} value={draft.record.guidance} /></label>
266
+ <label>How to fix it (for rewrite suggestions) <Textarea onChange={(event) => patchDraft({ rewriteGuidance: event.target.value || null })} rows={2} value={draft.record.rewriteGuidance ?? ""} /></label>
267
+ <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
268
+ <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
269
+ <label>
270
+ Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
271
+ <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
272
+ </label>
273
+ <div className="flex flex-wrap gap-2">
274
+ <Button
275
+ disabled={working !== null || !draft.record.title.trim() || !draft.record.guidance.trim()}
276
+ onClick={() => run("draft", async () => {
277
+ const drafted = await draftQuestions(draft.record.title, draft.record.guidance);
278
+ const existing = JSON.parse(draft.detectorsText || "[]") as unknown[];
279
+ setDraft({ ...draft, detectorsText: JSON.stringify([...existing, ...drafted.detectors], null, 2) });
280
+ return `Added ${drafted.detectors.length} drafted judgment question(s). Review them before saving. ${drafted.notes.join(" ")}`;
281
+ })}
282
+ type="button"
283
+ variant="outline"
284
+ >
285
+ Draft judgment questions
286
+ </Button>
287
+ <Button disabled={working !== null} type="submit">Save</Button>
288
+ {!draft.isNew ? (
289
+ <Button
290
+ onClick={() => run("delete", async () => {
291
+ await deletePolicy(draft.record.id);
292
+ setDraft(null);
293
+ await refresh();
294
+ return "Policy deleted.";
295
+ })}
296
+ type="button"
297
+ variant="outline"
298
+ >
299
+ Delete
300
+ </Button>
301
+ ) : null}
302
+ <Button onClick={() => setDraft(null)} type="button" variant="ghost">Cancel</Button>
303
+ </div>
304
+
305
+ <fieldset className="space-y-2" data-editorial-try>
306
+ <legend>Try it on sample text (nothing is saved)</legend>
307
+ <Textarea onChange={(event) => setTryText(event.target.value)} rows={4} value={tryText} />
308
+ <Button
309
+ disabled={working !== null || !tryText.trim()}
310
+ onClick={() => run("try", async () => {
311
+ setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
312
+ })}
313
+ size="sm"
314
+ type="button"
315
+ variant="outline"
316
+ >
317
+ Try policy
318
+ </Button>
319
+ {tryResult ? (
320
+ tryResult.findings.length === 0 ? <p>No findings.</p> : (
321
+ <ul data-editorial-try-findings>
322
+ {tryResult.findings.map((finding) => (
323
+ <li key={finding.id}>
324
+ {finding.status === "review" ? "Unsure: " : ""}“{finding.excerpt || "whole text"}” {describeProbability(finding) ? `(${describeProbability(finding)})` : ""}
325
+ </li>
326
+ ))}
327
+ </ul>
328
+ )
329
+ ) : null}
330
+ </fieldset>
331
+ </form>
332
+ ) : null}
333
+
334
+ <h4>Settings</h4>
335
+ <form
336
+ className="space-y-2"
337
+ data-editorial-settings
338
+ onSubmit={(event) => {
339
+ event.preventDefault();
340
+ void run("settings", async () => {
341
+ await saveSettings(settings, settingsExist);
342
+ setSettingsExist(true);
343
+ return "Settings saved.";
344
+ });
345
+ }}
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>
348
+ <label><input checked={settings.lanes.rules} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, rules: event.target.checked } })} type="checkbox" /> Rule checks</label>
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>
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>
351
+ <label><input checked={settings.publishWarning} onChange={(event) => setSettings({ ...settings, publishWarning: event.target.checked })} type="checkbox" /> Warn before publishing with open findings</label>
352
+ <label>Decision model <Input onChange={(event) => setSettings({ ...settings, classifierModel: event.target.value })} value={settings.classifierModel} /></label>
353
+ <label>Rewrite model <Input onChange={(event) => setSettings({ ...settings, rewriteModel: event.target.value })} value={settings.rewriteModel} /></label>
354
+ <label>Time limit for judgment checks (seconds, under 28) <Input max={26} min={1} onChange={(event) => setSettings({ ...settings, deadlineSeconds: Number(event.target.value) })} type="number" value={settings.deadlineSeconds} /></label>
355
+ <label>Input-token budget per check <Input min={1} onChange={(event) => setSettings({ ...settings, maxInputTokens: Number(event.target.value) })} type="number" value={settings.maxInputTokens} /></label>
356
+ <label>Past decisions shown to each judgment question <Input max={32} min={0} onChange={(event) => setSettings({ ...settings, maxExamplesPerQuestion: Number(event.target.value) })} type="number" value={settings.maxExamplesPerQuestion} /></label>
357
+ <label>House guidance for rewrites <Textarea onChange={(event) => setSettings({ ...settings, guidance: event.target.value })} rows={3} value={settings.guidance} /></label>
358
+ <Button disabled={working !== null} type="submit">Save settings</Button>
359
+ </form>
360
+ </section>
361
+ );
362
+ }