@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.
- package/amplify/data/resource.js +7 -0
- package/amplify/data/resource.ts +8 -0
- package/amplify/data/schema.js +8 -0
- package/amplify/data/schema.ts +9 -0
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +7 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/plugins/editorial-checks/function/handler.py +14 -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 +71 -0
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +87 -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/amplify/site-backend.js +5 -0
- package/amplify/site-backend.ts +11 -0
- package/app/globals.css +0 -175
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -6
- package/app/newsroom/[section]/page.tsx +2 -4
- package/app/newsroom/articles/page.tsx +1 -2
- package/app/newsroom/sections/[sectionId]/page.tsx +2 -6
- package/bin/papyrus-infra.mjs +11 -2
- 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/news-desk-page.tsx +3 -4
- package/components/newsroom-article-editor.tsx +138 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-ops-shell.tsx +146 -234
- 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/reference-cyclotron-priming.tsx +12 -13
- package/components/reference-cyclotron-status.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +6 -6
- package/components/reference-live-agreement.tsx +10 -10
- package/components/reference-priming.tsx +1 -1
- package/components/reference-relevance-review.tsx +1 -1
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +36 -511
- package/components/use-editorial-checks.ts +131 -0
- package/lib/cyclotron-suggestions.ts +9 -9
- 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/newsroom-board.ts +27 -2
- package/lib/newsroom-list-selection.ts +0 -3
- package/lib/newsroom-nav.ts +5 -8
- package/lib/newsroom-section-routing.ts +2 -4
- package/lib/relevance-status.ts +4 -4
- package/lib/site-brand.ts +18 -8
- package/package.json +34 -3
- package/routes.manifest.json +1 -1
- package/src/cdk-environment.mjs +44 -0
- 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
- 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: "
|
|
96
|
-
"daily decision cap": "The last
|
|
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
|
|
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
|
-
/** "
|
|
126
|
+
/** "Suggestions on" or "Suggestions off", the words the panel leads with. */
|
|
127
127
|
export function loopStateWords(loop: CyclotronLoopState): string {
|
|
128
|
-
return loop.enabled ? "
|
|
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
|
|
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: `
|
|
206
|
-
primingExamplesSentence: `${plural(provenance.primingExamples, "
|
|
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 : `
|
|
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
|
+
};
|