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

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 (66) hide show
  1. package/amplify/data/resource.js +7 -0
  2. package/amplify/data/resource.ts +8 -0
  3. package/amplify/data/schema.js +10 -0
  4. package/amplify/data/schema.ts +11 -0
  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 +7 -0
  8. package/amplify/functions/editorial-checks/handler.py +14 -0
  9. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  10. package/amplify/functions/editorial-checks/resource.js +43 -0
  11. package/amplify/functions/editorial-checks/resource.ts +60 -0
  12. package/amplify/functions/editorial-checks/schema.js +92 -0
  13. package/amplify/functions/editorial-checks/schema.ts +94 -0
  14. package/amplify/functions/editorial-checks/wire.js +49 -0
  15. package/amplify/functions/editorial-checks/wire.ts +57 -0
  16. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  17. package/amplify/site-backend.js +9 -0
  18. package/amplify/site-backend.ts +15 -0
  19. package/components/editorial-checks-admin-panel.tsx +347 -0
  20. package/components/editorial-finding-card.tsx +162 -0
  21. package/components/editorial-findings-panel.tsx +91 -0
  22. package/components/editorial-preview-popover.tsx +103 -0
  23. package/components/markus-body-editor.tsx +213 -0
  24. package/components/markus-ir-preview.tsx +147 -6
  25. package/components/newsroom-article-editor.tsx +136 -11
  26. package/components/newsroom-board-view.tsx +6 -7
  27. package/components/newsroom-ops-shell.tsx +7 -3
  28. package/components/newsroom-organizer.css +57 -61
  29. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  30. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  31. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  32. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  33. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  34. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  35. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  36. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  37. package/components/newsroom-theme.css +39 -0
  38. package/components/newsroom-theme.tsx +33 -0
  39. package/components/reference-cyclotron-priming.tsx +12 -13
  40. package/components/reference-cyclotron-status.tsx +4 -4
  41. package/components/reference-cyclotron-suggestion.tsx +6 -6
  42. package/components/reference-live-agreement.tsx +10 -10
  43. package/components/reference-priming.tsx +1 -1
  44. package/components/reference-relevance-review.tsx +1 -1
  45. package/components/reference-section-suggestion.tsx +1 -1
  46. package/components/topic-steering-workspace.tsx +13 -2
  47. package/components/use-editorial-checks.ts +133 -0
  48. package/lib/cyclotron-suggestions.ts +9 -9
  49. package/lib/editorial-checks-backend.ts +16 -0
  50. package/lib/editorial-checks-client.ts +194 -0
  51. package/lib/editorial-checks-demo.ts +147 -0
  52. package/lib/editorial-checks-settings.ts +27 -0
  53. package/lib/editorial-checks.ts +171 -0
  54. package/lib/editorial-preview.ts +228 -0
  55. package/lib/newsroom-board.ts +27 -2
  56. package/lib/relevance-status.ts +4 -4
  57. package/lib/site-brand.ts +17 -0
  58. package/package.json +34 -2
  59. package/routes.manifest.json +1 -1
  60. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  61. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  62. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  63. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  64. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  65. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  66. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,49 @@
