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

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 (68) hide show
  1. package/amplify/data/resource.js +7 -0
  2. package/amplify/data/resource.ts +8 -0
  3. package/amplify/data/schema.js +8 -0
  4. package/amplify/data/schema.ts +9 -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/model-attachment-upload/handler.ts +1 -0
  9. package/amplify/plugins/editorial-checks/function/handler.py +14 -0
  10. package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
  11. package/amplify/plugins/editorial-checks/function/resource.js +43 -0
  12. package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
  13. package/amplify/plugins/editorial-checks/papyrus.plugin.js +71 -0
  14. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +87 -0
  15. package/amplify/plugins/editorial-checks/schema.js +94 -0
  16. package/amplify/plugins/editorial-checks/schema.ts +96 -0
  17. package/amplify/plugins/registry.js +2 -0
  18. package/amplify/plugins/registry.ts +2 -0
  19. package/amplify/site-backend.js +5 -0
  20. package/amplify/site-backend.ts +11 -0
  21. package/components/editorial-checks-admin-panel.tsx +337 -0
  22. package/components/editorial-finding-card.tsx +162 -0
  23. package/components/editorial-findings-panel.tsx +91 -0
  24. package/components/editorial-preview-popover.tsx +103 -0
  25. package/components/markus-body-editor.tsx +213 -0
  26. package/components/markus-ir-preview.tsx +147 -6
  27. package/components/newsroom-article-editor.tsx +138 -11
  28. package/components/newsroom-board-view.tsx +6 -7
  29. package/components/newsroom-ops-shell.tsx +7 -3
  30. package/components/newsroom-organizer.css +57 -61
  31. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  32. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  33. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  34. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  35. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  36. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  37. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  38. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  39. package/components/newsroom-theme.css +39 -0
  40. package/components/newsroom-theme.tsx +33 -0
  41. package/components/reference-cyclotron-priming.tsx +12 -13
  42. package/components/reference-cyclotron-status.tsx +4 -4
  43. package/components/reference-cyclotron-suggestion.tsx +6 -6
  44. package/components/reference-live-agreement.tsx +10 -10
  45. package/components/reference-priming.tsx +1 -1
  46. package/components/reference-relevance-review.tsx +1 -1
  47. package/components/reference-section-suggestion.tsx +1 -1
  48. package/components/topic-steering-workspace.tsx +19 -1
  49. package/components/use-editorial-checks.ts +131 -0
  50. package/lib/cyclotron-suggestions.ts +9 -9
  51. package/lib/editorial-checks-backend.ts +16 -0
  52. package/lib/editorial-checks-client.ts +194 -0
  53. package/lib/editorial-checks-demo.ts +147 -0
  54. package/lib/editorial-checks-settings.ts +24 -0
  55. package/lib/editorial-checks.ts +178 -0
  56. package/lib/editorial-preview.ts +228 -0
  57. package/lib/newsroom-board.ts +27 -2
  58. package/lib/relevance-status.ts +4 -4
  59. package/lib/site-brand.ts +17 -0
  60. package/package.json +34 -2
  61. package/routes.manifest.json +1 -1
  62. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  63. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  64. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  65. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  66. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  67. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  68. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,94 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { editorialChecks } from "./function/resource.js";
