@anthusai/papyrus 2.0.2 → 3.0.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.
- package/amplify/plugins/editorial-checks/function/handler.py +11 -0
- package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
- package/amplify/plugins/editorial-checks/function/resource.js +43 -0
- package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +50 -0
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +63 -0
- package/amplify/plugins/editorial-checks/schema.js +94 -0
- package/amplify/plugins/editorial-checks/schema.ts +96 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/components/editorial-checks-admin-panel.tsx +337 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/newsroom-article-editor.tsx +138 -11
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +57 -61
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/topic-steering-workspace.tsx +19 -1
- package/components/use-editorial-checks.ts +131 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +194 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +24 -0
- package/lib/editorial-checks.ts +178 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/site-brand.ts +17 -0
- package/package.json +34 -2
- package/routes.manifest.json +1 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -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' 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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, SparklesIcon, XIcon } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
import { Input } from "./ui/input";
|
|
7
|
+
import { Spinner } from "./ui/spinner";
|
|
8
|
+
import { describeProbability, type EditorialFinding, type RewriteResult, type Span } from "../lib/editorial-checks";
|
|
9
|
+
|
|
10
|
+
export type RewriteTarget = { finding: EditorialFinding } | { selection: Span };
|
|
11
|
+
export type ApplyRewrite = (option: { span: Span; replacement: string; expected: string }, finding: EditorialFinding | null) => void;
|
|
12
|
+
|
|
13
|
+
export type FindingActions = {
|
|
14
|
+
onSelect?: (finding: EditorialFinding) => void;
|
|
15
|
+
onDismiss: (finding: EditorialFinding, reason: string) => void;
|
|
16
|
+
onAccept: (finding: EditorialFinding) => void;
|
|
17
|
+
onSuggest: (target: RewriteTarget, note: string | null) => Promise<RewriteResult>;
|
|
18
|
+
onApply: ApplyRewrite;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SuggestionState =
|
|
22
|
+
| { state: "loading" }
|
|
23
|
+
| { state: "ready"; result: RewriteResult }
|
|
24
|
+
| { state: "error"; message: string };
|
|
25
|
+
|
|
26
|
+
/** Ask for rewrite options for a target and let the editor pick one. Shared by findings and selections. */
|
|
27
|
+
export function useRewriteOptions(actions: Pick<FindingActions, "onSuggest">) {
|
|
28
|
+
const [suggestion, setSuggestion] = useState<SuggestionState | null>(null);
|
|
29
|
+
const request = async (target: RewriteTarget, note: string | null) => {
|
|
30
|
+
setSuggestion({ state: "loading" });
|
|
31
|
+
try {
|
|
32
|
+
setSuggestion({ state: "ready", result: await actions.onSuggest(target, note) });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
setSuggestion({ state: "error", message: error instanceof Error ? error.message : "Could not get rewrite options." });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return { suggestion, request, clear: () => setSuggestion(null) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function RewriteOptions({
|
|
41
|
+
suggestion,
|
|
42
|
+
finding,
|
|
43
|
+
onApply,
|
|
44
|
+
}: {
|
|
45
|
+
suggestion: SuggestionState | null;
|
|
46
|
+
finding: EditorialFinding | null;
|
|
47
|
+
onApply: ApplyRewrite;
|
|
48
|
+
}) {
|
|
49
|
+
if (!suggestion) return null;
|
|
50
|
+
if (suggestion.state === "loading") {
|
|
51
|
+
return <p className="flex items-center gap-2 text-muted-foreground" data-editorial-rewrite-loading><Spinner /> Asking for rewrite options…</p>;
|
|
52
|
+
}
|
|
53
|
+
if (suggestion.state === "error") return <p className="text-destructive" role="alert">{suggestion.message}</p>;
|
|
54
|
+
const { result } = suggestion;
|
|
55
|
+
if (result.options.length === 0) return <p className="text-muted-foreground">No usable options this time. Try again or add a note.</p>;
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-2" data-editorial-rewrite-options>
|
|
58
|
+
<p className="text-xs text-muted-foreground" data-editorial-rewrite-target>Rewriting: “{result.target.excerpt}”</p>
|
|
59
|
+
<ol className="space-y-2">
|
|
60
|
+
{result.options.map((option, index) => (
|
|
61
|
+
<li className="space-y-1 rounded-md bg-muted/40 p-2" data-editorial-rewrite-option={index} key={option.id}>
|
|
62
|
+
<p>{option.replacement}</p>
|
|
63
|
+
<p className="text-xs text-muted-foreground">{option.rationale}</p>
|
|
64
|
+
{option.factVerificationRequired ? <p className="text-xs text-destructive">Changes a factual claim: verify before using.</p> : null}
|
|
65
|
+
{option.unresolvedQuestions.length ? (
|
|
66
|
+
<ul className="list-disc pl-4 text-xs text-muted-foreground">
|
|
67
|
+
{option.unresolvedQuestions.map((question) => <li key={question}>{question}</li>)}
|
|
68
|
+
</ul>
|
|
69
|
+
) : null}
|
|
70
|
+
<Button
|
|
71
|
+
data-editorial-use-option
|
|
72
|
+
onClick={() => onApply({ span: option.span, replacement: option.replacement, expected: result.target.excerpt }, finding)}
|
|
73
|
+
size="xs"
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
Use this
|
|
77
|
+
</Button>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ol>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One finding: what is wrong, how sure, and what the editor can do about it. */
|
|
86
|
+
export function EditorialFindingCard({
|
|
87
|
+
finding,
|
|
88
|
+
active = false,
|
|
89
|
+
note,
|
|
90
|
+
actions,
|
|
91
|
+
}: {
|
|
92
|
+
finding: EditorialFinding;
|
|
93
|
+
active?: boolean;
|
|
94
|
+
note: string;
|
|
95
|
+
actions: FindingActions;
|
|
96
|
+
}) {
|
|
97
|
+
const [dismissing, setDismissing] = useState<string | null>(null);
|
|
98
|
+
const rewrites = useRewriteOptions(actions);
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className="space-y-1 rounded-lg border border-border p-2 data-[active=true]:border-ring"
|
|
102
|
+
data-active={active}
|
|
103
|
+
data-editorial-finding-item={finding.id}
|
|
104
|
+
>
|
|
105
|
+
<button className="block w-full text-left" onClick={() => actions.onSelect?.(finding)} type="button">
|
|
106
|
+
<span className="block">{finding.message}</span>
|
|
107
|
+
{finding.excerpt ? <q className="block truncate text-muted-foreground">{finding.excerptText ?? finding.excerpt}</q> : null}
|
|
108
|
+
<span className="text-xs text-muted-foreground">
|
|
109
|
+
{finding.status === "review" ? "Needs your judgment · " : ""}
|
|
110
|
+
{describeProbability(finding) ?? (finding.lane === "rules" ? "Rule" : "")}
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
<div className="flex flex-wrap gap-1">
|
|
114
|
+
{finding.replacement !== undefined ? (
|
|
115
|
+
<Button
|
|
116
|
+
data-editorial-use-replacement={finding.replacement}
|
|
117
|
+
onClick={() => actions.onApply({ span: finding.span, replacement: finding.replacement ?? "", expected: finding.excerpt }, finding)}
|
|
118
|
+
size="xs"
|
|
119
|
+
type="button"
|
|
120
|
+
variant="outline"
|
|
121
|
+
>
|
|
122
|
+
<CheckIcon aria-hidden="true" /> {finding.replacement ? `Use “${finding.replacement}”` : "Remove"}
|
|
123
|
+
</Button>
|
|
124
|
+
) : null}
|
|
125
|
+
{finding.excerpt ? (
|
|
126
|
+
<Button data-editorial-suggest onClick={() => void rewrites.request({ finding }, note.trim() || null)} size="xs" type="button" variant="outline">
|
|
127
|
+
<SparklesIcon aria-hidden="true" /> Suggest rewrites
|
|
128
|
+
</Button>
|
|
129
|
+
) : null}
|
|
130
|
+
{finding.status === "review" ? (
|
|
131
|
+
<Button onClick={() => actions.onAccept(finding)} size="xs" type="button" variant="ghost">
|
|
132
|
+
It's a problem
|
|
133
|
+
</Button>
|
|
134
|
+
) : null}
|
|
135
|
+
<Button data-editorial-dismiss onClick={() => setDismissing("")} size="xs" type="button" variant="ghost">
|
|
136
|
+
<XIcon aria-hidden="true" /> Dismiss
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
{dismissing !== null ? (
|
|
140
|
+
<form
|
|
141
|
+
className="flex gap-1"
|
|
142
|
+
onSubmit={(event) => {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
actions.onDismiss(finding, dismissing);
|
|
145
|
+
setDismissing(null);
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<Input
|
|
149
|
+
aria-label="Why dismiss?"
|
|
150
|
+
autoFocus
|
|
151
|
+
className="h-8"
|
|
152
|
+
onChange={(event) => setDismissing(event.target.value)}
|
|
153
|
+
placeholder="Why? (optional, helps tune this policy)"
|
|
154
|
+
value={dismissing}
|
|
155
|
+
/>
|
|
156
|
+
<Button data-editorial-confirm-dismiss size="xs" type="submit">Dismiss</Button>
|
|
157
|
+
</form>
|
|
158
|
+
) : null}
|
|
159
|
+
<RewriteOptions finding={finding} onApply={actions.onApply} suggestion={rewrites.suggestion} />
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SparklesIcon } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Badge } from "./ui/badge";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { Input } from "./ui/input";
|
|
8
|
+
import { Spinner } from "./ui/spinner";
|
|
9
|
+
import { groupByPolicy, laneNotice, visibleFindings, type EditorialCheckResult, type Span } from "../lib/editorial-checks";
|
|
10
|
+
import { EditorialFindingCard, RewriteOptions, useRewriteOptions, type FindingActions } from "./editorial-finding-card";
|
|
11
|
+
|
|
12
|
+
export type CheckStatus = { state: "idle" } | { state: "checking" } | { state: "error"; message: string };
|
|
13
|
+
|
|
14
|
+
type Props = FindingActions & {
|
|
15
|
+
result: EditorialCheckResult | null;
|
|
16
|
+
status: CheckStatus;
|
|
17
|
+
dismissed: ReadonlySet<string>;
|
|
18
|
+
activeFindingId: string | null;
|
|
19
|
+
selection: Span;
|
|
20
|
+
onCheck: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const SEVERITY_LABEL = { error: "Must fix", warn: "Should fix", info: "Consider" } as const;
|
|
24
|
+
const SEVERITY_VARIANT = { error: "default", warn: "secondary", info: "muted" } as const;
|
|
25
|
+
|
|
26
|
+
export function EditorialFindingsPanel(props: Props) {
|
|
27
|
+
const { result, status, dismissed, activeFindingId, selection } = props;
|
|
28
|
+
const [note, setNote] = useState("");
|
|
29
|
+
const selectionRewrites = useRewriteOptions(props);
|
|
30
|
+
const findings = result ? visibleFindings(result.findings, dismissed) : [];
|
|
31
|
+
const groups = groupByPolicy(findings);
|
|
32
|
+
const notice = result ? laneNotice(result) : null;
|
|
33
|
+
const hasSelection = selection.end > selection.start;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section aria-label="Editorial checks" className="space-y-3 rounded-xl border border-border bg-card p-3 text-sm" data-editorial-findings>
|
|
37
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
38
|
+
<h3 className="font-medium">Editorial checks</h3>
|
|
39
|
+
{status.state === "checking" ? <Spinner aria-label="Checking" /> : null}
|
|
40
|
+
<span className="text-muted-foreground" data-editorial-findings-count>
|
|
41
|
+
{result ? `${findings.length} open` : "Not checked yet"}
|
|
42
|
+
</span>
|
|
43
|
+
<Button className="ml-auto" data-editorial-check-now disabled={status.state === "checking"} onClick={props.onCheck} size="xs" type="button" variant="outline">
|
|
44
|
+
Check now
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{status.state === "error" ? <p className="text-destructive" role="alert">{status.message}</p> : null}
|
|
49
|
+
{notice ? <p className="text-muted-foreground" data-editorial-lane-notice>{notice}</p> : null}
|
|
50
|
+
|
|
51
|
+
<div className="flex flex-wrap items-center gap-2" data-editorial-rewrite-tools>
|
|
52
|
+
<Input
|
|
53
|
+
aria-label="Note for rewrites"
|
|
54
|
+
className="h-8 max-w-xs flex-1"
|
|
55
|
+
onChange={(event) => setNote(event.target.value)}
|
|
56
|
+
placeholder="Note for rewrites, e.g. shorter"
|
|
57
|
+
value={note}
|
|
58
|
+
/>
|
|
59
|
+
<Button
|
|
60
|
+
disabled={!hasSelection}
|
|
61
|
+
onClick={() => void selectionRewrites.request({ selection }, note.trim() || null)}
|
|
62
|
+
size="xs"
|
|
63
|
+
title={hasSelection ? "Get rewrite options for the selected text" : "Select text in the body first"}
|
|
64
|
+
type="button"
|
|
65
|
+
variant="outline"
|
|
66
|
+
>
|
|
67
|
+
<SparklesIcon aria-hidden="true" /> Rewrite selection
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
<RewriteOptions finding={null} onApply={props.onApply} suggestion={selectionRewrites.suggestion} />
|
|
71
|
+
|
|
72
|
+
{result && findings.length === 0 ? <p className="text-muted-foreground">No open findings.</p> : null}
|
|
73
|
+
{groups.map((group) => (
|
|
74
|
+
<div className="space-y-2" data-editorial-policy={group.policyId} key={group.policyId}>
|
|
75
|
+
<div className="flex items-center gap-2">
|
|
76
|
+
<Badge variant={SEVERITY_VARIANT[group.severity]}>{SEVERITY_LABEL[group.severity]}</Badge>
|
|
77
|
+
<strong>{group.policyId}</strong>
|
|
78
|
+
<span className="text-muted-foreground">{group.findings.length}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<ul className="space-y-2">
|
|
81
|
+
{group.findings.map((finding) => (
|
|
82
|
+
<li key={finding.id}>
|
|
83
|
+
<EditorialFindingCard actions={props} active={finding.id === activeFindingId} finding={finding} note={note} />
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</section>
|
|
90
|
+
);
|
|
91
|
+
}
|