1
+ import { secret } from "@aws-amplify/backend";
2
+ import { Stack } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ /** The model operations the function performs with its own role. The two context reads are its least-privilege access to private policy. */
5
+ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
6
+ "Query/fields/listKnowledgeCorporaByRoleAndName",
7
+ "Query/fields/getEditorialContext",
8
+ "Query/fields/listEditorialPolicies",
9
+ "Query/fields/getEditorialCheckSettings",
10
+ "Query/fields/listEditorialFeedbacks",
11
+ "Mutation/fields/createEditorialPolicy",
12
+ "Mutation/fields/updateEditorialPolicy",
13
+ ];
14
+ export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
15
+ /**
16
+ * SSM parameters holding the provider keys. A Python function reads its secret()
17
+ * values itself (Amplify only resolves them for Node functions), so it needs read
18
+ * access to the app's branch and shared secret paths, and to the sandbox paths.
19
+ */
20
+ export function editorialChecksSecretParameters(region, account, appId) {
21
+ const arn = (path) => `arn:aws:ssm:${region}:${account}:parameter/amplify/${path}`;
22
+ return EDITORIAL_CHECKS_SECRETS.flatMap((name) => [
23
+ arn(`papyrus/*/${name}`),
24
+ arn(`shared/papyrus/${name}`),
25
+ ...(appId ? [arn(`${appId}/*/${name}`), arn(`shared/${appId}/${name}`)] : []),
26
+ ]);
27
+ }
28
+ /**
29
+ * Environment, secrets and least-privilege IAM for the editorial-checks function. Every site gets
30
+ * these; whether checks run is a stored setting (EditorialCheckSettings.enabled), not a deploy flag.
31
+ */
32
+ export function wireEditorialChecks(backend) {
33
+ const lambda = backend.editorialChecks.resources.lambda;
34
+ const stack = Stack.of(lambda);
35
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
36
+ for (const name of EDITORIAL_CHECKS_SECRETS)
37
+ backend.editorialChecks.addEnvironment(name, secret(name));
38
+ lambda.addToRolePolicy(new PolicyStatement({
39
+ effect: Effect.ALLOW,
40
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
41
+ resources: editorialChecksSecretParameters(stack.region, stack.account, (process.env.AWS_APP_ID ?? "").trim()),
42
+ }));
43
+ const apiId = backend.data.resources.graphqlApi.apiId;
44
+ lambda.addToRolePolicy(new PolicyStatement({
45
+ effect: Effect.ALLOW,
46
+ actions: ["appsync:GraphQL"],
47
+ resources: EDITORIAL_CHECKS_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
48
+ }));
49
+ }
@@ -0,0 +1,57 @@
1
+ import { secret } from "@aws-amplify/backend";
2
+ import { Stack } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
5
+
6
+ /** The model operations the function performs with its own role. The two context reads are its least-privilege access to private policy. */
7
+ export const EDITORIAL_CHECKS_FUNCTION_FIELDS = [
8
+ "Query/fields/listKnowledgeCorporaByRoleAndName",
9
+ "Query/fields/getEditorialContext",
10
+ "Query/fields/listEditorialPolicies",
11
+ "Query/fields/getEditorialCheckSettings",
12
+ "Query/fields/listEditorialFeedbacks",
13
+ "Mutation/fields/createEditorialPolicy",
14
+ "Mutation/fields/updateEditorialPolicy",
15
+ ];
16
+
17
+ export const EDITORIAL_CHECKS_SECRETS = ["TYPESAFE_API_KEY", "OPENAI_API_KEY"];
18
+
19
+ /**
20
+ * SSM parameters holding the provider keys. A Python function reads its secret()
21
+ * values itself (Amplify only resolves them for Node functions), so it needs read
22
+ * access to the app's branch and shared secret paths, and to the sandbox paths.
23
+ */
24
+ export function editorialChecksSecretParameters(region: string, account: string, appId: string): string[] {
25
+ const arn = (path: string) => `arn:aws:ssm:${region}:${account}:parameter/amplify/${path}`;
26
+ return EDITORIAL_CHECKS_SECRETS.flatMap((name) => [
27
+ arn(`papyrus/*/${name}`),
28
+ arn(`shared/papyrus/${name}`),
29
+ ...(appId ? [arn(`${appId}/*/${name}`), arn(`shared/${appId}/${name}`)] : []),
30
+ ]);
31
+ }
32
+
33
+ /**
34
+ * Environment, secrets and least-privilege IAM for the editorial-checks function. Every site gets
35
+ * these; whether checks run is a stored setting (EditorialCheckSettings.enabled), not a deploy flag.
36
+ */
37
+ export function wireEditorialChecks(backend: any): void {
38
+ const lambda = backend.editorialChecks.resources.lambda as LambdaFunction;
39
+ const stack = Stack.of(lambda);
40
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
41
+ for (const name of EDITORIAL_CHECKS_SECRETS) backend.editorialChecks.addEnvironment(name, secret(name));
42
+ lambda.addToRolePolicy(
43
+ new PolicyStatement({
44
+ effect: Effect.ALLOW,
45
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
46
+ resources: editorialChecksSecretParameters(stack.region, stack.account, (process.env.AWS_APP_ID ?? "").trim()),
47
+ }),
48
+ );
49
+ const apiId = backend.data.resources.graphqlApi.apiId as string;
50
+ lambda.addToRolePolicy(
51
+ new PolicyStatement({
52
+ effect: Effect.ALLOW,
53
+ actions: ["appsync:GraphQL"],
54
+ resources: EDITORIAL_CHECKS_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
55
+ }),
56
+ );
57
+ }
@@ -49,6 +49,7 @@ const ALLOWED_ROLES = new Set([
49
49
  "graph_export",
50
50
  "raw_payload",
51
51
  "priming_outcome",
52
+ "priming_attempts",
52
53
  ]);