3
+ const editors = ["editor", "admin"];
4
+ /**
5
+ * Policies, settings and editors' decisions live in Papyrus; Limatus checks
6
+ * content against them. Editors read policies and record decisions; admins
7
+ * manage policies and settings. Only the function (IAM) calls Limatus.
8
+ */
9
+ export function editorialChecksSchemaDefinitions() {
10
+ return {
11
+ EditorialPolicy: a
12
+ .model({
13
+ id: a.id().required(),
14
+ policyKey: a.string().required(),
15
+ scope: a.string().required(),
16
+ version: a.integer().required(),
17
+ title: a.string().required(),
18
+ guidance: a.string().required(),
19
+ severity: a.string().required(),
20
+ enabled: a.boolean().required(),
21
+ detectors: a.json().required(),
22
+ examples: a.json(),
23
+ rewriteGuidance: a.string(),
24
+ source: a.string().required(),
25
+ })
26
+ .disableOperations(["subscriptions"])
27
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
28
+ EditorialCheckSettings: a
29
+ .model({
30
+ id: a.id().required(),
31
+ settings: a.json().required(),
32
+ })
33
+ .disableOperations(["subscriptions"])
34
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
35
+ EditorialFeedback: a
36
+ .model({
37
+ id: a.id().required(),
38
+ itemId: a.string().required(),
39
+ findingId: a.string().required(),
40
+ policyId: a.string().required(),
41
+ detectorId: a.string().required(),
42
+ lane: a.string().required(),
43
+ probability: a.float(),
44
+ decision: a.string().required(),
45
+ reason: a.string(),
46
+ text: a.string(),
47
+ })
48
+ .disableOperations(["subscriptions"])
49
+ .secondaryIndexes((index) => [index("itemId").queryField("listEditorialFeedbackByItem")])
50
+ .authorization((allow) => [allow.group("editor").to(["create", "read"]), allow.group("admin")]),
51
+ checkEditorial: a
52
+ .query()
53
+ .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
54
+ .returns(a.json())
55
+ .authorization((allow) => [allow.groups(editors)])
56
+ .handler(a.handler.function(editorialChecks)),
57
+ suggestEditorialRewrite: a
58
+ .query()
59
+ .arguments({
60
+ body: a.string().required(),
61
+ format: a.string(),
62
+ section: a.string(),
63
+ finding: a.json(),
64
+ selection: a.json(),
65
+ note: a.string(),
66
+ })
67
+ .returns(a.json())
68
+ .authorization((allow) => [allow.groups(editors)])
69
+ .handler(a.handler.function(editorialChecks)),
70
+ draftEditorialQuestions: a
71
+ .query()
72
+ .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
73
+ .returns(a.json())
74
+ .authorization((allow) => [allow.group("admin")])
75
+ .handler(a.handler.function(editorialChecks)),
76
+ tryEditorialPolicy: a
77
+ .query()
78
+ .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
79
+ .returns(a.json())
80
+ .authorization((allow) => [allow.group("admin")])
81
+ .handler(a.handler.function(editorialChecks)),
82
+ importEditorialStarterPack: a
83
+ .mutation()
84
+ .arguments({ name: a.string().required() })
85
+ .returns(a.json())
86
+ .authorization((allow) => [allow.group("admin")])
87
+ .handler(a.handler.function(editorialChecks)),
88
+ recalibrateEditorialPolicies: a
89
+ .mutation()
90
+ .returns(a.json())
91
+ .authorization((allow) => [allow.group("admin")])
92
+ .handler(a.handler.function(editorialChecks)),
93
+ };
94
+ }
@@ -0,0 +1,96 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { editorialChecks } from "./function/resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Policies, settings and editors' decisions live in Papyrus; Limatus checks
8
+ * content against them. Editors read policies and record decisions; admins
9
+ * manage policies and settings. Only the function (IAM) calls Limatus.
10
+ */
11
+ export function editorialChecksSchemaDefinitions() {
12
+ return {
13
+ EditorialPolicy: a
14
+ .model({
15
+ id: a.id().required(),
16
+ policyKey: a.string().required(),
17
+ scope: a.string().required(),
18
+ version: a.integer().required(),
19
+ title: a.string().required(),
20
+ guidance: a.string().required(),
21
+ severity: a.string().required(),
22
+ enabled: a.boolean().required(),
23
+ detectors: a.json().required(),
24
+ examples: a.json(),
25
+ rewriteGuidance: a.string(),
26
+ source: a.string().required(),
27
+ })
28
+ .disableOperations(["subscriptions"])
29
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
30
+ EditorialCheckSettings: a
31
+ .model({
32
+ id: a.id().required(),
33
+ settings: a.json().required(),
34
+ })
35
+ .disableOperations(["subscriptions"])
36
+ .authorization((allow) => [allow.group("editor").to(["read"]), allow.group("admin")]),
37
+ EditorialFeedback: a
38
+ .model({
39
+ id: a.id().required(),
40
+ itemId: a.string().required(),
41
+ findingId: a.string().required(),
42
+ policyId: a.string().required(),
43
+ detectorId: a.string().required(),
44
+ lane: a.string().required(),
45
+ probability: a.float(),
46
+ decision: a.string().required(),
47
+ reason: a.string(),
48
+ text: a.string(),
49
+ })
50
+ .disableOperations(["subscriptions"])
51
+ .secondaryIndexes((index) => [index("itemId").queryField("listEditorialFeedbackByItem")])
52
+ .authorization((allow) => [allow.group("editor").to(["create", "read"]), allow.group("admin")]),
53
+ checkEditorial: a
54
+ .query()
55
+ .arguments({ body: a.string().required(), format: a.string(), section: a.string() })
56
+ .returns(a.json())
57
+ .authorization((allow) => [allow.groups(editors)])
58
+ .handler(a.handler.function(editorialChecks)),
59
+ suggestEditorialRewrite: a
60
+ .query()
61
+ .arguments({
62
+ body: a.string().required(),
63
+ format: a.string(),
64
+ section: a.string(),
65
+ finding: a.json(),
66
+ selection: a.json(),
67
+ note: a.string(),
68
+ })
69
+ .returns(a.json())
70
+ .authorization((allow) => [allow.groups(editors)])
71
+ .handler(a.handler.function(editorialChecks)),
72
+ draftEditorialQuestions: a
73
+ .query()
74
+ .arguments({ title: a.string().required(), guidance: a.string().required(), examples: a.json() })
75
+ .returns(a.json())
76
+ .authorization((allow) => [allow.group("admin")])
77
+ .handler(a.handler.function(editorialChecks)),
78
+ tryEditorialPolicy: a
79
+ .query()
80
+ .arguments({ policy: a.json().required(), text: a.string().required(), format: a.string() })
81
+ .returns(a.json())
82
+ .authorization((allow) => [allow.group("admin")])
83
+ .handler(a.handler.function(editorialChecks)),
84
+ importEditorialStarterPack: a
85
+ .mutation()
86
+ .arguments({ name: a.string().required() })
87
+ .returns(a.json())
88
+ .authorization((allow) => [allow.group("admin")])
89
+ .handler(a.handler.function(editorialChecks)),
90
+ recalibrateEditorialPolicies: a
91
+ .mutation()
92
+ .returns(a.json())
93
+ .authorization((allow) => [allow.group("admin")])
94
+ .handler(a.handler.function(editorialChecks)),
95
+ };
96
+ }
@@ -1,6 +1,8 @@
1
+ import { editorialChecksPlugin } from "./editorial-checks/papyrus.plugin.js";
1
2
  import { signupPlugin } from "./signup/papyrus.plugin.js";
