@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
@@ -12,10 +12,10 @@ import { formatSuggestionTime } from "./reference-cyclotron-suggestion";
12
12
 
13
13
  function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasurement }) {
14
14
  const figures: Array<[string, string]> = [
15
- ["Agreement", formatPercentage(measurement.agreement)],
16
- ["Precision", formatPercentage(measurement.precision)],
17
- ["Recall", formatPercentage(measurement.recall)],
18
- ["Comparisons", String(measurement.comparisons)],
15
+ ["Matches editors", formatPercentage(measurement.agreement)],
16
+ ["Suggested includes editors kept", formatPercentage(measurement.precision)],
17
+ ["Editor includes suggested", formatPercentage(measurement.recall)],
18
+ ["Decisions compared", String(measurement.comparisons)],
19
19
  ];
20
20
  return (
21
21
  <dl className="flex flex-wrap gap-x-6 gap-y-1">
@@ -30,19 +30,19 @@ function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasur
30
30
  }
31
31
 
32
32
  /**
33
- * Live agreement at the top of References: how often the cyclotron's
33
+ * Live agreement at the top of References: how often the
34
34
  * suggestions matched what editors then decided. Hidden without a payload.
35
35
  */
36
36
  export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreement | null }) {
37
37
  const state = agreementStripState(agreement);
38
38
  if (state.kind === "hidden") return null;
39
39
  return (
40
- <section aria-label="Live agreement" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
41
- <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Live agreement</h3>
40
+ <section aria-label="Suggestion accuracy" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
41
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion accuracy</h3>
42
42
  {state.loop ? (
43
43
  <p className="text-sm text-foreground" data-reference-live-agreement-loop>
44
44
  <span className="font-semibold">{loopStateWords(state.loop)}</span>
45
- {" · "}Last sweep {formatSuggestionTime(state.loop.lastSweepAt)}
45
+ {" · "}Last checked {formatSuggestionTime(state.loop.lastSweepAt)}
46
46
  {" · "}{dailyCapWords(state.loop)}
47
47
  </p>
48
48
  ) : null}
@@ -51,11 +51,11 @@ export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreeme
51
51
  ) : null}
52
52
  {state.kind === "not_enough" ? (
53
53
  <p className="text-sm text-foreground" data-reference-live-agreement-not-enough>
54
- Not enough comparisons yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
54
+ Not enough decisions yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
55
55
  </p>
56
56
  ) : <MeasurementFigures measurement={state.measurement} />}
57
57
  <p className="text-xs text-foreground">
58
- Suggestions compared with the decisions editors made afterwards. Computed {formatSuggestionTime(agreement?.computedAt ?? null)}.
58
+ Suggestions compared with the decisions editors made afterwards. Updated {formatSuggestionTime(agreement?.computedAt ?? null)}.
59
59
  </p>
60
60
  </section>
61
61
  );