53
54
  const ALLOWED_MEDIA_TYPES = new Set([
54
55
  "application/json",
@@ -20,9 +20,12 @@ 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 { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
23
25
  import { resolvePlugins } from "./plugins/registry.js";
24
26
  import { wirePlugins } from "./plugins/wire.js";
25
27
  import { assignmentAction } from "./functions/assignment-action/resource.js";
28
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access.js";
26
29
  import { categoryAction } from "./functions/category-action/resource.js";
27
30
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
28
31
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource.js";
@@ -88,6 +91,7 @@ export function defineSiteBackend(papyrusSite) {
88
91
  auth: defineSiteAuth(authConfig),
89
92
  categoryAction,
90
93
  contentActions,
94
+ editorialChecks,
91
95
  data: defineSiteData(pluginDefinitions),
92
96
  knowledgeQuery,
93
97
  manageUserRole,
@@ -304,10 +308,14 @@ export function defineSiteBackend(papyrusSite) {
304
308
  }
305
309
  grantCorporaRead(backend.knowledgeQuery.resources.lambda);
306
310
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda);
311
+ scopeAssignmentDataAccess(backend.assignmentAction.resources.lambda, backend.data.resources.graphqlApi.arn);
307
312
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda);
308
313
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
309
314
  const contentActionsLambda = backend.contentActions.resources.lambda;
310
315
  grantNewsroomReadWriteDelete(contentActionsLambda);
316
+ // AppSync validates the token; pending intake additionally binds its actor
317
+ // and publication to this deployment's pool and existing API endpoint.
318
+ contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
311
319
  contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
312
320
  contentActionsLambda.addToRolePolicy(new PolicyStatement({
313
321
  actions: ["appsync:GraphQL"],
@@ -584,6 +592,7 @@ export function defineSiteBackend(papyrusSite) {
584
592
  : null,
585
593
  },
586
594
  });
595
+ wireEditorialChecks(backend);
587
596
  wirePlugins(backend, plugins, contentActionsLambda);
588
597
  return backend;
589
598
  }
@@ -27,9 +27,12 @@ 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 { wireEditorialChecks } from "./functions/editorial-checks/wire";
30
32
  import { resolvePlugins } from "./plugins/registry";
31
33
  import { wirePlugins } from "./plugins/wire";
32
34
  import { assignmentAction } from "./functions/assignment-action/resource";
35
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access";
33
36
  import { categoryAction } from "./functions/category-action/resource";
34
37
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
35
38
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource";
@@ -109,6 +112,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
109
112
  auth: defineSiteAuth(authConfig),
110
113
  categoryAction,
111
114
  contentActions,
115
+ editorialChecks,
112
116
  data: defineSiteData(pluginDefinitions),
113
117
  knowledgeQuery,
114
118
  manageUserRole,
@@ -387,10 +391,20 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
387
391
  }
388
392
  grantCorporaRead(backend.knowledgeQuery.resources.lambda as LambdaFunction);
389
393
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda as LambdaFunction);
394
+ scopeAssignmentDataAccess(
395
+ backend.assignmentAction.resources.lambda as LambdaFunction,
396
+ backend.data.resources.graphqlApi.arn,
397
+ );
390
398
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda as LambdaFunction);
391
399
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
392
400
  const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
393
401
  grantNewsroomReadWriteDelete(contentActionsLambda);
402
+ // AppSync validates the token; pending intake additionally binds its actor
403
+ // and publication to this deployment's pool and existing API endpoint.
404
+ contentActionsLambda.addEnvironment(
405
+ "PAPYRUS_USER_POOL_ISSUER",
406
+ `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`,
407
+ );
394
408
  contentActionsLambda.addEnvironment(
395
409
  "PAPYRUS_GRAPHQL_ENDPOINT",
396
410
  backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
@@ -721,6 +735,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
721
735
  : null,
722
736
  },
723
737
  });
738
+ wireEditorialChecks(backend);
724
739
  wirePlugins(backend, plugins, contentActionsLambda);
725
740
 
