@anthusai/papyrus 1.19.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 (80) 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/app/globals.css +0 -175
  22. package/app/newsroom/[section]/[...selection]/page.tsx +0 -6
  23. package/app/newsroom/[section]/page.tsx +2 -4
  24. package/app/newsroom/articles/page.tsx +1 -2
  25. package/app/newsroom/sections/[sectionId]/page.tsx +2 -6
  26. package/bin/papyrus-infra.mjs +11 -2
  27. package/components/editorial-checks-admin-panel.tsx +337 -0
  28. package/components/editorial-finding-card.tsx +162 -0
  29. package/components/editorial-findings-panel.tsx +91 -0
  30. package/components/editorial-preview-popover.tsx +103 -0
  31. package/components/markus-body-editor.tsx +213 -0
  32. package/components/markus-ir-preview.tsx +147 -6
  33. package/components/news-desk-page.tsx +3 -4
  34. package/components/newsroom-article-editor.tsx +138 -11
  35. package/components/newsroom-board-view.tsx +6 -7
  36. package/components/newsroom-ops-shell.tsx +146 -234
  37. package/components/newsroom-organizer.css +57 -61
  38. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  39. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  40. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  41. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  42. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  43. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  44. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  45. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  46. package/components/newsroom-theme.css +39 -0
  47. package/components/newsroom-theme.tsx +33 -0
  48. package/components/reference-cyclotron-priming.tsx +12 -13
  49. package/components/reference-cyclotron-status.tsx +4 -4
  50. package/components/reference-cyclotron-suggestion.tsx +6 -6
  51. package/components/reference-live-agreement.tsx +10 -10
  52. package/components/reference-priming.tsx +1 -1
  53. package/components/reference-relevance-review.tsx +1 -1
  54. package/components/reference-section-suggestion.tsx +1 -1
  55. package/components/topic-steering-workspace.tsx +36 -511
  56. package/components/use-editorial-checks.ts +131 -0
  57. package/lib/cyclotron-suggestions.ts +9 -9
  58. package/lib/editorial-checks-backend.ts +16 -0
  59. package/lib/editorial-checks-client.ts +194 -0
  60. package/lib/editorial-checks-demo.ts +147 -0
  61. package/lib/editorial-checks-settings.ts +24 -0
  62. package/lib/editorial-checks.ts +178 -0
  63. package/lib/editorial-preview.ts +228 -0
  64. package/lib/newsroom-board.ts +27 -2
  65. package/lib/newsroom-list-selection.ts +0 -3
  66. package/lib/newsroom-nav.ts +5 -8
  67. package/lib/newsroom-section-routing.ts +2 -4
  68. package/lib/relevance-status.ts +4 -4
  69. package/lib/site-brand.ts +18 -8
  70. package/package.json +34 -3
  71. package/routes.manifest.json +1 -1
  72. package/src/cdk-environment.mjs +44 -0
  73. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  74. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  75. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  76. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  77. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  78. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  79. package/components/newsroom-newsprint-board.css +0 -7
  80. package/components/newsroom-organizer-shell.tsx +0 -153