2
3
  /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
3
4
  export const KNOWN_PLUGINS = {
5
+ "editorial-checks": editorialChecksPlugin,
4
6
  signup: signupPlugin,
5
7
  };
6
8
  export function resolvePlugins(requests) {
@@ -1,8 +1,10 @@
1
1
  import type { PapyrusPlugin, PluginRequest } from "./types";
2
+ import { editorialChecksPlugin } from "./editorial-checks/papyrus.plugin";
2
3
  import { signupPlugin } from "./signup/papyrus.plugin";
3
4
 
4
5
  /** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
5
6
  export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
7
+ "editorial-checks": editorialChecksPlugin,
6
8
  signup: signupPlugin,
7
9
  };
8
10
 
@@ -23,6 +23,7 @@ import { defineSiteData } from "./data/resource.js";
23
23
  import { resolvePlugins } from "./plugins/registry.js";
24
24
  import { wirePlugins } from "./plugins/wire.js";
25
25
  import { assignmentAction } from "./functions/assignment-action/resource.js";
26
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access.js";
26
27
  import { categoryAction } from "./functions/category-action/resource.js";
27
28
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource.js";
28
29
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource.js";
@@ -304,10 +305,14 @@ export function defineSiteBackend(papyrusSite) {
304
305
  }
305
306
  grantCorporaRead(backend.knowledgeQuery.resources.lambda);
306
307
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda);
308
+ scopeAssignmentDataAccess(backend.assignmentAction.resources.lambda, backend.data.resources.graphqlApi.arn);
307
309
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda);
308
310
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
309
311
  const contentActionsLambda = backend.contentActions.resources.lambda;
310
312
  grantNewsroomReadWriteDelete(contentActionsLambda);
313
+ // AppSync validates the token; pending intake additionally binds its actor
314
+ // and publication to this deployment's pool and existing API endpoint.
315
+ contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
311
316
  contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
312
317
  contentActionsLambda.addToRolePolicy(new PolicyStatement({
313
318
  actions: ["appsync:GraphQL"],
@@ -30,6 +30,7 @@ import { defineSiteData } from "./data/resource";
30
30
  import { resolvePlugins } from "./plugins/registry";
31
31
  import { wirePlugins } from "./plugins/wire";
32
32
  import { assignmentAction } from "./functions/assignment-action/resource";
33
+ import { scopeAssignmentDataAccess } from "./functions/assignment-action/data-access";
33
34
  import { categoryAction } from "./functions/category-action/resource";
34
35
  import { ConsoleChatResponderStack } from "./functions/console-chat-responder/resource";
35
36
  import { contentActions, setSiteFilesForContentActionsBundle } from "./functions/content-actions/resource";
@@ -387,10 +388,20 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
387
388
  }
388
389
  grantCorporaRead(backend.knowledgeQuery.resources.lambda as LambdaFunction);
389
390
  grantNewsroomReadWrite(backend.assignmentAction.resources.lambda as LambdaFunction);
391
+ scopeAssignmentDataAccess(
392
+ backend.assignmentAction.resources.lambda as LambdaFunction,
393
+ backend.data.resources.graphqlApi.arn,
394
+ );
390
395
  grantNewsroomReadWrite(backend.categoryAction.resources.lambda as LambdaFunction);
391
396
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
392
397
  const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
393
398
  grantNewsroomReadWriteDelete(contentActionsLambda);
399
+ // AppSync validates the token; pending intake additionally binds its actor
400
+ // and publication to this deployment's pool and existing API endpoint.
401
+ contentActionsLambda.addEnvironment(
402
+ "PAPYRUS_USER_POOL_ISSUER",
403
+ `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`,
404
+ );
394
405
  contentActionsLambda.addEnvironment(
395
406
  "PAPYRUS_GRAPHQL_ENDPOINT",
396
407
  backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
@@ -0,0 +1,337 @@
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_PACK = "ai-writing-tells";
25
+ const POLICY_KEY = /^[a-z0-9][a-z0-9._-]*$/;
26
+
27
+ type Draft = {
28
+ isNew: boolean;
29
+ record: EditorialPolicyRecord;
30
+ detectorsText: string;
31
+ violatesText: string;
32
+ compliesText: string;
33
+ };
34
+
35
+ function emptyPolicy(): EditorialPolicyRecord {
36
+ return {
37
+ id: "",
38
+ policyKey: "",
39
+ scope: "publication",
40
+ version: 1,
41
+ title: "",
42
+ guidance: "",
43
+ severity: "warn",
44
+ enabled: true,
45
+ detectors: [],
46
+ examples: null,
47
+ rewriteGuidance: null,
48
+ source: "custom",
49
+ };
50
+ }
51
+
52
+ function draftFrom(record: EditorialPolicyRecord, isNew: boolean): Draft {
53
+ return {
54
+ isNew,
55
+ record,
56
+ detectorsText: JSON.stringify(record.detectors, null, 2),
57
+ violatesText: (record.examples?.violates ?? []).join("\n"),
58
+ compliesText: (record.examples?.complies ?? []).join("\n"),
59
+ };
60
+ }
61
+
62
+ function lines(text: string): string[] {
63
+ return text.split("\n").map((line) => line.trim()).filter(Boolean);
64
+ }
65
+
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> {
68
+ const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
69
+ if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
70
+ const violates = lines(draft.violatesText);
71
+ const complies = lines(draft.compliesText);
72
+ return {
73
+ id: draft.record.policyKey || "draft",
74
+ version: draft.record.version,
75
+ title: draft.record.title || "Draft policy",
76
+ guidance: draft.record.guidance || "Draft policy.",
77
+ severity: draft.record.severity,
78
+ enabled: true,
79
+ detectors,
80
+ ...(violates.length || complies.length ? { examples: { violates, complies } } : {}),
81
+ ...(draft.record.rewriteGuidance ? { rewriteGuidance: draft.record.rewriteGuidance } : {}),
82
+ };
83
+ }
84
+
85
+ export function EditorialChecksAdminPanel() {
86
+ const [policies, setPolicies] = useState<EditorialPolicyRecord[] | null>(null);
87
+ const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
88
+ const [settingsExist, setSettingsExist] = useState(false);
89
+ const [draft, setDraft] = useState<Draft | null>(null);
90
+ const [message, setMessage] = useState<string | null>(null);
91
+ const [working, setWorking] = useState<string | null>(null);
92
+ const [tryText, setTryText] = useState("");
93
+ const [tryResult, setTryResult] = useState<EditorialCheckResult | null>(null);
94
+
95
+ const refresh = useCallback(async () => {
96
+ try {
97
+ const [loadedPolicies, loadedSettings] = await Promise.all([listPolicies(), loadSettings()]);
98
+ setPolicies(loadedPolicies);
99
+ setSettings(loadedSettings.settings);
100
+ setSettingsExist(loadedSettings.exists);
101
+ } catch (error) {
102
+ setMessage(`Error: ${error instanceof Error ? error.message : "could not load editorial checks"}`);
103
+ }
104
+ }, []);
105
+
106
+ useEffect(() => {
107
+ void refresh();
108
+ }, [refresh]);
109
+
110
+ const run = async (label: string, action: () => Promise<string | void>) => {
111
+ setWorking(label);
112
+ setMessage(null);
113
+ try {
114
+ const outcome = await action();
115
+ if (outcome) setMessage(outcome);
116
+ } catch (error) {
117
+ setMessage(`Error: ${error instanceof Error ? error.message : String(error)}`);
118
+ } finally {
119
+ setWorking(null);
120
+ }
121
+ };
122
+
123
+ const patchDraft = (patch: Partial<EditorialPolicyRecord>) => setDraft((current) => (current ? { ...current, record: { ...current.record, ...patch } } : current));
124
+
125
+ const save = () =>
126
+ run("save", async () => {
127
+ if (!draft) return;
128
+ const key = draft.record.policyKey.trim();
129
+ if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
130
+ if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
131
+ const policy = limatusPolicy(draft);
132
+ const record: EditorialPolicyRecord = {
133
+ ...draft.record,
134
+ id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
135
+ policyKey: key,
136
+ version: draft.isNew ? 1 : draft.record.version + 1,
137
+ detectors: policy.detectors as unknown[],
138
+ examples: (policy.examples as EditorialPolicyRecord["examples"]) ?? null,
139
+ };
140
+ await savePolicy(record, draft.isNew);
141
+ setDraft(null);
142
+ await refresh();
143
+ return `Saved ${record.title} (version ${record.version}).`;
144
+ });
145
+
146
+ return (
147
+ <section aria-labelledby="newsroom-editorial-checks-title" data-news-desk-admin-panel="editorial-checks">
148
+ <h3 id="newsroom-editorial-checks-title">Editorial checks</h3>
149
+ <p>
150
+ Policies the article editor checks drafts against. Rules match words and patterns; judgment questions are answered by a decision model, sentence by sentence.
151
+ Findings are advice: editors can dismiss them, and publishing is never blocked.
152
+ </p>
153
+ <p aria-live="polite" role="status">
154
+ {working ? <><Spinner /> Working…</> : message}
155
+ </p>
156
+
157
+ <h4>Policies</h4>
158
+ <div className="flex flex-wrap gap-2">
159
+ <Button onClick={() => { setDraft(draftFrom(emptyPolicy(), true)); setTryResult(null); }} size="sm" type="button">New policy</Button>
160
+ <Button
161
+ disabled={working !== null}
162
+ onClick={() => run("import", async () => {
163
+ const outcome = await importStarterPack(STARTER_PACK);
164
+ await refresh();
165
+ return `Starter pack: ${outcome.created} policies added, ${outcome.skipped} already present.`;
166
+ })}
167
+ size="sm"
168
+ type="button"
169
+ variant="outline"
170
+ >
171
+ Add AI-writing tells starter pack
172
+ </Button>
173
+ <Button
174
+ disabled={working !== null}
175
+ onClick={() => run("recalibrate", async () => {
176
+ const outcome = await recalibrate();
177
+ await refresh();
178
+ const notes = outcome.detectors.filter((d) => d.note).map((d) => `${d.policyId}/${d.detectorId}: ${d.note}`);
179
+ return `Recalibrated ${outcome.updated} ${outcome.updated === 1 ? "policy" : "policies"} from editors' decisions.${notes.length ? ` ${notes.join("; ")}` : ""}`;
180
+ })}
181
+ size="sm"
182
+ type="button"
183
+ variant="outline"
184
+ >
185
+ Recalibrate from editors&apos; decisions
186
+ </Button>
187
+ </div>
188
+ {policies === null ? <p>Loading…</p> : policies.length === 0 ? <p>No policies yet. Add the starter pack or write one.</p> : (
189
+ <table data-editorial-policies>
190
+ <thead>
191
+ <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>
192
+ </thead>
193
+ <tbody>
194
+ {policies.map((policy) => (
195
+ <tr data-editorial-policy-row={policy.id} key={policy.id}>
196
+ <td>{policy.title}<br /><small>{policy.policyKey} · v{policy.version}</small></td>
197
+ <td>{policy.scope === "publication" ? "Whole publication" : `Desk: ${policy.scope}`}</td>
198
+ <td>{policy.severity}</td>
199
+ <td>{policy.detectors.length === 0 ? "Guidance only" : policy.detectors.length}</td>
200
+ <td>
201
+ <input
202
+ aria-label={`${policy.title} enabled`}
203
+ checked={policy.enabled}
204
+ onChange={(event) => void run("toggle", async () => {
205
+ await savePolicy({ ...policy, enabled: event.target.checked, version: policy.version + 1 }, false);
206
+ await refresh();
207
+ })}
208
+ type="checkbox"
209
+ />
210
+ </td>
211
+ <td>
212
+ <button onClick={() => { setDraft(draftFrom(policy, false)); setTryResult(null); }} type="button">Edit</button>
213
+ </td>
214
+ </tr>
215
+ ))}
216
+ </tbody>
217
+ </table>
218
+ )}
219
+
220
+ {draft ? (
221
+ <form
222
+ className="space-y-2"
223
+ data-editorial-policy-form
224
+ onSubmit={(event) => { event.preventDefault(); void save(); }}
225
+ >
226
+ <h4>{draft.isNew ? "New policy" : `Edit ${draft.record.title}`}</h4>
227
+ <label>Key <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ policyKey: event.target.value })} value={draft.record.policyKey} /></label>
228
+ <label>Title <Input onChange={(event) => patchDraft({ title: event.target.value })} value={draft.record.title} /></label>
229
+ <label>
230
+ Applies to (publication, or a desk key){" "}
231
+ <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ scope: event.target.value.trim() || "publication" })} value={draft.record.scope} />
232
+ </label>
233
+ <label>
234
+ Severity{" "}
235
+ <select onChange={(event) => patchDraft({ severity: event.target.value as EditorialPolicyRecord["severity"] })} value={draft.record.severity}>
236
+ <option value="error">Must fix</option>
237
+ <option value="warn">Should fix</option>
238
+ <option value="info">Consider</option>
239
+ </select>
240
+ </label>
241
+ <label>Guidance (what the policy asks, in plain words) <Textarea onChange={(event) => patchDraft({ guidance: event.target.value })} rows={3} value={draft.record.guidance} /></label>
242
+ <label>How to fix it (for rewrite suggestions) <Textarea onChange={(event) => patchDraft({ rewriteGuidance: event.target.value || null })} rows={2} value={draft.record.rewriteGuidance ?? ""} /></label>
243
+ <label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
244
+ <label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
245
+ <label>
246
+ Detectors (JSON; see <code>limatus schema policy-pack-v1</code>)
247
+ <Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
248
+ </label>
249
+ <div className="flex flex-wrap gap-2">
250
+ <Button
251
+ disabled={working !== null || !draft.record.title.trim() || !draft.record.guidance.trim()}
252
+ onClick={() => run("draft", async () => {
253
+ const drafted = await draftQuestions(draft.record.title, draft.record.guidance);
254
+ const existing = JSON.parse(draft.detectorsText || "[]") as unknown[];
255
+ setDraft({ ...draft, detectorsText: JSON.stringify([...existing, ...drafted.detectors], null, 2) });
256
+ return `Added ${drafted.detectors.length} drafted judgment question(s). Review them before saving. ${drafted.notes.join(" ")}`;
257
+ })}
258
+ type="button"
259
+ variant="outline"
260
+ >
261
+ Draft judgment questions
262
+ </Button>
263
+ <Button disabled={working !== null} type="submit">Save</Button>
264
+ {!draft.isNew ? (
265
+ <Button
266
+ onClick={() => run("delete", async () => {
267
+ await deletePolicy(draft.record.id);
268
+ setDraft(null);
269
+ await refresh();
270
+ return "Policy deleted.";
271
+ })}
272
+ type="button"
273
+ variant="outline"
274
+ >
275
+ Delete
276
+ </Button>
277
+ ) : null}
278
+ <Button onClick={() => setDraft(null)} type="button" variant="ghost">Cancel</Button>
279
+ </div>
280
+
281
+ <fieldset className="space-y-2" data-editorial-try>
282
+ <legend>Try it on sample text (nothing is saved)</legend>
283
+ <Textarea onChange={(event) => setTryText(event.target.value)} rows={4} value={tryText} />
284
+ <Button
285
+ disabled={working !== null || !tryText.trim()}
286
+ onClick={() => run("try", async () => {
287
+ setTryResult(await tryPolicy(limatusPolicy(draft), tryText));
288
+ })}
289
+ size="sm"
290
+ type="button"
291
+ variant="outline"
292
+ >
293
+ Try policy
294
+ </Button>
295
+ {tryResult ? (
296
+ tryResult.findings.length === 0 ? <p>No findings.</p> : (
297
+ <ul data-editorial-try-findings>
298
+ {tryResult.findings.map((finding) => (
299
+ <li key={finding.id}>
300
+ {finding.status === "review" ? "Unsure: " : ""}“{finding.excerpt || "whole text"}” {describeProbability(finding) ? `(${describeProbability(finding)})` : ""}
301
+ </li>
302
+ ))}
303
+ </ul>
304
+ )
305
+ ) : null}
306
+ </fieldset>
307
+ </form>
308
+ ) : null}
309
+
310
+ <h4>Settings</h4>
311
+ <form
312
+ className="space-y-2"
313
+ data-editorial-settings
314
+ onSubmit={(event) => {
315
+ event.preventDefault();
316
+ void run("settings", async () => {
317
+ await saveSettings(settings, settingsExist);
318
+ setSettingsExist(true);
319
+ return "Settings saved.";
320
+ });
321
+ }}
322
+ >
323
+ <label><input checked={settings.lanes.rules} onChange={(event) => setSettings({ ...settings, lanes: { ...settings.lanes, rules: event.target.checked } })} type="checkbox" /> Rule checks</label>
324
+ <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
+ <label><input checked={settings.checkWhileTyping} onChange={(event) => setSettings({ ...settings, checkWhileTyping: event.target.checked })} type="checkbox" /> Check while editors type (after a short pause)</label>
326
+ <label><input checked={settings.publishWarning} onChange={(event) => setSettings({ ...settings, publishWarning: event.target.checked })} type="checkbox" /> Warn before publishing with open findings</label>
327
+ <label>Decision model <Input onChange={(event) => setSettings({ ...settings, classifierModel: event.target.value })} value={settings.classifierModel} /></label>
328
+ <label>Rewrite model <Input onChange={(event) => setSettings({ ...settings, rewriteModel: event.target.value })} value={settings.rewriteModel} /></label>
329
+ <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>
330
+ <label>Input-token budget per check <Input min={1} onChange={(event) => setSettings({ ...settings, maxInputTokens: Number(event.target.value) })} type="number" value={settings.maxInputTokens} /></label>
331
+ <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>
332
+ <label>House guidance for rewrites <Textarea onChange={(event) => setSettings({ ...settings, guidance: event.target.value })} rows={3} value={settings.guidance} /></label>
333
+ <Button disabled={working !== null} type="submit">Save settings</Button>
334
+ </form>
335
+ </section>
336
+ );
337
+ }