726
741
  return backend;
@@ -0,0 +1,347 @@
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
+ loadSettings,
16
+ recalibrate,
17
+ savePolicy,
18
+ saveSettings,
19
+ tryPolicy,
20
+ type EditorialPolicyRecord,
21
+ type EditorialSettings,
22
+ } from "../lib/editorial-checks-client";
23
+
24
+ const STARTER_PACKS = [
25
+ { name: "ai-writing-tells", label: "AI-writing tells" },
26
+ { name: "plain-language", label: "Federal Plain Language Guidelines" },
27
+ { name: "asd-ste100", label: "ASD-STE100 Simplified Technical English" },
28
+ ] as const;
29
+ const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
30
+
31
+ type Draft = {
32
+ isNew: boolean;
33
+ record: EditorialPolicyRecord;
34
+ detectorsText: string;
35
+ violatesText: string;
36
+ compliesText: string;
37
+ };
38
+
39
+ function emptyPolicy(): EditorialPolicyRecord {
40
+ return {
41
+ id: "",
42
+ policyKey: "",
43
+ scope: "publication",
44
+ version: 1,
45
+ title: "",
46
+ guidance: "",
47
+ severity: "warn",
48
+ enabled: true,
49
+ detectors: [],
50
+ examples: null,
51
+ rewriteGuidance: null,
52
+ source: "custom",
53
+ };
54
+ }
55
+
56
+ function draftFrom(record: EditorialPolicyRecord, isNew: boolean): Draft {
57
+ return {
58
+ isNew,
59
+ record,
60
+ detectorsText: JSON.stringify(record.detectors, null, 2),
61
+ violatesText: (record.examples?.violates ?? []).join("\n"),
62
+ compliesText: (record.examples?.complies ?? []).join("\n"),
63
+ };
64
+ }
65
+
66
+ function lines(text: string): string[] {
67
+ return text.split("\n").map((line) => line.trim()).filter(Boolean);
68
+ }
69
+
70
+ /** The policy as Limatus sees it (its key is the policy id). Throws on invalid detector JSON. */
71
+ function limatusPolicy(draft: Draft): Record<string, unknown> {
72
+ const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
73
+ if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
74
+ const violates = lines(draft.violatesText);
75
+ const complies = lines(draft.compliesText);
76
+ return {
77
+ id: draft.record.policyKey || "draft",
78
+ version: draft.record.version,
79
+ title: draft.record.title || "Draft policy",
80
+ guidance: draft.record.guidance || "Draft policy.",
81
+ severity: draft.record.severity,
82
+ enabled: true,
83
+ detectors,
84
+ ...(violates.length || complies.length ? { examples: { violates, complies } } : {}),
85
+ ...(draft.record.rewriteGuidance ? { rewriteGuidance: draft.record.rewriteGuidance } : {}),
86
+ };
87
+ }
88
+
89
+ export function EditorialChecksAdminPanel() {
90
+ const [policies, setPolicies] = useState<EditorialPolicyRecord[] | null>(null);
91
+ const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
92
+ const [settingsExist, setSettingsExist] = useState(false);
93
+ const [draft, setDraft] = useState<Draft | null>(null);
94
+ const [starterPack, setStarterPack] = useState<string>(STARTER_PACKS[0].name);
95
+ const [message, setMessage] = useState<string | null>(null);
96
+ const [working, setWorking] = useState<string | null>(null);
97
+ const [tryText, setTryText] = useState("");
98
+ const [tryResult, setTryResult] = useState<EditorialCheckResult | null>(null);
99
+
100
+ const refresh = useCallback(async () => {
101
+ try {
102
+ const [loadedPolicies, loadedSettings] = await Promise.all([listPolicies(), loadSettings()]);
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 = limatusPolicy(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
+ {STARTER_PACKS.map((pack) => <option key={pack.name} value={pack.name}>{pack.label}</option>)}
167
+ </select>
168
+ <Button
169
+ disabled={working !== null}
170
+ onClick={() => run("import", async () => {
171
+ const outcome = await importStarterPack(starterPack);
172
+ await refresh();
173
+ const label = STARTER_PACKS.find((pack) => pack.name === starterPack)?.label ?? starterPack;
174
+ return `${label}: ${outcome.created} policies added, ${outcome.skipped} already present.`;
175
+ })}
176
+ size="sm"
177
+ type="button"
178
+ variant="outline"
179
+ >
180
+ Add starter pack
181
+ </Button>
182
+ <Button
183
+ disabled={working !== null}
184
+ onClick={() => run("recalibrate", async () => {
185
+ const outcome = await recalibrate();
186
+ await refresh();
187
+ const notes = outcome.detectors.filter((d) => d.note).map((d) => `${d.policyId}/${d.detectorId}: ${d.note}`);
188
+ return `Recalibrated ${outcome.updated} ${outcome.updated === 1 ? "policy" : "policies"} from editors' decisions.${notes.length ? ` ${notes.join("; ")}` : ""}`;
189
+ })}
190
+ size="sm"
191
+ type="button"
192
+ variant="outline"
193
+ >
194
+ Recalibrate from editors&apos; decisions
195
+ </Button>
196
+ </div>
197
+ {policies === null ? <p>Loading…</p> : policies.length === 0 ? <p>No policies yet. Add the starter pack or write one.</p> : (
198
+ <table data-editorial-policies>
199
+ <thead>
200
+ <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>
201
+ </thead>
202
+ <tbody>
203
+ {policies.map((policy) => (
204
+ <tr data-editorial-policy-row={policy.id} key={policy.id}>
205
+ <td>{policy.title}<br /><small>{policy.policyKey} · v{policy.version}</small></td>
206
+ <td>{policy.scope === "publication" ? "Whole publication" : `Desk: ${policy.scope}`}</td>
207
+ <td>{policy.severity}</td>
208
+ <td>{policy.detectors.length === 0 ? "Guidance only" : policy.detectors.length}</td>
209
+ <td>
210
+ <input
211
+ aria-label={`${policy.title} enabled`}
212
+ checked={policy.enabled}
213
+ onChange={(event) => void run("toggle", async () => {
214
+ await savePolicy({ ...policy, enabled: event.target.checked, version: policy.version + 1 }, false);
215
+ await refresh();
216
+ })}
217
+ type="checkbox"
218
+ />
219
+ </td>
220
+ <td>
221
+ <button onClick={() => { setDraft(draftFrom(policy, false)); setTryResult(null); }} type="button">Edit</button>
222
+ </td>
223
+ </tr>
224
+ ))}
225
+ </tbody>
226
+ </table>
227
+ )}
228
+
229
+ {draft ? (
230
+ <form
231
+ className="space-y-2"
232
+ data-editorial-policy-form
233
+ onSubmit={(event) => { event.preventDefault(); void save(); }}
234
+ >
235
+ <h4>{draft.isNew ? "New policy" : `Edit ${draft.record.title}`}</h4>
236
+ <label>Key <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ policyKey: event.target.value })} value={draft.record.policyKey} /></label>
237
+ <label>Title <Input onChange={(event) => patchDraft({ title: event.target.value })} value={draft.record.title} /></label>
238
+ <label>
239
+ Applies to (publication, or a desk key){" "}
240
+ <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ scope: event.target.value.trim() || "publication" })} value={draft.record.scope} />
241
+ </label>
242
+ <label>
243
+ Severity{" "}
244
+ <select onChange={(event) => patchDraft({ severity: event.target.value as EditorialPolicyRecord["severity"] })} value={draft.record.severity}>
245
+ <option value="error">Must fix</option>
246
+ <option value="warn">Should fix</option>
247
+ <option value="info">Consider</option>
248
+ </select>
249
+ </label>
250
+ <label>Guidance (what the policy asks, in plain words) <Textarea onChange={(event) => patchDraft({ guidance: event.target.value })} rows={3} value={draft.record.guidance} /></label>
251
+ <label>How to fix it (for rewrite suggestions) <Textarea onChange={(event) => patchDraft({ rewriteGuidance: event.target.value || null })} rows={2} value={draft.record.rewriteGuidance ?? ""} /></label>
252
+ <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
253
+ <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
254
+ <label>
255
+ Detectors (JSON; see <code>limatus schema policy-pack-v1</code>)
256
+ <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
257
+ </label>
258
+ <div className="flex flex-wrap gap-2">
259
+ <Button
260
+ disabled={working !== null || !draft.record.title.trim() || !draft.record.guidance.trim()}
261
+ onClick={() => run("draft", async () => {
262
+ const drafted = await draftQuestions(draft.record.title, draft.record.guidance);
263
+ const existing = JSON.parse(draft.detectorsText || "[]") as unknown[];
264
+ setDraft({ ...draft, detectorsText: JSON.stringify([...existing, ...drafted.detectors], null, 2) });
265
+ return `Added ${drafted.detectors.length} drafted judgment question(s). Review them before saving. ${drafted.notes.join(" ")}`;
266
+ })}
267
+ type="button"
268
+ variant="outline"
269
+ >
270
+ Draft judgment questions
271
+ </Button>
272
+ <Button disabled={working !== null} type="submit">Save</Button>
273
+ {!draft.isNew ? (
274
+ <Button
275
+ onClick={() => run("delete", async () => {
276
+ await deletePolicy(draft.record.id);
277
+ setDraft(null);
278
+ await refresh();
279
+ return "Policy deleted.";
280
+ })}
281
+ type="button"
282
+ variant="outline"
283
+ >
284
+ Delete
285
+ </Button>
286
+ ) : null}
287
+ <Button onClick={() => setDraft(null)} type="button" variant="ghost">Cancel</Button>
288
+ </div>
289
+
290
+ <fieldset className="space-y-2" data-editorial-try>
291
+ <legend>Try it on sample text (nothing is saved)</legend>
292
+ <Textarea onChange={(event) => setTryText(event.target.value)} rows={4} value={tryText} />
293
+ <Button
294
+ disabled={working !== null || !tryText.trim()}
295
+ onClick={() => run("try", async () => {
296
+ setTryResult(await tryPolicy(limatusPolicy(draft), tryText));
297
+ })}
298
+ size="sm"
299
+ type="button"
300
+ variant="outline"
301
+ >
302
+ Try policy
303
+ </Button>
304
+ {tryResult ? (
305
+ tryResult.findings.length === 0 ? <p>No findings.</p> : (
306
+ <ul data-editorial-try-findings>
307
+ {tryResult.findings.map((finding) => (
308
+ <li key={finding.id}>
309
+ {finding.status === "review" ? "Unsure: " : ""}“{finding.excerpt || "whole text"}” {describeProbability(finding) ? `(${describeProbability(finding)})` : ""}
310
+ </li>
311
+ ))}
312
+ </ul>
313
+ )
314
+ ) : null}
315
+ </fieldset>
316
+ </form>
317
+ ) : null}
318
+
319
+ <h4>Settings</h4>
320
+ <form
321
+ className="space-y-2"
322
+ data-editorial-settings
323
+ onSubmit={(event) => {
324
+ event.preventDefault();
325
+ void run("settings", async () => {
326
+ await saveSettings(settings, settingsExist);
327
+ setSettingsExist(true);
328
+ return "Settings saved.";
329
+ });
330
+ }}
331
+ >
332
+ <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>
333
+ <label><input checked={settings.lanes.rules} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, rules: event.target.checked } })} type="checkbox" /> Rule checks</label>
334
+ <label><input checked={settings.lanes.classifier} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, classifier: event.target.checked } })} type="checkbox" /> Judgment checks (decision model)</label>
335
+ <label><input checked={settings.checkWhileTyping} onChange={(event) => setSettings({ ...settings, checkWhileTyping: event.target.checked })} type="checkbox" /> Check while editors type (after a short pause)</label>
336
+ <label><input checked={settings.publishWarning} onChange={(event) => setSettings({ ...settings, publishWarning: event.target.checked })} type="checkbox" /> Warn before publishing with open findings</label>
337
+ <label>Decision model <Input onChange={(event) => setSettings({ ...settings, classifierModel: event.target.value })} value={settings.classifierModel} /></label>
338
+ <label>Rewrite model <Input onChange={(event) => setSettings({ ...settings, rewriteModel: event.target.value })} value={settings.rewriteModel} /></label>
339
+ <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>
340
+ <label>Input-token budget per check <Input min={1} onChange={(event) => setSettings({ ...settings, maxInputTokens: Number(event.target.value) })} type="number" value={settings.maxInputTokens} /></label>
341
+ <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>
342
+ <label>House guidance for rewrites <Textarea onChange={(event) => setSettings({ ...settings, guidance: event.target.value })} rows={3} value={settings.guidance} /></label>
343
+ <Button disabled={working !== null} type="submit">Save settings</Button>
344
+ </form>
345
+ </section>
346
+ );
347
+ }