@@ -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
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
3
+ * The shapes match Limatus results so the editor, the preview marks and the
4
+ * rewrite flow behave as they do against the real plugin.
5
+ */
6
+ import type { EditorialCheckResult, EditorialFinding, EditorialSegment, RewriteResult, Span } from "./editorial-checks";
7
+ import type { EditorialChecksBackend } from "./editorial-checks-backend";
8
+ import { DEFAULT_EDITORIAL_SETTINGS } from "./editorial-checks-settings";
9
+
10
+ type DemoRule = { policyId: string; phrase: RegExp; severity: EditorialFinding["severity"]; message: string; replacement?: string };
11
+
12
+ const RULES: DemoRule[] = [
13
+ { policyId: "promotional-language", phrase: /\bgame[- ]changing\b/gi, severity: "warn", message: "Say what changed instead of calling it game-changing." },
14
+ { policyId: "plain-words", phrase: /\butilize\b/gi, severity: "info", message: "Prefer the plain word.", replacement: "use" },
15
+ { policyId: "formulaic-openers", phrase: /\bIn today's [a-z-]+ world\b/gi, severity: "info", message: "Formulaic opener: start with the point." },
16
+ ];
17
+
18
+ /** Demo rewrites keyed by a phrase in the target; anything else gets a clearly labelled placeholder. */
19
+ const CANNED: Record<string, string[]> = {
20
+ "game-changing": ["Our caching layer cuts build times for teams.", "Teams get faster builds from our caching layer."],
21
+ "Builds finish in 4 minutes": ["Builds now take 4 minutes, down from 9.", "A build takes 4 minutes instead of 9."],
22
+ };
23
+
24
+ export function demoPlainText(source: string): string {
25
+ return source
26
+ .replace(/^#{1,6}\s+/, "")
27
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
28
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
29
+ .replace(/\s?\[@[^\]]+\]/g, "")
30
+ .replace(/(\*\*|__|\*|_|~~|`)/g, "")
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+ }
34
+
35
+ function hash(text: string): string {
36
+ let value = 0;
37
+ for (let i = 0; i < text.length; i += 1) value = (value * 31 + text.charCodeAt(i)) >>> 0;
38
+ return value.toString(16).padStart(8, "0");
39
+ }
40
+
41
+ /** Paragraph and sentence segments of a body, by blank lines and sentence-ending punctuation. */
42
+ export function demoSegments(body: string): EditorialSegment[] {
43
+ const segments: EditorialSegment[] = [{ id: "doc", kind: "document", start: 0, end: body.length, textHash: hash(body) }];
44
+ const fence = /^(```|~~~)/;
45
+ let inFence = false;
46
+ for (const match of body.matchAll(/[^\n]+(?:\n[^\n]+)*/g)) {
47
+ const raw = match[0];
48
+ const start = match.index ?? 0;
49
+ if (fence.test(raw.trim())) inFence = !inFence;
50
+ if (inFence || fence.test(raw.trim()) || /^(:::|::|>|\||---)/.test(raw.trim())) continue;
51
+ const block = raw.trim().startsWith("#") ? "heading" : /^([-*+]|\d+\.)\s/.test(raw.trim()) ? "listItem" : "paragraph";
52
+ const sourceStart = start + raw.length - raw.replace(/^(#{1,6}|[-*+]|\d+\.)\s+/, "").length;
53
+ const text = demoPlainText(raw);
54
+ const paragraphId = `p-${hash(text)}`;
55
+ segments.push({ id: paragraphId, kind: "paragraph", block, start: sourceStart, end: start + raw.length, textHash: hash(text), text });
56
+ const sentencePattern = /[^.!?]+[.!?]+(?=\s|$)|[^.!?]+$/g;
57
+ for (const sentence of body.slice(sourceStart, start + raw.length).matchAll(sentencePattern)) {
58
+ const leading = sentence[0].length - sentence[0].trimStart().length;
59
+ const sStart = sourceStart + (sentence.index ?? 0) + leading;
60
+ const sText = sentence[0].trim();
61
+ if (!sText) continue;
62
+ segments.push({
63
+ id: `s-${hash(sText)}`,
64
+ kind: "sentence",
65
+ block,
66
+ parentId: paragraphId,
67
+ start: sStart,
68
+ end: sStart + sText.length,
69
+ textHash: hash(sText),
70
+ text: demoPlainText(sText),
71
+ });
72
+ }
73
+ }
74
+ return segments;
75
+ }
76
+
77
+ export function demoCheck(body: string): EditorialCheckResult {
78
+ const segments = demoSegments(body);
79
+ const sentences = segments.filter((segment) => segment.kind === "sentence");
80
+ const findings: EditorialFinding[] = [];
81
+ for (const rule of RULES) {
82
+ for (const match of body.matchAll(rule.phrase)) {
83
+ const start = match.index ?? 0;
84
+ const end = start + match[0].length;
85
+ const sentence = sentences.find((segment) => segment.start <= start && end <= segment.end);
86
+ if (!sentence) continue;
87
+ findings.push({
88
+ id: `f-${hash(`${rule.policyId}|${sentence.text}|${match[0]}`)}${hash(String(start)).slice(0, 8)}`,
89
+ policyId: rule.policyId,
90
+ policyVersion: 1,
91
+ detectorId: `${rule.policyId}-demo`,
92
+ lane: "rules",
93
+ severity: rule.severity,
94
+ status: "violation",
95
+ segmentId: sentence.id,
96
+ span: { start, end },
97
+ excerpt: match[0],
98
+ excerptText: match[0],
99
+ message: rule.message,
100
+ ...(rule.replacement ? { replacement: rule.replacement } : {}),
101
+ });
102
+ }
103
+ }
104
+ return {
105
+ schemaVersion: 1,
106
+ offsetUnit: "utf16",
107
+ document: { hash: `demo:${hash(body)}`, length: body.length },
108
+ pack: { packId: "demo", version: "1" },
109
+ segments,
110
+ protected: [],
111
+ findings: findings.sort((a, b) => a.span.start - b.span.start),
112
+ coverage: [],
113
+ lanes: { rules: { status: "ran", detectors: RULES.length }, classifier: { status: "disabled" } },
114
+ };
115
+ }
116
+
117
+ export function demoSuggest(body: string, target: { finding: EditorialFinding } | { selection: Span }): RewriteResult {
118
+ let span: Span;
119
+ if ("finding" in target) {
120
+ const sentence = demoSegments(body).find((s) => s.kind === "sentence" && s.start <= target.finding.span.start && target.finding.span.end <= s.end);
121
+ span = sentence ? { start: sentence.start, end: sentence.end } : target.finding.span;
122
+ } else {
123
+ span = target.selection;
124
+ }
125
+ const excerpt = body.slice(span.start, span.end);
126
+ const replacements = Object.entries(CANNED).find(([phrase]) => excerpt.includes(phrase))?.[1] ?? [`${demoPlainText(excerpt)} (demo rewrite)`];
127
+ return {
128
+ target: { ...("finding" in target ? { findingId: target.finding.id } : {}), span, excerpt },
129
+ model: "demo",
130
+ options: replacements.map((replacement, index) => ({
131
+ id: `o-demo-${index}`,
132
+ span,
133
+ replacement,
134
+ rationale: index === 0 ? "Says what changed, plainly." : "An alternative phrasing.",
135
+ factVerificationRequired: false,
136
+ unresolvedQuestions: [],
137
+ })),
138
+ };
139
+ }
140
+
141
+ export const demoEditorialBackend: EditorialChecksBackend = {
142
+ loadSettings: async () => ({ settings: DEFAULT_EDITORIAL_SETTINGS, exists: false }),
143
+ dismissedFindingIds: async () => new Set(),
144
+ check: async (body) => demoCheck(body),
145
+ suggest: async (body, _section, target) => demoSuggest(body, target),
146
+ recordFeedback: async () => undefined,
147
+ };
@@ -0,0 +1,24 @@
1
+ /** Runtime settings for editorial checks, stored in EditorialCheckSettings (id "settings"). */
2
+ export type EditorialSettings = {
3
+ lanes: { rules: boolean; classifier: boolean };
4
+ classifierModel: string;
5
+ rewriteModel: string;
6
+ deadlineSeconds: number;
7
+ maxInputTokens: number;
8
+ maxExamplesPerQuestion: number;
9
+ guidance: string;
10
+ publishWarning: boolean;
11
+ checkWhileTyping: boolean;
12
+ };
13
+
14
+ export const DEFAULT_EDITORIAL_SETTINGS: EditorialSettings = {
15
+ lanes: { rules: true, classifier: true },
16
+ classifierModel: "jev-latest",
17
+ rewriteModel: "gpt-6-luna",
18
+ deadlineSeconds: 20,
19
+ maxInputTokens: 500000,
20
+ maxExamplesPerQuestion: 4,
21
+ guidance: "",
22
+ publishWarning: true,
23
+ checkWhileTyping: true,
24
+ };