@anthusai/papyrus 2.0.0-next.73 → 2.0.0-next.75
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 +69 -0
- package/amplify/data/resource.ts +73 -0
- package/amplify/data/schema.js +68 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +25 -0
- package/amplify/functions/automation-view/schema.ts +27 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/plugins/reader-steering/config.js +3 -1
- package/amplify/plugins/reader-steering/config.ts +11 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/newsroom-ops-overview.tsx +18 -0
- package/components/notification-center.tsx +262 -0
- package/components/topic-steering-workspace.tsx +17 -3
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { Badge } from "./ui/badge";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
8
|
+
import {
|
|
9
|
+
cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
|
|
10
|
+
steeringText, subjectHref, summarize, summaryText,
|
|
11
|
+
type AnswerInput, type CyclotronEntry, type ReviewRequest,
|
|
12
|
+
} from "../lib/review-requests";
|
|
13
|
+
import type { ReferenceRecord } from "../lib/category-repository";
|
|
14
|
+
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
15
|
+
import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
16
|
+
|
|
17
|
+
export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
18
|
+
|
|
19
|
+
type ViewProps = {
|
|
20
|
+
loadState: NotificationLoadState;
|
|
21
|
+
requests: ReviewRequest[];
|
|
22
|
+
readIds: ReadonlySet<string>;
|
|
23
|
+
held: CyclotronEntry[];
|
|
24
|
+
autoDecided: CyclotronEntry[];
|
|
25
|
+
demo?: boolean;
|
|
26
|
+
busyId?: string | null;
|
|
27
|
+
error?: string | null;
|
|
28
|
+
onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
|
|
29
|
+
onMarkRead?: (requestId: string) => void;
|
|
30
|
+
onMarkAllRead?: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
|
|
34
|
+
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean;
|
|
35
|
+
onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
|
|
36
|
+
}) {
|
|
37
|
+
const [note, setNote] = useState("");
|
|
38
|
+
const href = subjectHref(request.subject, demo);
|
|
39
|
+
const open = request.state === "open";
|
|
40
|
+
const steering = steeringText(request);
|
|
41
|
+
const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
|
|
42
|
+
return (
|
|
43
|
+
<li className="rounded-lg border p-3 space-y-2" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
|
|
44
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
45
|
+
<Badge variant="outline"><span aria-hidden="true">{modeMark(request.mode)} </span>{modeLabel(request.mode)}</Badge>
|
|
46
|
+
{unread ? <Badge variant="default"><span aria-hidden="true">● </span>New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
|
|
47
|
+
<strong className="text-sm">{request.reason}</strong>
|
|
48
|
+
</div>
|
|
49
|
+
<p className="text-xs text-muted-foreground">
|
|
50
|
+
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
51
|
+
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{request.subject.type.replace("_", " ")} {request.subject.id}</Link> : <span>{request.subject.type} {request.subject.id}</span>}
|
|
52
|
+
</p>
|
|
53
|
+
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
54
|
+
{request.links.length ? (
|
|
55
|
+
<ul className="text-xs space-y-0.5" aria-label="Items in this request">
|
|
56
|
+
{request.links.slice(0, 10).map((link, index) => {
|
|
57
|
+
const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
|
|
58
|
+
return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
|
|
59
|
+
})}
|
|
60
|
+
{request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
|
|
61
|
+
</ul>
|
|
62
|
+
) : null}
|
|
63
|
+
<p className="text-sm" data-review-next>{nextText(request)}</p>
|
|
64
|
+
{steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
|
|
65
|
+
{open ? (
|
|
66
|
+
<div className="space-y-2" data-review-controls>
|
|
67
|
+
<label className="block text-xs text-muted-foreground">
|
|
68
|
+
Steering note (optional)
|
|
69
|
+
<textarea
|
|
70
|
+
className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
|
|
71
|
+
disabled={busy}
|
|
72
|
+
onChange={(event) => setNote(event.target.value)}
|
|
73
|
+
placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
|
|
74
|
+
rows={2}
|
|
75
|
+
value={note}
|
|
76
|
+
/>
|
|
77
|
+
</label>
|
|
78
|
+
<div className="flex flex-wrap gap-2">
|
|
79
|
+
{request.mode === "flag" ? (
|
|
80
|
+
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Acknowledge</Button>
|
|
81
|
+
) : (
|
|
82
|
+
<>
|
|
83
|
+
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
|
|
84
|
+
<Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
<Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
|
|
88
|
+
<Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
|
|
89
|
+
{unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
) : unread ? (
|
|
93
|
+
<Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
|
|
94
|
+
) : null}
|
|
95
|
+
</li>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
|
|
100
|
+
const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
|
|
101
|
+
return (
|
|
102
|
+
<li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
|
|
103
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
104
|
+
<Badge variant="outline"><span aria-hidden="true">{held ? "◔" : "✓"} </span>{held ? "Held for review" : "Decided by the system"}</Badge>
|
|
105
|
+
<strong className="text-sm">{entry.title}</strong>
|
|
106
|
+
</div>
|
|
107
|
+
<p className="text-xs text-muted-foreground">
|
|
108
|
+
Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
|
|
109
|
+
{entry.reviewedByPerson ? " · reviewed by a person" : ""}
|
|
110
|
+
</p>
|
|
111
|
+
{held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
|
|
112
|
+
<Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
|
|
113
|
+
</li>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Section({ id, title, count, children, empty }: { id: string; title: string; count: number; children: React.ReactNode; empty: string }) {
|
|
118
|
+
return (
|
|
119
|
+
<section aria-labelledby={`notification-${id}`} className="space-y-2" data-notification-section={id}>
|
|
120
|
+
<h2 className="text-sm font-medium" id={`notification-${id}`}>{title} ({count})</h2>
|
|
121
|
+
{count ? children : <p className="text-sm text-muted-foreground">{empty}</p>}
|
|
122
|
+
</section>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
|
|
127
|
+
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
128
|
+
const sections = useMemo(() => sectionReviews(requests), [requests]);
|
|
129
|
+
const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
|
|
130
|
+
const summary = summarize(sections, held, autoDecided, unreadCount);
|
|
131
|
+
const entry = (request: ReviewRequest) => (
|
|
132
|
+
<Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
133
|
+
);
|
|
134
|
+
if (loadState === "loading") return <div role="status" data-notification-state="loading"><strong>Loading the Notification Center</strong></div>;
|
|
135
|
+
if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
|
|
136
|
+
if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. Nothing waiting on a person has changed.</p></div>;
|
|
137
|
+
return (
|
|
138
|
+
<div className="space-y-6" data-notification-center>
|
|
139
|
+
<header className="space-y-1">
|
|
140
|
+
<h1 className="text-lg font-semibold">Notification Center</h1>
|
|
141
|
+
<p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
|
|
142
|
+
<p className="text-xs text-muted-foreground">
|
|
143
|
+
Look, or steer. Anything you ignore keeps going: soft reviews proceed at their deadline and flags never wait. Only a hard gate waits for you.
|
|
144
|
+
</p>
|
|
145
|
+
<p className="text-xs text-muted-foreground" data-notification-unread>
|
|
146
|
+
{unreadCount ? `${unreadCount} unread` : "Nothing unread"}
|
|
147
|
+
{unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
|
|
148
|
+
</p>
|
|
149
|
+
{error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
|
|
150
|
+
</header>
|
|
151
|
+
|
|
152
|
+
<Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
|
|
153
|
+
<ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
|
|
154
|
+
</Section>
|
|
155
|
+
<Section count={sections.softReviews.length} empty="No soft reviews are running." id="soft-reviews" title="Proceeding soon: soft reviews, soonest deadline first">
|
|
156
|
+
<ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
|
|
157
|
+
</Section>
|
|
158
|
+
<Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
|
|
159
|
+
<ul className="space-y-2">{held.slice(0, 10).map((item) => <CyclotronLine entry={item} held key={item.lineageId} />)}</ul>
|
|
160
|
+
{held.length > 10 ? <p className="text-sm">and {held.length - 10} more. <Link href={getNewsroomNavHref("/newsroom/references", demo)}>See all in References</Link></p> : null}
|
|
161
|
+
</Section>
|
|
162
|
+
<Section count={sections.flags.length} empty="No flags." id="flags" title="Flags: for your information">
|
|
163
|
+
<ul className="space-y-2">{sections.flags.map(entry)}</ul>
|
|
164
|
+
</Section>
|
|
165
|
+
<Section count={sections.decided.length} empty="Nothing answered or proceeded yet." id="decided" title="Answered, and what became of it">
|
|
166
|
+
<ul className="space-y-2">{sections.decided.slice(0, 20).map(entry)}</ul>
|
|
167
|
+
</Section>
|
|
168
|
+
<details data-notification-section="auto-decided">
|
|
169
|
+
<summary className="cursor-pointer text-sm font-medium">Recently decided by the system ({autoDecided.length})</summary>
|
|
170
|
+
<div className="mt-2 space-y-2">
|
|
171
|
+
<p className="text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
|
|
172
|
+
{autoDecided.length ? <ul className="space-y-2">{autoDecided.map((item) => <CyclotronLine entry={item} held={false} key={item.lineageId} />)}</ul> : <p className="text-sm text-muted-foreground">No automatic decisions yet.</p>}
|
|
173
|
+
</div>
|
|
174
|
+
</details>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type ContainerProps = {
|
|
180
|
+
references: ReferenceRecord[];
|
|
181
|
+
decisions: ReadonlyMap<string, RelevanceDecisionView>;
|
|
182
|
+
humanCuratedReferenceLineageIds: ReadonlySet<string>;
|
|
183
|
+
actorLabel: string;
|
|
184
|
+
demo: boolean;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const REFRESH_MILLISECONDS = 60_000;
|
|
188
|
+
|
|
189
|
+
export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
|
|
190
|
+
const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
|
|
191
|
+
const [requests, setRequests] = useState<ReviewRequest[]>([]);
|
|
192
|
+
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
|
193
|
+
const [userKey, setUserKey] = useState<string | null>(null);
|
|
194
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
195
|
+
const [error, setError] = useState<string | null>(null);
|
|
196
|
+
|
|
197
|
+
const refresh = useCallback(async () => {
|
|
198
|
+
if (demo) {
|
|
199
|
+
setRequests(FIXTURE_REVIEW_REQUESTS);
|
|
200
|
+
setReadIds(new Set(["rr-flag-1"]));
|
|
201
|
+
setLoadState("ready");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const { loadReviews } = await import("../lib/review-client");
|
|
206
|
+
const loaded = await loadReviews();
|
|
207
|
+
if (loaded.state !== "ready") {
|
|
208
|
+
setLoadState(loaded.state);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
setRequests(loaded.requests);
|
|
212
|
+
setReadIds(new Set(loaded.readIds));
|
|
213
|
+
setUserKey(loaded.userKey);
|
|
214
|
+
setLoadState("ready");
|
|
215
|
+
} catch {
|
|
216
|
+
setLoadState("unavailable");
|
|
217
|
+
}
|
|
218
|
+
}, [demo]);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
void refresh();
|
|
222
|
+
const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
|
|
223
|
+
return () => window.clearInterval(timer);
|
|
224
|
+
}, [refresh]);
|
|
225
|
+
|
|
226
|
+
const held = useMemo(() => cyclotronHeldForReview(references, decisions, demo), [references, decisions, demo]);
|
|
227
|
+
const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
|
|
228
|
+
|
|
229
|
+
const markRead = useCallback(async (requestId: string) => {
|
|
230
|
+
setReadIds((current) => new Set(current).add(requestId));
|
|
231
|
+
if (demo || !userKey) return;
|
|
232
|
+
const { markReviewRead } = await import("../lib/review-client");
|
|
233
|
+
await markReviewRead(userKey, requestId).catch(() => undefined);
|
|
234
|
+
}, [demo, userKey]);
|
|
235
|
+
|
|
236
|
+
const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
|
|
237
|
+
setError(null);
|
|
238
|
+
setBusyId(request.id);
|
|
239
|
+
try {
|
|
240
|
+
if (demo) throw new Error("This is a demo: answers are not saved.");
|
|
241
|
+
const { answerReviewRequest } = await import("../lib/review-client");
|
|
242
|
+
const saved = await answerReviewRequest(request, input, actorLabel);
|
|
243
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
244
|
+
await markRead(request.id);
|
|
245
|
+
} catch (failure) {
|
|
246
|
+
setError(failure instanceof Error ? failure.message : "Could not save the answer.");
|
|
247
|
+
} finally {
|
|
248
|
+
setBusyId(null);
|
|
249
|
+
}
|
|
250
|
+
}, [actorLabel, demo, markRead]);
|
|
251
|
+
|
|
252
|
+
const markAllRead = useCallback(() => {
|
|
253
|
+
for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
|
|
254
|
+
}, [markRead, readIds, requests]);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<NotificationCenterView
|
|
258
|
+
autoDecided={autoDecided} busyId={busyId} demo={demo} error={error} held={held} loadState={loadState}
|
|
259
|
+
onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -14,6 +14,7 @@ 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
16
|
import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
|
|
17
|
+
import { AutomationPanel } from "./automation-panel";
|
|
17
18
|
import { MonitoringPanel } from "./monitoring-panel";
|
|
18
19
|
import { ModerationPanel } from "./moderation-panel";
|
|
19
20
|
import { NewsletterPanel } from "./newsletter-panel";
|
|
@@ -251,7 +252,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
251
252
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
252
253
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
253
254
|
type UserRoleAction = "grant" | "revoke";
|
|
254
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
|
|
255
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
|
|
255
256
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
256
257
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
257
258
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -3003,7 +3004,7 @@ function NewsDeskDashboard({
|
|
|
3003
3004
|
return entries;
|
|
3004
3005
|
}, [summaryStatus, tabCounts]);
|
|
3005
3006
|
const opsPageTitle = activeTab === "overview"
|
|
3006
|
-
? "
|
|
3007
|
+
? "Notification Center"
|
|
3007
3008
|
: activeTab === "administration" && administrationPanel === "sections"
|
|
3008
3009
|
? "Sections"
|
|
3009
3010
|
: formatDeskSectionHeadline(activeTab);
|
|
@@ -3061,8 +3062,11 @@ function NewsDeskDashboard({
|
|
|
3061
3062
|
|
|
3062
3063
|
{activeTab === "overview" ? (
|
|
3063
3064
|
<NewsroomOpsOverview
|
|
3065
|
+
actorLabel={authState.label}
|
|
3064
3066
|
assignments={assignments}
|
|
3067
|
+
decisions={relevanceDecisions}
|
|
3065
3068
|
demo={Boolean(dashboard.isDemo)}
|
|
3069
|
+
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3066
3070
|
messages={messages}
|
|
3067
3071
|
newsroomSections={newsroomSections}
|
|
3068
3072
|
references={references}
|
|
@@ -4315,6 +4319,15 @@ function AdministrationDeskView({
|
|
|
4315
4319
|
<strong>Procedures</strong>
|
|
4316
4320
|
<span>Procedure registry and versioned Tactus code</span>
|
|
4317
4321
|
</Link>
|
|
4322
|
+
<Link
|
|
4323
|
+
href={administrationPanelHref("automations", isDemo)}
|
|
4324
|
+
data-news-desk-admin-nav="automations"
|
|
4325
|
+
data-active={administrationPanel === "automations" || undefined}
|
|
4326
|
+
onClick={() => onPanelChange("automations")}
|
|
4327
|
+
>
|
|
4328
|
+
<strong>Automation</strong>
|
|
4329
|
+
<span>Procedures that start on their own</span>
|
|
4330
|
+
</Link>
|
|
4318
4331
|
<Link
|
|
4319
4332
|
href={administrationPanelHref("delegations", isDemo)}
|
|
4320
4333
|
data-news-desk-admin-nav="delegations"
|
|
@@ -4381,6 +4394,7 @@ function AdministrationDeskView({
|
|
|
4381
4394
|
{administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
4382
4395
|
{administrationPanel === "moderation" ? <ModerationPanel /> : null}
|
|
4383
4396
|
{administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
|
|
4397
|
+
{administrationPanel === "automations" ? <AutomationPanel isDemo={isDemo} /> : null}
|
|
4384
4398
|
{administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
4385
4399
|
{administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
|
|
4386
4400
|
{administrationPanel === "users" ? (
|
|
@@ -12855,7 +12869,7 @@ function readerPluginEnabled(): boolean {
|
|
|
12855
12869
|
}
|
|
12856
12870
|
|
|
12857
12871
|
function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
|
|
12858
|
-
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
|
|
12872
|
+
if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
|
|
12859
12873
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
12860
12874
|
if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
|
|
12861
12875
|
if (value === "moderation" && readerPluginEnabled()) return value;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { AutomationResult, RunNowResult } from "./automation-overview";
|
|
5
|
+
|
|
6
|
+
type GraphQLResult = { data?: { automationOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the automations through the site's own authenticated API (user pool, editor or admin).
|
|
10
|
+
* The browser holds no table name, key or secret: the function behind the query does the reads.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadAutomationOverview(): Promise<AutomationResult> {
|
|
13
|
+
const client = generateClient({ authMode: "userPool" });
|
|
14
|
+
let result: GraphQLResult;
|
|
15
|
+
try {
|
|
16
|
+
result = (await client.graphql({
|
|
17
|
+
query: `query AutomationOverview($automationId: String) { automationOverview(automationId: $automationId) }`,
|
|
18
|
+
variables: {},
|
|
19
|
+
})) as GraphQLResult;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Amplify throws the response when AppSync returns errors.
|
|
22
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
23
|
+
}
|
|
24
|
+
const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
|
|
25
|
+
if (refused) return { error: "forbidden" };
|
|
26
|
+
if (result.errors?.length || result.data?.automationOverview === undefined || result.data.automationOverview === null) return { state: "unavailable" };
|
|
27
|
+
const raw = result.data.automationOverview;
|
|
28
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as AutomationResult;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type RunGraphQLResult = { data?: { runAutomation?: unknown }; errors?: { message?: string; errorType?: string }[] };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Starts a run of the automation's procedure now, as the signed-in editor or admin. The function behind the
|
|
35
|
+
* mutation checks the groups again, refuses a disabled automation or one at its limit, and writes the row that
|
|
36
|
+
* links the run to the automation's history. `input` is sent as JSON text; null uses the record's own input.
|
|
37
|
+
*/
|
|
38
|
+
export async function runAutomationNow(automationId: string, input: Record<string, unknown> | null): Promise<RunNowResult> {
|
|
39
|
+
const client = generateClient({ authMode: "userPool" });
|
|
40
|
+
let result: RunGraphQLResult;
|
|
41
|
+
try {
|
|
42
|
+
result = (await client.graphql({
|
|
43
|
+
query: `mutation RunAutomation($automationId: String!, $input: AWSJSON) { runAutomation(automationId: $automationId, input: $input) }`,
|
|
44
|
+
variables: { automationId, input: input === null ? null : JSON.stringify(input) },
|
|
45
|
+
})) as RunGraphQLResult;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as RunGraphQLResult;
|
|
48
|
+
}
|
|
49
|
+
const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
|
|
50
|
+
if (refused) return { error: "forbidden" };
|
|
51
|
+
if (result.errors?.length || result.data?.runAutomation === undefined || result.data.runAutomation === null) return { state: "unavailable" };
|
|
52
|
+
const raw = result.data.runAutomation;
|
|
53
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as RunNowResult;
|
|
54
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AutomationOverview, AutomationRun } from "./automation-overview";
|
|
2
|
+
|
|
3
|
+
const NOT_RECORDED = "not recorded for this run";
|
|
4
|
+
const ESTIMATED = "estimated from the model usage reported by the run";
|
|
5
|
+
|
|
6
|
+
function run(fields: Partial<AutomationRun> & Pick<AutomationRun, "status" | "startedAt">): AutomationRun {
|
|
7
|
+
return {
|
|
8
|
+
origin: "monitor", requestedBy: null, reason: null, sourceId: null, referenceId: null, triggerStatus: fields.status, runId: null,
|
|
9
|
+
finishedAt: null, durationSeconds: null, error: null, runRecord: "none", storyId: null, storyVersionId: null, storyTitle: null,
|
|
10
|
+
costUsd: null, costNote: NOT_RECORDED, budgetUsd: null, overBudget: false, usage: null, ...fields,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A realistic overview for tests and the `?demo=1` newsroom: one live monitor automation and one stored schedule. */
|
|
15
|
+
export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
|
|
16
|
+
state: "ready",
|
|
17
|
+
asOf: "2026-10-23T12:00:00Z",
|
|
18
|
+
monitoring: "ready",
|
|
19
|
+
automations: [
|
|
20
|
+
{
|
|
21
|
+
id: "example.ttab.ruling-analysis",
|
|
22
|
+
title: "Analyze new TTAB rulings",
|
|
23
|
+
description: "Drafts a story for each new Board decision.",
|
|
24
|
+
enabled: true,
|
|
25
|
+
enabledNote: "the router starts nothing for a disabled automation",
|
|
26
|
+
trigger: { kind: "monitor", config: { monitorId: "ttab", publicationId: "example" } },
|
|
27
|
+
supported: true,
|
|
28
|
+
supportNote: null,
|
|
29
|
+
procedureKey: "example.ttab.ruling-analysis",
|
|
30
|
+
inputTemplate: { proceeding: "{sourceIdPrefix}", opinion_url: "{documentUrl}" },
|
|
31
|
+
limits: { timeoutSeconds: 840, maxConcurrent: 2, budgetUsdPerRun: 0.05 },
|
|
32
|
+
limitsNote: "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged",
|
|
33
|
+
runNow: { available: true, reason: null, needsInput: true, inputNames: ["opinion_url", "proceeding"] },
|
|
34
|
+
createdAt: "2026-10-23T10:00:00Z",
|
|
35
|
+
updatedAt: "2026-10-23T10:00:00Z",
|
|
36
|
+
link: { state: "linked", text: "The router's trigger matches this record.", routerMode: "live", routerEnabled: true },
|
|
37
|
+
notice: null,
|
|
38
|
+
skippedAlreadyHadReference: 2,
|
|
39
|
+
lastRun: null,
|
|
40
|
+
runs: [
|
|
41
|
+
run({ sourceId: "91000001-EXA_26", referenceId: "ref-1", runId: "run-1", status: "completed", triggerStatus: "completed",
|
|
42
|
+
startedAt: "2026-10-23T11:30:00Z", finishedAt: "2026-10-23T11:33:10Z", durationSeconds: 190, runRecord: "found",
|
|
43
|
+
storyId: "story-1", storyVersionId: "version-1", storyTitle: "Board affirms a refusal for likelihood of confusion",
|
|
44
|
+
costUsd: 0.0123, costNote: ESTIMATED, budgetUsd: 0.05,
|
|
45
|
+
usage: { calls: 4, promptTokens: 61000, completionTokens: 5200, models: { "gpt-6-luna": 4 }, complete: true, unpricedModels: [] } }),
|
|
46
|
+
run({ sourceId: "91000002-EXA_26", referenceId: "ref-2", runId: "run-2", status: "failed", triggerStatus: "failed",
|
|
47
|
+
startedAt: "2026-10-23T05:30:00Z", finishedAt: "2026-10-23T05:31:00Z", durationSeconds: 60, runRecord: "found",
|
|
48
|
+
error: "the opinion could not be downloaded" }),
|
|
49
|
+
run({ sourceId: "91000003-EXA_26", referenceId: "ref-3", status: "started", startedAt: "2026-10-23T11:59:00Z" }),
|
|
50
|
+
run({ origin: "manual", requestedBy: "ryan@example.test", runId: "run-4", status: "completed", triggerStatus: "completed",
|
|
51
|
+
startedAt: "2026-10-23T09:00:00Z", finishedAt: "2026-10-23T09:05:00Z", durationSeconds: 300, runRecord: "found",
|
|
52
|
+
costUsd: 0.081, costNote: ESTIMATED, budgetUsd: 0.05, overBudget: true }),
|
|
53
|
+
run({ sourceId: "91000005-EXA_26", referenceId: "ref-5", status: "deferred", startedAt: "2026-10-23T08:00:00Z",
|
|
54
|
+
reason: "deferred: concurrency limit: 2 running, at most 2 at a time" }),
|
|
55
|
+
run({ sourceId: "91000006-EXA_26", referenceId: "ref-6", status: "skipped", startedAt: "2026-10-22T08:00:00Z",
|
|
56
|
+
reason: "automation disabled" }),
|
|
57
|
+
],
|
|
58
|
+
outsideRuns: [
|
|
59
|
+
{ runId: "run-9", status: "completed", startedAt: "2026-10-22T15:00:00Z", finishedAt: "2026-10-22T15:02:00Z", durationSeconds: 120,
|
|
60
|
+
error: null, requestedBy: "papyrus-cli", storyId: null, storyVersionId: null, storyTitle: null, costUsd: null,
|
|
61
|
+
costNote: NOT_RECORDED, overBudget: false },
|
|
62
|
+
],
|
|
63
|
+
outsideNotice: null,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "example.weekly-digest",
|
|
67
|
+
title: "Weekly digest",
|
|
68
|
+
description: null,
|
|
69
|
+
enabled: false,
|
|
70
|
+
enabledNote: "the router starts nothing for a disabled automation",
|
|
71
|
+
trigger: { kind: "schedule", config: { cron: "0 9 * * 1" } },
|
|
72
|
+
supported: false,
|
|
73
|
+
supportNote: "not yet supported",
|
|
74
|
+
procedureKey: "example.weekly-digest",
|
|
75
|
+
inputTemplate: {},
|
|
76
|
+
limits: { timeoutSeconds: 840 },
|
|
77
|
+
limitsNote: "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged",
|
|
78
|
+
runNow: { available: false, reason: "not yet supported", needsInput: false, inputNames: [] },
|
|
79
|
+
createdAt: "2026-10-23T10:05:00Z",
|
|
80
|
+
updatedAt: "2026-10-23T10:05:00Z",
|
|
81
|
+
link: { state: "not_applicable", text: "not yet supported" },
|
|
82
|
+
notice: "not yet supported",
|
|
83
|
+
skippedAlreadyHadReference: 0,
|
|
84
|
+
lastRun: null,
|
|
85
|
+
runs: [],
|
|
86
|
+
outsideRuns: [],
|
|
87
|
+
outsideNotice: null,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
// Newest first, as the history is served.
|
|
92
|
+
FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs.sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt)));
|
|
93
|
+
FIXTURE_AUTOMATION_OVERVIEW.automations[0].lastRun = FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs[0];
|