@@ -10,7 +10,7 @@ export function ReferencePriming(props: PrimingPanelProps) {
10
10
  return (
11
11
  <section className="shrink-0 space-y-3" aria-label="Representative reference examples">
12
12
  <Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
13
- {open ? "Hide example priming" : "Curate reference examples (optional)"}
13
+ {open ? "Hide example references" : "Add example references (optional)"}
14
14
  </Button>
15
15
  {open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
16
16
  </section>
@@ -67,7 +67,7 @@ export function ReferenceRelevanceReview({
67
67
  onChange={(event) => setShareable(event.target.checked)}
68
68
  type="checkbox"
69
69
  />
70
- My explanation may be quoted publicly (for the Cyclotron example page)
70
+ My explanation may be quoted publicly (on a public example page)
71
71
  </label>
72
72
  {error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
73
73
  </section>
@@ -98,7 +98,7 @@ export function SectionPicker({
98
98
  </label>
99
99
  );
100
100
  })}
101
- <p className="text-xs text-foreground">The Section you choose is a training label for the cyclotron.</p>
101
+ <p className="text-xs text-foreground">The Section you choose is saved with your decision.</p>
102
102
  </fieldset>
103
103
  );
104
104
  }
@@ -13,6 +13,7 @@ import { usePathname, useRouter } from "next/navigation";
13
13
  import type { ReactNode, RefObject } from "react";
14
14
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
+ import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
16
17
  import { SignupEntriesPanel } from "./signup-entries-panel";
17
18
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
18
19
  import { generateClient } from "aws-amplify/data";
@@ -246,7 +247,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
246
247
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
247
248
  type AssignmentDeskViewMode = "queue" | "budget";
248
249
  type UserRoleAction = "grant" | "revoke";
249
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups";
250
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks";
250
251
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
251
252
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
252
253
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -4317,6 +4318,17 @@ function AdministrationDeskView({
4317
4318
  <strong>Agent delegations</strong>
4318
4319
  <span>Let an agent curate References for you</span>
4319
4320
  </Link>
4321
+ {editorialChecksPluginEnabled() ? (
4322
+ <Link
4323
+ href={administrationPanelHref("editorial-checks", isDemo)}
4324
+ data-news-desk-admin-nav="editorial-checks"
4325
+ data-active={administrationPanel === "editorial-checks" || undefined}
4326
+ onClick={() => onPanelChange("editorial-checks")}
4327
+ >
4328
+ <strong>Editorial checks</strong>
4329
+ <span>Policies the article editor checks drafts against</span>
4330
+ </Link>
4331
+ ) : null}
4320
4332
  {signupPluginEnabled() ? (
4321
4333
  <Link
4322
4334
  href={administrationPanelHref("signups", isDemo)}
@@ -4331,6 +4343,7 @@ function AdministrationDeskView({
4331
4343
  </nav>}
4332
4344
  <div className="news-desk-settings-panel">
4333
4345
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4346
+ {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4334
4347
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4335
4348
  {administrationPanel === "users" ? (
4336
4349
  <AdministrationUsersPanel
@@ -12794,9 +12807,14 @@ function signupPluginEnabled(): boolean {
12794
12807
  return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
12795
12808
  }
12796
12809
 
12810
+ function editorialChecksPluginEnabled(): boolean {
12811
+ return pluginSettings(amplifyOutputs as PluginOutputs, "editorial-checks") !== null;
12812
+ }
12813
+
12797
12814
  function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
12798
12815
  if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
12799
12816
  if (value === "signups" && signupPluginEnabled()) return value;
12817
+ if (value === "editorial-checks" && editorialChecksPluginEnabled()) return value;
12800
12818
  return "users";
12801
12819
  }
12802
12820
 
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ feedbackFor,
6
+ publishWarnings,
7
+ type EditorialCheckResult,
8
+ type EditorialFinding,
9
+ type FeedbackDecision,
10
+ type RewriteResult,
11
+ type Span,
12
+ } from "../lib/editorial-checks";
13
+ import type { EditorialChecksBackend } from "../lib/editorial-checks-backend";
14
+ import { DEFAULT_EDITORIAL_SETTINGS, type EditorialSettings } from "../lib/editorial-checks-settings";
15
+ import type { CheckStatus } from "./editorial-findings-panel";
16
+
17
+ const CHECK_PAUSE_MS = 2000;
18
+
19
+ /**
20
+ * Editorial checks for one article body. A result only applies to the exact body
21
+ * it was computed for: if the editor typed while the check ran, the result is
22
+ * dropped and the next pause checks again.
23
+ */
24
+ export function useEditorialChecks({
25
+ backend,
26
+ body,
27
+ section,
28
+ itemId,
29
+ }: {
30
+ /** null turns editorial checks off. */
31
+ backend: EditorialChecksBackend | null;
32
+ body: string | null;
33
+ section: string;
34
+ itemId: string | null;
35
+ }) {
36
+ const enabled = backend !== null;
37
+ const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
38
+ const [result, setResult] = useState<EditorialCheckResult | null>(null);
39
+ const [checkedBody, setCheckedBody] = useState<string | null>(null);
40
+ const [status, setStatus] = useState<CheckStatus>({ state: "idle" });
41
+ const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
42
+ const [activeFindingId, setActiveFindingId] = useState<string | null>(null);
43
+ const [selection, setSelection] = useState<Span>({ start: 0, end: 0 });
44
+ const latestBody = useRef(body);
45
+ latestBody.current = body;
46
+ const sequence = useRef(0);
47
+
48
+ useEffect(() => {
49
+ if (!enabled) return;
50
+ backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined);
51
+ }, [backend, enabled]);
52
+
53
+ useEffect(() => {
54
+ if (!enabled || !itemId) return;
55
+ backend?.dismissedFindingIds(itemId).then(setDismissed).catch(() => undefined);
56
+ }, [backend, enabled, itemId]);
57
+
58
+ const check = useCallback(async () => {
59
+ const text = latestBody.current;
60
+ if (!backend || text === null || !text.trim()) return;
61
+ const run = ++sequence.current;
62
+ setStatus({ state: "checking" });
63
+ try {
64
+ const checked = await backend.check(text, section.trim() || null);
65
+ if (run !== sequence.current) return;
66
+ if (latestBody.current !== text) {
67
+ setStatus({ state: "idle" });
68
+ return;
69
+ }
70
+ setResult(checked);
71
+ setCheckedBody(text);
72
+ setStatus({ state: "idle" });
73
+ } catch (error) {
74
+ if (run === sequence.current) setStatus({ state: "error", message: error instanceof Error ? error.message : "The check failed." });
75
+ }
76
+ }, [backend, section]);
77
+
78
+ useEffect(() => {
79
+ if (!enabled || !settings.checkWhileTyping || body === null || body === checkedBody) return;
80
+ const timer = window.setTimeout(() => void check(), CHECK_PAUSE_MS);
81
+ return () => window.clearTimeout(timer);
82
+ }, [body, check, checkedBody, enabled, settings.checkWhileTyping]);
83
+
84
+ const record = useCallback(
85
+ (finding: EditorialFinding, decision: FeedbackDecision, reason: string | null = null) => {
86
+ if (!result || checkedBody === null) return;
87
+ void backend?.recordFeedback(feedbackFor(result, checkedBody, finding, decision, itemId, reason)).catch(() => undefined);
88
+ },
89
+ [backend, checkedBody, itemId, result],
90
+ );
91
+
92
+ const dismiss = useCallback(
93
+ (finding: EditorialFinding, reason: string) => {
94
+ record(finding, "dismissed", reason);
95
+ setDismissed((current) => new Set(current).add(finding.id));
96
+ },
97
+ [record],
98
+ );
99
+
100
+ const suggest = useCallback(
101
+ (target: { finding: EditorialFinding } | { selection: Span }, note: string | null): Promise<RewriteResult> =>
102
+ backend
103
+ ? backend.suggest(latestBody.current ?? "", section.trim() || null, target, note)
104
+ : Promise.reject(new Error("Editorial checks are off.")),
105
+ [backend, section],
106
+ );
107
+
108
+ const current = result && checkedBody === body;
109
+ return {
110
+ enabled,
111
+ settings,
112
+ result,
113
+ /** Findings whose spans still match the body on screen. */
114
+ inlineResult: current ? result : null,
115
+ status,
116
+ dismissed,
117
+ activeFindingId,
118
+ setActiveFindingId,
119
+ selection,
120
+ setSelection,
121
+ check,
122
+ suggest,
123
+ dismiss,
124
+ accept: (finding: EditorialFinding) => record(finding, "accepted"),
125
+ fixed: (finding: EditorialFinding) => {
126
+ record(finding, "fixed");
127
+ setDismissed((current) => new Set(current).add(finding.id));
128
+ },
129
+ openWarnings: enabled && settings.publishWarning && result ? publishWarnings(result.findings, dismissed) : [],
130
+ };
131
+ }
@@ -92,13 +92,13 @@ export function latestShadowAgreement(payloads: Array<{ json: unknown; updatedAt
92
92
  }
93
93
 
94
94
  const STOP_REASON_WORDS: Record<string, string> = {
95
- disabled: "The relevance cyclotron is switched off in the steering config, so it is making no suggestions.",
96
- "daily decision cap": "The last sweep stopped at the daily decision cap, so some references have no suggestion yet.",
95
+ disabled: "Suggestions are switched off in the settings, so none are being made.",
96
+ "daily decision cap": "The last check reached the daily limit, so some references have no suggestion yet.",
97
97
  };
98
98
 
99
99
  /** The sentence an editor reads for a stop reason; unknown reasons are shown as given. */
100
100
  export function stopReasonSentence(reason: string): string {
101
- return STOP_REASON_WORDS[reason] ?? `The last sweep stopped early: ${reason}.`;
101
+ return STOP_REASON_WORDS[reason] ?? `The last check stopped early: ${reason}.`;
102
102
  }
103
103
 
104
104
  /**
@@ -123,14 +123,14 @@ export function agreementStripState(agreement: ShadowAgreement | null): Agreemen
123
123
  };
124
124
  }
125
125
 
126
- /** "Loop on" or "Loop off", the word the strip leads with. */
126
+ /** "Suggestions on" or "Suggestions off", the words the panel leads with. */
127
127
  export function loopStateWords(loop: CyclotronLoopState): string {
128
- return loop.enabled ? "Loop on" : "Loop off";
128
+ return loop.enabled ? "Suggestions on" : "Suggestions off";
129
129
  }
130
130
 
131
131
  /** The daily cap in words; no cap is said plainly. */
132
132
  export function dailyCapWords(loop: CyclotronLoopState): string {
133
- return loop.maxDecisionsPerDay === null ? "No daily decision cap" : `Daily cap ${loop.maxDecisionsPerDay} decisions`;
133
+ return loop.maxDecisionsPerDay === null ? "No daily limit" : `Daily limit ${loop.maxDecisionsPerDay} decisions`;
134
134
  }
135
135
 
136
136
  export function formatPercentage(fraction: number | null): string {
@@ -202,10 +202,10 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
202
202
  if (!provenance) return null;
203
203
  const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
204
204
  return {
205
- teachingLabelsSentence: `Taught by ${plural(provenance.teachingLabels, "label")} that editors set themselves`,
206
- primingExamplesSentence: `${plural(provenance.primingExamples, "priming example")} supplied as starting examples`,
205
+ teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
+ primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
207
207
  doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
208
- modelVersion: decision.version === null ? null : `version ${decision.version}`,
208
+ modelVersion: decision.version === null ? null : `revision ${decision.version}`,
209
209
  decidedAt: decision.decidedAt,
210
210
  };
211
211
  }
@@ -0,0 +1,16 @@
1
+ import type { EditorialCheckResult, EditorialFeedbackInput, EditorialFinding, RewriteResult, Span } from "./editorial-checks";
2
+ import type { EditorialSettings } from "./editorial-checks-settings";
3
+
4
+ /** What the article editor needs from editorial checks: the plugin's GraphQL API, or the demo. */
5
+ export type EditorialChecksBackend = {
6
+ loadSettings: () => Promise<{ settings: EditorialSettings; exists: boolean }>;
7
+ dismissedFindingIds: (itemId: string) => Promise<Set<string>>;
8
+ check: (body: string, section: string | null) => Promise<EditorialCheckResult>;
9
+ suggest: (
10
+ body: string,
11
+ section: string | null,
12
+ target: { finding: EditorialFinding } | { selection: Span },
13
+ note: string | null,
14
+ ) => Promise<RewriteResult>;
15
+ recordFeedback: (input: EditorialFeedbackInput) => Promise<void>;
16
+ };
@@ -0,0 +1,194 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { EditorialCheckResult, EditorialFeedbackInput, EditorialFinding, RewriteResult, Span } from "./editorial-checks";
5
+ import type { EditorialChecksBackend } from "./editorial-checks-backend";
6
+ import { DEFAULT_EDITORIAL_SETTINGS, type EditorialSettings } from "./editorial-checks-settings";
7
+
8
+ export { DEFAULT_EDITORIAL_SETTINGS, type EditorialSettings };
9
+
10
+ type GraphQLResult<T> = { data?: T; errors?: { message?: string }[] };
11
+
12
+ async function call<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
13
+ const client = generateClient({ authMode: "userPool" });
14
+ const result = (await client.graphql({ query, variables })) as GraphQLResult<T>;
15
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Editorial checks request failed.");
16
+ if (!result.data) throw new Error("Editorial checks returned no data.");
17
+ return result.data;
18
+ }
19
+
20
+ /** AWSJSON comes back as a JSON string. */
21
+ function parsed<T>(value: unknown): T {
22
+ return (typeof value === "string" ? JSON.parse(value) : value) as T;
23
+ }
24
+
25
+ export async function checkEditorial(body: string, section: string | null): Promise<EditorialCheckResult> {
26
+ const data = await call<{ checkEditorial: unknown }>(
27
+ `query CheckEditorial($body: String!, $format: String, $section: String) { checkEditorial(body: $body, format: $format, section: $section) }`,
28
+ { body, format: "markus", section },
29
+ );
30
+ return parsed<EditorialCheckResult>(data.checkEditorial);
31
+ }
32
+
33
+ export async function suggestRewrites(
34
+ body: string,
35
+ section: string | null,
36
+ target: { finding: EditorialFinding } | { selection: Span },
37
+ note: string | null,
38
+ ): Promise<RewriteResult> {
39
+ const data = await call<{ suggestEditorialRewrite: unknown }>(
40
+ `query SuggestEditorialRewrite($body: String!, $format: String, $section: String, $finding: AWSJSON, $selection: AWSJSON, $note: String) {
41
+ suggestEditorialRewrite(body: $body, format: $format, section: $section, finding: $finding, selection: $selection, note: $note)
42
+ }`,
43
+ {
44
+ body,
45
+ format: "markus",
46
+ section,
47
+ finding: "finding" in target ? JSON.stringify(target.finding) : null,
48
+ selection: "selection" in target ? JSON.stringify(target.selection) : null,
49
+ note,
50
+ },
51
+ );
52
+ return parsed<RewriteResult>(data.suggestEditorialRewrite);
53
+ }
54
+
55
+ export async function recordFeedback(input: EditorialFeedbackInput): Promise<void> {
56
+ await call(
57
+ `mutation CreateEditorialFeedback($input: CreateEditorialFeedbackInput!) { createEditorialFeedback(input: $input) { id } }`,
58
+ { input },
59
+ );
60
+ }
61
+
62
+ export async function dismissedFindingIds(itemId: string): Promise<Set<string>> {
63
+ const ids = new Set<string>();
64
+ let nextToken: string | null | undefined;
65
+ do {
66
+ const data = await call<{ listEditorialFeedbackByItem: { items: { findingId: string; decision: string }[]; nextToken?: string | null } }>(
67
+ `query ListEditorialFeedbackByItem($itemId: String!, $nextToken: String) {
68
+ listEditorialFeedbackByItem(itemId: $itemId, limit: 500, nextToken: $nextToken) { items { findingId decision } nextToken }
69
+ }`,
70
+ { itemId, nextToken },
71
+ );
72
+ for (const item of data.listEditorialFeedbackByItem.items) if (item.decision === "dismissed") ids.add(item.findingId);
73
+ nextToken = data.listEditorialFeedbackByItem.nextToken;
74
+ } while (nextToken);
75
+ return ids;
76
+ }
77
+
78
+ // --- administration ---------------------------------------------------------------
79
+
80
+ export type EditorialPolicyRecord = {
81
+ id: string;
82
+ policyKey: string;
83
+ scope: string;
84
+ version: number;
85
+ title: string;
86
+ guidance: string;
87
+ severity: "info" | "warn" | "error";
88
+ enabled: boolean;
89
+ detectors: unknown[];
90
+ examples: { violates?: string[]; complies?: string[] } | null;
91
+ rewriteGuidance: string | null;
92
+ source: string;
93
+ };
94
+
95
+ const POLICY_FIELDS = "id policyKey scope version title guidance severity enabled detectors examples rewriteGuidance source";
96
+
97
+ function policyFromRow(row: Record<string, unknown>): EditorialPolicyRecord {
98
+ return {
99
+ ...(row as unknown as EditorialPolicyRecord),
100
+ detectors: parsed<unknown[]>(row.detectors ?? "[]"),
101
+ examples: row.examples ? parsed<EditorialPolicyRecord["examples"]>(row.examples) : null,
102
+ };
103
+ }
104
+
105
+ function policyInput(policy: EditorialPolicyRecord): Record<string, unknown> {
106
+ return {
107
+ ...policy,
108
+ detectors: JSON.stringify(policy.detectors),
109
+ examples: policy.examples ? JSON.stringify(policy.examples) : null,
110
+ };
111
+ }
112
+
113
+ export async function listPolicies(): Promise<EditorialPolicyRecord[]> {
114
+ const policies: EditorialPolicyRecord[] = [];
115
+ let nextToken: string | null | undefined;
116
+ do {
117
+ const data = await call<{ listEditorialPolicies: { items: Record<string, unknown>[]; nextToken?: string | null } }>(
118
+ `query ListEditorialPolicies($nextToken: String) { listEditorialPolicies(limit: 500, nextToken: $nextToken) { items { ${POLICY_FIELDS} } nextToken } }`,
119
+ { nextToken },
120
+ );
121
+ policies.push(...data.listEditorialPolicies.items.map(policyFromRow));
122
+ nextToken = data.listEditorialPolicies.nextToken;
123
+ } while (nextToken);
124
+ return policies.sort((a, b) => a.scope.localeCompare(b.scope) || a.title.localeCompare(b.title));
125
+ }
126
+
127
+ export async function savePolicy(policy: EditorialPolicyRecord, isNew: boolean): Promise<void> {
128
+ const operation = isNew ? "createEditorialPolicy" : "updateEditorialPolicy";
129
+ const inputType = isNew ? "CreateEditorialPolicyInput" : "UpdateEditorialPolicyInput";
130
+ await call(`mutation SavePolicy($input: ${inputType}!) { ${operation}(input: $input) { id } }`, { input: policyInput(policy) });
131
+ }
132
+
133
+ export async function deletePolicy(id: string): Promise<void> {
134
+ await call(`mutation DeleteEditorialPolicy($input: DeleteEditorialPolicyInput!) { deleteEditorialPolicy(input: $input) { id } }`, { input: { id } });
135
+ }
136
+
137
+ export async function loadSettings(): Promise<{ settings: EditorialSettings; exists: boolean }> {
138
+ const data = await call<{ getEditorialCheckSettings: { settings: unknown } | null }>(
139
+ `query GetEditorialCheckSettings { getEditorialCheckSettings(id: "settings") { settings } }`,
140
+ );
141
+ const stored = data.getEditorialCheckSettings ? parsed<Partial<EditorialSettings>>(data.getEditorialCheckSettings.settings) : {};
142
+ return {
143
+ settings: { ...DEFAULT_EDITORIAL_SETTINGS, ...stored, lanes: { ...DEFAULT_EDITORIAL_SETTINGS.lanes, ...(stored.lanes ?? {}) } },
144
+ exists: data.getEditorialCheckSettings !== null,
145
+ };
146
+ }
147
+
148
+ export async function saveSettings(settings: EditorialSettings, exists: boolean): Promise<void> {
149
+ const operation = exists ? "updateEditorialCheckSettings" : "createEditorialCheckSettings";
150
+ const inputType = exists ? "UpdateEditorialCheckSettingsInput" : "CreateEditorialCheckSettingsInput";
151
+ await call(`mutation SaveSettings($input: ${inputType}!) { ${operation}(input: $input) { id } }`, {
152
+ input: { id: "settings", settings: JSON.stringify(settings) },
153
+ });
154
+ }
155
+
156
+ export async function importStarterPack(name: string): Promise<{ created: number; skipped: number }> {
157
+ const data = await call<{ importEditorialStarterPack: unknown }>(
158
+ `mutation ImportStarterPack($name: String!) { importEditorialStarterPack(name: $name) }`,
159
+ { name },
160
+ );
161
+ return parsed(data.importEditorialStarterPack);
162
+ }
163
+
164
+ export async function tryPolicy(policy: Record<string, unknown>, text: string): Promise<EditorialCheckResult> {
165
+ const data = await call<{ tryEditorialPolicy: unknown }>(
166
+ `query TryEditorialPolicy($policy: AWSJSON!, $text: String!) { tryEditorialPolicy(policy: $policy, text: $text, format: "markus") }`,
167
+ { policy: JSON.stringify(policy), text },
168
+ );
169
+ return parsed(data.tryEditorialPolicy);
170
+ }
171
+
172
+ export type DraftedQuestions = { detectors: Record<string, unknown>[]; notes: string[]; model: string };
173
+
174
+ export async function draftQuestions(title: string, guidance: string): Promise<DraftedQuestions> {
175
+ const data = await call<{ draftEditorialQuestions: unknown }>(
176
+ `query DraftEditorialQuestions($title: String!, $guidance: String!) { draftEditorialQuestions(title: $title, guidance: $guidance) }`,
177
+ { title, guidance },
178
+ );
179
+ return parsed(data.draftEditorialQuestions);
180
+ }
181
+
182
+ export async function recalibrate(): Promise<{ updated: number; detectors: { policyId: string; detectorId: string; labels: number; note?: string }[] }> {
183
+ const data = await call<{ recalibrateEditorialPolicies: unknown }>(`mutation Recalibrate { recalibrateEditorialPolicies }`);
184
+ return parsed(data.recalibrateEditorialPolicies);
185
+ }
186
+
187
+ /** The plugin's GraphQL API, signed in as the editor. */
188
+ export const graphqlEditorialBackend: EditorialChecksBackend = {
189
+ loadSettings,
190
+ dismissedFindingIds,
191
+ check: checkEditorial,
192
+ suggest: suggestRewrites,
193
+ recordFeedback,
194
+ };