@anthusai/papyrus 2.0.3 → 3.1.0

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 (49) hide show
  1. package/amplify/plugins/editorial-checks/function/handler.py +11 -0
  2. package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
  3. package/amplify/plugins/editorial-checks/function/resource.js +43 -0
  4. package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
  5. package/amplify/plugins/editorial-checks/papyrus.plugin.js +50 -0
  6. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +63 -0
  7. package/amplify/plugins/editorial-checks/schema.js +94 -0
  8. package/amplify/plugins/editorial-checks/schema.ts +96 -0
  9. package/amplify/plugins/registry.js +2 -0
  10. package/amplify/plugins/registry.ts +2 -0
  11. package/components/editorial-checks-admin-panel.tsx +337 -0
  12. package/components/editorial-finding-card.tsx +162 -0
  13. package/components/editorial-findings-panel.tsx +91 -0
  14. package/components/editorial-preview-popover.tsx +103 -0
  15. package/components/markus-body-editor.tsx +213 -0
  16. package/components/markus-ir-preview.tsx +147 -6
  17. package/components/newsroom-article-editor.tsx +138 -11
  18. package/components/newsroom-board-view.tsx +6 -7
  19. package/components/newsroom-ops-shell.tsx +7 -3
  20. package/components/newsroom-organizer.css +57 -61
  21. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  22. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  23. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  24. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  25. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  26. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  27. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  28. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  29. package/components/newsroom-theme.css +39 -0
  30. package/components/newsroom-theme.tsx +33 -0
  31. package/components/topic-steering-workspace.tsx +19 -1
  32. package/components/use-editorial-checks.ts +131 -0
  33. package/lib/editorial-checks-backend.ts +16 -0
  34. package/lib/editorial-checks-client.ts +194 -0
  35. package/lib/editorial-checks-demo.ts +147 -0
  36. package/lib/editorial-checks-settings.ts +24 -0
  37. package/lib/editorial-checks.ts +178 -0
  38. package/lib/editorial-preview.ts +228 -0
  39. package/lib/newsroom-board.ts +27 -2
  40. package/lib/site-brand.ts +17 -0
  41. package/package.json +34 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  44. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  45. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  46. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  47. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  48. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  49. package/components/newsroom-newsprint-board.css +0 -7
@@ -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
+ }
@@ -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
+ };