@anthusai/papyrus 2.0.0-next.1 → 2.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +6 -7
- 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/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 +19 -1
- 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/relevance-status.ts +4 -4
- 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
|
@@ -12,10 +12,10 @@ import { formatSuggestionTime } from "./reference-cyclotron-suggestion";
|
|
|
12
12
|
|
|
13
13
|
function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasurement }) {
|
|
14
14
|
const figures: Array<[string, string]> = [
|
|
15
|
-
["
|
|
16
|
-
["
|
|
17
|
-
["
|
|
18
|
-
["
|
|
15
|
+
["Matches editors", formatPercentage(measurement.agreement)],
|
|
16
|
+
["Suggested includes editors kept", formatPercentage(measurement.precision)],
|
|
17
|
+
["Editor includes suggested", formatPercentage(measurement.recall)],
|
|
18
|
+
["Decisions compared", String(measurement.comparisons)],
|
|
19
19
|
];
|
|
20
20
|
return (
|
|
21
21
|
<dl className="flex flex-wrap gap-x-6 gap-y-1">
|
|
@@ -30,19 +30,19 @@ function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasur
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Live agreement at the top of References: how often the
|
|
33
|
+
* Live agreement at the top of References: how often the
|
|
34
34
|
* suggestions matched what editors then decided. Hidden without a payload.
|
|
35
35
|
*/
|
|
36
36
|
export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreement | null }) {
|
|
37
37
|
const state = agreementStripState(agreement);
|
|
38
38
|
if (state.kind === "hidden") return null;
|
|
39
39
|
return (
|
|
40
|
-
<section aria-label="
|
|
41
|
-
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
40
|
+
<section aria-label="Suggestion accuracy" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
|
|
41
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion accuracy</h3>
|
|
42
42
|
{state.loop ? (
|
|
43
43
|
<p className="text-sm text-foreground" data-reference-live-agreement-loop>
|
|
44
44
|
<span className="font-semibold">{loopStateWords(state.loop)}</span>
|
|
45
|
-
{" · "}Last
|
|
45
|
+
{" · "}Last checked {formatSuggestionTime(state.loop.lastSweepAt)}
|
|
46
46
|
{" · "}{dailyCapWords(state.loop)}
|
|
47
47
|
</p>
|
|
48
48
|
) : null}
|
|
@@ -51,11 +51,11 @@ export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreeme
|
|
|
51
51
|
) : null}
|
|
52
52
|
{state.kind === "not_enough" ? (
|
|
53
53
|
<p className="text-sm text-foreground" data-reference-live-agreement-not-enough>
|
|
54
|
-
Not enough
|
|
54
|
+
Not enough decisions yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
|
|
55
55
|
</p>
|
|
56
56
|
) : <MeasurementFigures measurement={state.measurement} />}
|
|
57
57
|
<p className="text-xs text-foreground">
|
|
58
|
-
Suggestions compared with the decisions editors made afterwards.
|
|
58
|
+
Suggestions compared with the decisions editors made afterwards. Updated {formatSuggestionTime(agreement?.computedAt ?? null)}.
|
|
59
59
|
</p>
|
|
60
60
|
</section>
|
|
61
61
|
);
|
|
@@ -10,7 +10,7 @@ export function ReferencePriming(props: PrimingPanelProps) {
|
|
|
10
10
|
return (
|
|
11
11
|
<section className="shrink-0 space-y-3" aria-label="Representative reference examples">
|
|
12
12
|
<Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
|
|
13
|
-
{open ? "Hide example
|
|
13
|
+
{open ? "Hide example references" : "Add example references (optional)"}
|
|
14
14
|
</Button>
|
|
15
15
|
{open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
|
|
16
16
|
</section>
|
|
@@ -67,7 +67,7 @@ export function ReferenceRelevanceReview({
|
|
|
67
67
|
onChange={(event) => setShareable(event.target.checked)}
|
|
68
68
|
type="checkbox"
|
|
69
69
|
/>
|
|
70
|
-
My explanation may be quoted publicly (
|
|
70
|
+
My explanation may be quoted publicly (on a public example page)
|
|
71
71
|
</label>
|
|
72
72
|
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
|
|
73
73
|
</section>
|
|
@@ -98,7 +98,7 @@ export function SectionPicker({
|
|
|
98
98
|
</label>
|
|
99
99
|
);
|
|
100
100
|
})}
|
|
101
|
-
<p className="text-xs text-foreground">The Section you choose is
|
|
101
|
+
<p className="text-xs text-foreground">The Section you choose is saved with your decision.</p>
|
|
102
102
|
</fieldset>
|
|
103
103
|
);
|
|
104
104
|
}
|
|
@@ -13,6 +13,7 @@ import { usePathname, useRouter } from "next/navigation";
|
|
|
13
13
|
import type { ReactNode, RefObject } from "react";
|
|
14
14
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
15
15
|
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
16
|
+
import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
|
|
16
17
|
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
17
18
|
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
18
19
|
import { generateClient } from "aws-amplify/data";
|
|
@@ -246,7 +247,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
246
247
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
247
248
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
248
249
|
type UserRoleAction = "grant" | "revoke";
|
|
249
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups";
|
|
250
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks";
|
|
250
251
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
251
252
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
252
253
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -4317,6 +4318,17 @@ function AdministrationDeskView({
|
|
|
4317
4318
|
<strong>Agent delegations</strong>
|
|
4318
4319
|
<span>Let an agent curate References for you</span>
|
|
4319
4320
|
</Link>
|
|
4321
|
+
{editorialChecksPluginEnabled() ? (
|
|
4322
|
+
<Link
|
|
4323
|
+
href={administrationPanelHref("editorial-checks", isDemo)}
|
|
4324
|
+
data-news-desk-admin-nav="editorial-checks"
|
|
4325
|
+
data-active={administrationPanel === "editorial-checks" || undefined}
|
|
4326
|
+
onClick={() => onPanelChange("editorial-checks")}
|
|
4327
|
+
>
|
|
4328
|
+
<strong>Editorial checks</strong>
|
|
4329
|
+
<span>Policies the article editor checks drafts against</span>
|
|
4330
|
+
</Link>
|
|
4331
|
+
) : null}
|
|
4320
4332
|
{signupPluginEnabled() ? (
|
|
4321
4333
|
<Link
|
|
4322
4334
|
href={administrationPanelHref("signups", isDemo)}
|
|
@@ -4331,6 +4343,7 @@ function AdministrationDeskView({
|
|
|
4331
4343
|
</nav>}
|
|
4332
4344
|
<div className="news-desk-settings-panel">
|
|
4333
4345
|
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4346
|
+
{administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
|
|
4334
4347
|
{administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
|
|
4335
4348
|
{administrationPanel === "users" ? (
|
|
4336
4349
|
<AdministrationUsersPanel
|
|
@@ -12794,9 +12807,14 @@ function signupPluginEnabled(): boolean {
|
|
|
12794
12807
|
return pluginSettings(amplifyOutputs as PluginOutputs, "signup") !== null;
|
|
12795
12808
|
}
|
|
12796
12809
|
|
|
12810
|
+
function editorialChecksPluginEnabled(): boolean {
|
|
12811
|
+
return pluginSettings(amplifyOutputs as PluginOutputs, "editorial-checks") !== null;
|
|
12812
|
+
}
|
|
12813
|
+
|
|
12797
12814
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
12798
12815
|
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
|
|
12799
12816
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
12817
|
+
if (value === "editorial-checks" && editorialChecksPluginEnabled()) return value;
|
|
12800
12818
|
return "users";
|
|
12801
12819
|
}
|
|
12802
12820
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
feedbackFor,
|
|
6
|
+
publishWarnings,
|
|
7
|
+
type EditorialCheckResult,
|
|
8
|
+
type EditorialFinding,
|
|
9
|
+
type FeedbackDecision,
|
|
10
|
+
type RewriteResult,
|
|
11
|
+
type Span,
|
|
12
|
+
} from "../lib/editorial-checks";
|
|
13
|
+
import type { EditorialChecksBackend } from "../lib/editorial-checks-backend";
|
|
14
|
+
import { DEFAULT_EDITORIAL_SETTINGS, type EditorialSettings } from "../lib/editorial-checks-settings";
|
|
15
|
+
import type { CheckStatus } from "./editorial-findings-panel";
|
|
16
|
+
|
|
17
|
+
const CHECK_PAUSE_MS = 2000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Editorial checks for one article body. A result only applies to the exact body
|
|
21
|
+
* it was computed for: if the editor typed while the check ran, the result is
|
|
22
|
+
* dropped and the next pause checks again.
|
|
23
|
+
*/
|
|
24
|
+
export function useEditorialChecks({
|
|
25
|
+
backend,
|
|
26
|
+
body,
|
|
27
|
+
section,
|
|
28
|
+
itemId,
|
|
29
|
+
}: {
|
|
30
|
+
/** null turns editorial checks off. */
|
|
31
|
+
backend: EditorialChecksBackend | null;
|
|
32
|
+
body: string | null;
|
|
33
|
+
section: string;
|
|
34
|
+
itemId: string | null;
|
|
35
|
+
}) {
|
|
36
|
+
const enabled = backend !== null;
|
|
37
|
+
const [settings, setSettings] = useState<EditorialSettings>(DEFAULT_EDITORIAL_SETTINGS);
|
|
38
|
+
const [result, setResult] = useState<EditorialCheckResult | null>(null);
|
|
39
|
+
const [checkedBody, setCheckedBody] = useState<string | null>(null);
|
|
40
|
+
const [status, setStatus] = useState<CheckStatus>({ state: "idle" });
|
|
41
|
+
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
|
42
|
+
const [activeFindingId, setActiveFindingId] = useState<string | null>(null);
|
|
43
|
+
const [selection, setSelection] = useState<Span>({ start: 0, end: 0 });
|
|
44
|
+
const latestBody = useRef(body);
|
|
45
|
+
latestBody.current = body;
|
|
46
|
+
const sequence = useRef(0);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!enabled) return;
|
|
50
|
+
backend?.loadSettings().then(({ settings: loaded }) => setSettings(loaded)).catch(() => undefined);
|
|
51
|
+
}, [backend, enabled]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!enabled || !itemId) return;
|
|
55
|
+
backend?.dismissedFindingIds(itemId).then(setDismissed).catch(() => undefined);
|
|
56
|
+
}, [backend, enabled, itemId]);
|
|
57
|
+
|
|
58
|
+
const check = useCallback(async () => {
|
|
59
|
+
const text = latestBody.current;
|
|
60
|
+
if (!backend || text === null || !text.trim()) return;
|
|
61
|
+
const run = ++sequence.current;
|
|
62
|
+
setStatus({ state: "checking" });
|
|
63
|
+
try {
|
|
64
|
+
const checked = await backend.check(text, section.trim() || null);
|
|
65
|
+
if (run !== sequence.current) return;
|
|
66
|
+
if (latestBody.current !== text) {
|
|
67
|
+
setStatus({ state: "idle" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setResult(checked);
|
|
71
|
+
setCheckedBody(text);
|
|
72
|
+
setStatus({ state: "idle" });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (run === sequence.current) setStatus({ state: "error", message: error instanceof Error ? error.message : "The check failed." });
|
|
75
|
+
}
|
|
76
|
+
}, [backend, section]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!enabled || !settings.checkWhileTyping || body === null || body === checkedBody) return;
|
|
80
|
+
const timer = window.setTimeout(() => void check(), CHECK_PAUSE_MS);
|
|
81
|
+
return () => window.clearTimeout(timer);
|
|
82
|
+
}, [body, check, checkedBody, enabled, settings.checkWhileTyping]);
|
|
83
|
+
|
|
84
|
+
const record = useCallback(
|
|
85
|
+
(finding: EditorialFinding, decision: FeedbackDecision, reason: string | null = null) => {
|
|
86
|
+
if (!result || checkedBody === null) return;
|
|
87
|
+
void backend?.recordFeedback(feedbackFor(result, checkedBody, finding, decision, itemId, reason)).catch(() => undefined);
|
|
88
|
+
},
|
|
89
|
+
[backend, checkedBody, itemId, result],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const dismiss = useCallback(
|
|
93
|
+
(finding: EditorialFinding, reason: string) => {
|
|
94
|
+
record(finding, "dismissed", reason);
|
|
95
|
+
setDismissed((current) => new Set(current).add(finding.id));
|
|
96
|
+
},
|
|
97
|
+
[record],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const suggest = useCallback(
|
|
101
|
+
(target: { finding: EditorialFinding } | { selection: Span }, note: string | null): Promise<RewriteResult> =>
|
|
102
|
+
backend
|
|
103
|
+
? backend.suggest(latestBody.current ?? "", section.trim() || null, target, note)
|
|
104
|
+
: Promise.reject(new Error("Editorial checks are off.")),
|
|
105
|
+
[backend, section],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const current = result && checkedBody === body;
|
|
109
|
+
return {
|
|
110
|
+
enabled,
|
|
111
|
+
settings,
|
|
112
|
+
result,
|
|
113
|
+
/** Findings whose spans still match the body on screen. */
|
|
114
|
+
inlineResult: current ? result : null,
|
|
115
|
+
status,
|
|
116
|
+
dismissed,
|
|
117
|
+
activeFindingId,
|
|
118
|
+
setActiveFindingId,
|
|
119
|
+
selection,
|
|
120
|
+
setSelection,
|
|
121
|
+
check,
|
|
122
|
+
suggest,
|
|
123
|
+
dismiss,
|
|
124
|
+
accept: (finding: EditorialFinding) => record(finding, "accepted"),
|
|
125
|
+
fixed: (finding: EditorialFinding) => {
|
|
126
|
+
record(finding, "fixed");
|
|
127
|
+
setDismissed((current) => new Set(current).add(finding.id));
|
|
128
|
+
},
|
|
129
|
+
openWarnings: enabled && settings.publishWarning && result ? publishWarnings(result.findings, dismissed) : [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -92,13 +92,13 @@ export function latestShadowAgreement(payloads: Array<{ json: unknown; updatedAt
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const STOP_REASON_WORDS: Record<string, string> = {
|
|
95
|
-
disabled: "
|
|
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
|
+
};
|