@anthusai/papyrus 5.43.0 → 5.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +95 -19
- package/components/monitoring-panel.tsx +4 -2
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-references-view.tsx +2 -4
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-review-card.tsx +80 -63
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/monitoring-client.ts +14 -1
- package/lib/monitoring-overview.ts +33 -1
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +4 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI drafts inside a review request (a trend or monitor story batch, an idea batch): what each one says, what it rests on,
|
|
3
|
+
* and how an editor's Accept or Reject on it moves the Proposal and, once every draft is decided, closes the request.
|
|
4
|
+
* Pure functions, no network, so the card and its tests share them.
|
|
5
|
+
*/
|
|
6
|
+
import { proposalHref, draftIdsOf, type AnswerInput, type DraftDecision, type ReviewLink, type ReviewRequest } from "./review-requests";
|
|
7
|
+
|
|
8
|
+
/** A Proposal row as the console reads it; JSON fields may arrive as strings. */
|
|
9
|
+
export type DraftRow = {
|
|
10
|
+
id: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
title: string;
|
|
13
|
+
summary?: string | null;
|
|
14
|
+
body?: string | null;
|
|
15
|
+
state: string;
|
|
16
|
+
held?: boolean | null;
|
|
17
|
+
evidence?: unknown;
|
|
18
|
+
history?: unknown;
|
|
19
|
+
override?: unknown;
|
|
20
|
+
createdAt?: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type DraftSource = { title: string; date: string | null; url: string | null };
|
|
24
|
+
export type DraftChoice = "accept" | "reject";
|
|
25
|
+
|
|
26
|
+
export type DraftView = {
|
|
27
|
+
id: string;
|
|
28
|
+
kindLabel: string;
|
|
29
|
+
headline: string;
|
|
30
|
+
dek: string | null;
|
|
31
|
+
section: string | null;
|
|
32
|
+
/** The evidence in words: the signals it rests on, or the monitor that found its source. */
|
|
33
|
+
evidence: string[];
|
|
34
|
+
sources: DraftSource[];
|
|
35
|
+
moreSources: number;
|
|
36
|
+
state: string;
|
|
37
|
+
held: boolean;
|
|
38
|
+
href: string;
|
|
39
|
+
/** What the Proposal's own state says an editor already decided, or null while it is open. */
|
|
40
|
+
decided: DraftChoice | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const MAXIMUM_SOURCES = 5;
|
|
44
|
+
const MAXIMUM_EVIDENCE = 3;
|
|
45
|
+
const KIND_WORDS: Record<string, string> = { story_draft: "Story draft", new_concept: "New concept", rename: "Rename", merge: "Merge", split: "Split", new_column: "New column", element_symbol: "Element symbol" };
|
|
46
|
+
|
|
47
|
+
function parseJson(value: unknown): unknown {
|
|
48
|
+
if (typeof value !== "string") return value ?? null;
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(value);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The lines of a "## Heading" section of the body, up to the next heading or the closing "Section:" line. */
|
|
57
|
+
function bodySection(body: string, heading: RegExp): string[] {
|
|
58
|
+
const lines = body.split(/\r?\n/);
|
|
59
|
+
const start = lines.findIndex((line) => heading.test(line.trim()));
|
|
60
|
+
if (start < 0) return [];
|
|
61
|
+
const found: string[] = [];
|
|
62
|
+
for (const line of lines.slice(start + 1)) {
|
|
63
|
+
const text = line.trim();
|
|
64
|
+
if (/^#{1,6}\s/.test(text) || /^Section:/.test(text)) break;
|
|
65
|
+
if (text) found.push(text);
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseSource(line: string): DraftSource | null {
|
|
71
|
+
const numbered = line.match(/^\d+\.\s+(.+)$/);
|
|
72
|
+
if (!numbered) return null;
|
|
73
|
+
let rest = numbered[1].trim();
|
|
74
|
+
let url: string | null = null;
|
|
75
|
+
const link = rest.match(/^(.*?)\s*(https?:\/\/\S+)$/);
|
|
76
|
+
if (link) {
|
|
77
|
+
rest = link[1].trim();
|
|
78
|
+
url = link[2];
|
|
79
|
+
}
|
|
80
|
+
let date: string | null = null;
|
|
81
|
+
const dated = rest.match(/^(.*?)\s*\((\d{4}-\d{2}-\d{2})\)$/);
|
|
82
|
+
if (dated) {
|
|
83
|
+
rest = dated[1].trim();
|
|
84
|
+
date = dated[2];
|
|
85
|
+
}
|
|
86
|
+
return rest ? { title: rest, date, url } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function evidenceLines(row: DraftRow): string[] {
|
|
90
|
+
const body = row.body ?? "";
|
|
91
|
+
const signals = bodySection(body, /^##\s+Signals cited\s*$/i)
|
|
92
|
+
.map((line) => line.match(/^\d+\.\s+(.+?)(?:\s+\(https?:\/\/[^)\s]*\))?\s*(?:`[^`]*`)?\s*$/)?.[1]?.trim())
|
|
93
|
+
.filter((line): line is string => Boolean(line));
|
|
94
|
+
if (signals.length) return signals.slice(0, MAXIMUM_EVIDENCE);
|
|
95
|
+
const evidence = parseJson(row.evidence);
|
|
96
|
+
const source = evidence && typeof evidence === "object" && typeof (evidence as { source?: unknown }).source === "string" ? String((evidence as { source: string }).source) : "";
|
|
97
|
+
if (!source) return [];
|
|
98
|
+
const monitor = source.match(/^(.+?) monitor:/);
|
|
99
|
+
return [monitor ? `Found by the ${monitor[1]} monitor.` : source];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function draftChoiceOf(state: string): DraftChoice | null {
|
|
103
|
+
return state === "approved" ? "accept" : state === "declined" ? "reject" : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What an editor reads before deciding: headline, dek, section, evidence and the cited sources, from the Proposal row. */
|
|
107
|
+
export function draftView(row: DraftRow, demo = false): DraftView {
|
|
108
|
+
const body = row.body ?? "";
|
|
109
|
+
const sources = [...bodySection(body, /^##\s+Sources?\s*$/i)].map(parseSource).filter((source): source is DraftSource => source !== null);
|
|
110
|
+
const section = body.match(/^Section:\s*(.+?)\.\s+Drafted by AI/m)?.[1]?.trim() ?? null;
|
|
111
|
+
const override = parseJson(row.override) as { result?: string } | null;
|
|
112
|
+
return {
|
|
113
|
+
id: row.id,
|
|
114
|
+
kindLabel: KIND_WORDS[row.kind] ?? row.kind,
|
|
115
|
+
headline: row.title,
|
|
116
|
+
dek: row.summary?.trim() || null,
|
|
117
|
+
section,
|
|
118
|
+
evidence: evidenceLines(row),
|
|
119
|
+
sources: sources.slice(0, MAXIMUM_SOURCES),
|
|
120
|
+
moreSources: Math.max(0, sources.length - MAXIMUM_SOURCES),
|
|
121
|
+
state: row.state,
|
|
122
|
+
held: Boolean(row.held),
|
|
123
|
+
href: proposalHref(row.id, demo),
|
|
124
|
+
decided: draftChoiceOf(row.state) ?? (row.state === "overridden_by_editor" ? (override?.result === "declined" ? "reject" : "accept") : null),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- moving the Proposal ----------------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The states Accept walks through, one `transitionProposal` call each, ending at "approved" (the same state the Proposals panel's Approve reaches).
|
|
132
|
+
* A draft goes through steering and editor review on the way, as the panel's own "Move to editor review" then "Approve" do.
|
|
133
|
+
*/
|
|
134
|
+
export function acceptPath(state: string): string[] | null {
|
|
135
|
+
if (state === "draft") return ["in_steering", "editor_review", "approved"];
|
|
136
|
+
if (state === "in_steering") return ["editor_review", "approved"];
|
|
137
|
+
if (state === "editor_review") return ["approved"];
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Reject ends at "declined". */
|
|
142
|
+
export function rejectPath(state: string): string[] | null {
|
|
143
|
+
if (state === "draft") return ["declined"];
|
|
144
|
+
if (state === "in_steering") return ["editor_review", "declined"];
|
|
145
|
+
if (state === "editor_review") return ["declined"];
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The sentence for a draft that cannot move, or null when it can. A hold is named, never hidden. */
|
|
150
|
+
export function cannotMoveReason(view: Pick<DraftView, "state" | "held" | "decided">): string | null {
|
|
151
|
+
if (view.decided) return null;
|
|
152
|
+
if (view.held) return "This draft is on hold in Proposals. Release the hold there before you decide it.";
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- the request's answer ---------------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export type DraftLookup = Readonly<Record<string, DraftView | undefined>>;
|
|
159
|
+
|
|
160
|
+
/** The decision recorded on a link, else what the Proposal's own state says (decided in Proposals), else null. */
|
|
161
|
+
export function draftDecisionOf(link: ReviewLink | undefined, view: DraftView | undefined, fallback: { by: string; at: string }): DraftDecision | null {
|
|
162
|
+
if (link?.decision) return link.decision;
|
|
163
|
+
return view?.decided ? { choice: view.decided, title: view.headline, why: null, notThisKind: false, ...fallback } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Ids of the request's drafts that exist and are not decided yet. */
|
|
167
|
+
export function undecidedDraftIds(request: ReviewRequest, drafts: DraftLookup): string[] {
|
|
168
|
+
return draftIdsOf(request).filter((id) => {
|
|
169
|
+
const view = drafts[id];
|
|
170
|
+
if (!view) return false;
|
|
171
|
+
return draftDecisionOf(request.links.find((link) => link.id === id), view, { by: "", at: "" }) === null;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** How many drafts in these requests still wait for a decision (drafts not yet loaded count as waiting). */
|
|
176
|
+
export function draftsToDecide(requests: ReviewRequest[], drafts: DraftLookup, missing: ReadonlySet<string> = new Set()): number {
|
|
177
|
+
return requests.reduce((total, request) => total + draftIdsOf(request).filter((id) => !missing.has(id) && (!drafts[id] || undecidedDraftIds(request, drafts).includes(id))).length, 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The links with one draft's decision recorded. Links for drafts the request did not name are not invented. */
|
|
181
|
+
export function withDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): ReviewLink[] {
|
|
182
|
+
const named = request.links.some((link) => link.id === draftId);
|
|
183
|
+
const links = named ? request.links : [...request.links, { type: "proposal", id: draftId }];
|
|
184
|
+
return links.map((link) => (link.id === draftId ? { ...link, decision } : link));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The update that records a decision on one draft while the request stays open. Applied with the condition state = open. */
|
|
188
|
+
export function draftDecisionUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision): Record<string, unknown> {
|
|
189
|
+
const history = [...request.history, { at: decision.at, event: "draft_decided", by: decision.by, note: `${decision.choice} ${draftId}` }].slice(-50);
|
|
190
|
+
return { id: request.id, links: JSON.stringify(withDecision(request, draftId, decision)), updatedAt: decision.at, history: JSON.stringify(history) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function quote(text: string): string {
|
|
194
|
+
return `"${text.replace(/"/g, "'")}"`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The answer that closes a request once every draft that can be decided has been: Accept on all is an approval, Reject on all a rejection,
|
|
199
|
+
* a mix is an answer with a note naming each draft. A draft rejected as the wrong kind of story makes the answer a "not this kind" steer, which
|
|
200
|
+
* the idea pipeline reads back. Null while a draft is undecided, or when no draft of the request could be loaded.
|
|
201
|
+
*/
|
|
202
|
+
export function batchAnswer(request: ReviewRequest, drafts: DraftLookup, fallback: { by: string; at: string }): AnswerInput | null {
|
|
203
|
+
const ids = draftIdsOf(request).filter((id) => drafts[id]);
|
|
204
|
+
if (!ids.length) return null;
|
|
205
|
+
const decisions: (DraftDecision & { id: string })[] = [];
|
|
206
|
+
for (const id of ids) {
|
|
207
|
+
const decision = draftDecisionOf(request.links.find((link) => link.id === id), drafts[id], fallback);
|
|
208
|
+
if (!decision) return null;
|
|
209
|
+
decisions.push({ ...decision, title: decision.title ?? drafts[id]?.headline, id });
|
|
210
|
+
}
|
|
211
|
+
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
212
|
+
const lines = decisions
|
|
213
|
+
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
214
|
+
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
215
|
+
const mixed = rejected.length > 0 && rejected.length < decisions.length;
|
|
216
|
+
if (mixed) {
|
|
217
|
+
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
218
|
+
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
219
|
+
}
|
|
220
|
+
const links = request.links.some((link) => link.type === "proposal") ? request.links.map((link) => {
|
|
221
|
+
const decision = decisions.find((entry) => entry.id === link.id);
|
|
222
|
+
return decision ? { ...link, decision } : link;
|
|
223
|
+
}) : [...request.links, ...decisions.map((decision) => ({ type: "proposal", id: decision.id, decision }))];
|
|
224
|
+
return {
|
|
225
|
+
decision: !rejected.length ? "approve" : mixed ? "feedback" : "reject",
|
|
226
|
+
feedback: lines.join(" ") || null,
|
|
227
|
+
steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
|
|
228
|
+
drafts: decisions,
|
|
229
|
+
links,
|
|
230
|
+
};
|
|
231
|
+
}
|
package/lib/review-fixture.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import type { DraftRow } from "./review-drafts";
|
|
1
2
|
import type { ReviewRequest } from "./review-requests";
|
|
2
3
|
|
|
3
4
|
/** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
|
|
4
5
|
export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
5
6
|
{
|
|
6
|
-
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "
|
|
7
|
+
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Accept this ruling summary before it is published",
|
|
7
8
|
ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
|
|
8
9
|
state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
|
|
9
10
|
appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
13
|
id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
|
|
13
|
-
ask: "
|
|
14
|
+
ask: "Accept or reject each draft.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
|
|
14
15
|
deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
|
|
15
16
|
links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
|
|
16
17
|
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
@@ -42,4 +43,37 @@ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
|
42
43
|
answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
|
|
43
44
|
links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
|
|
44
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: "rr-soft-3", subject: { type: "story", id: "story-weekly" }, mode: "soft_review", reason: "Check the tone of the weekly summary", ask: "Is the tone right for readers?",
|
|
48
|
+
raisedBy: "copy-editor", raisedAt: "2026-10-23T06:00:00Z", deadline: "2026-10-31T07:00:00Z", state: "open", answer: null, links: [], history: [], appliedIn: null,
|
|
49
|
+
updatedAt: "2026-10-23T06:00:00Z",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const TREND_BODY = (dek: string, section: string) => [
|
|
54
|
+
`${dek} [1][3]`, "", "## Signals cited", "",
|
|
55
|
+
"1. Sparse attention: technique, 32 items in the last 2 weeks against 9 in the 6 weeks before (about 4 expected; p=0.001 after correcting for 40 windows tried) `entity:sparse-attention`",
|
|
56
|
+
"2. Open weights: cluster, steady, 58 items `cluster:open-weights`", "", "## Sources", "",
|
|
57
|
+
"3. A sparse-attention model ships open weights (2026-10-20) https://example.org/sparse-attention",
|
|
58
|
+
"4. Benchmarks for long-context models (2026-10-18) https://example.org/long-context", "", `Section: ${section}. Drafted by AI from the knowledge snapshot; an editor approves the copy and the figure before anything is published.`, "",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
/** Demo and test drafts for the batch fixtures: a trend story, a monitor story and a draft that already has a decision. */
|
|
62
|
+
export const FIXTURE_DRAFTS: DraftRow[] = [
|
|
63
|
+
{
|
|
64
|
+
id: "proposal-1", kind: "story_draft", title: "Sparse attention goes open", summary: "Open-weight models are adopting sparse attention faster than closed labs reported.",
|
|
65
|
+
body: TREND_BODY("Open-weight models are adopting sparse attention faster than closed labs reported.", "Research"), state: "draft", held: false,
|
|
66
|
+
evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "proposal-2", kind: "story_draft", title: "A new quantisation paper challenges 4-bit defaults", summary: "A preprint argues that 3-bit weights match 4-bit accuracy on long-context tasks.",
|
|
70
|
+
body: ["A preprint argues that 3-bit weights match 4-bit accuracy on long-context tasks. [1]", "", "## Source", "", "1. Rethinking 4-bit defaults (2026-10-22) https://example.org/quantisation", "",
|
|
71
|
+
"Section: Research. Drafted by AI from an item the arxiv-cs monitor found. An editor approves the copy before anything is published.", ""].join("\n"),
|
|
72
|
+
state: "draft", held: false, evidence: { signals: 1, source: "arxiv-cs monitor: https://example.org/quantisation" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "proposal-3", kind: "story_draft", title: "Eight new models this week", summary: "A list of the week's releases.",
|
|
76
|
+
body: TREND_BODY("A list of the week's releases.", "News"), state: "editor_review", held: false,
|
|
77
|
+
evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
78
|
+
},
|
|
45
79
|
];
|
package/lib/review-requests.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
|
|
4
4
|
*
|
|
5
5
|
* Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
|
|
6
|
-
* what happens next without the reader, and only a hard gate says "Waiting for you".
|
|
6
|
+
* what happens next without the reader, and only a hard gate says "Waiting for you". One decision vocabulary: Accept or Reject.
|
|
7
7
|
*/
|
|
8
8
|
import type { ReferenceRecord } from "./category-repository";
|
|
9
9
|
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
@@ -16,9 +16,11 @@ export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
|
|
|
16
16
|
export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
|
|
17
17
|
export type ReviewSteer = "adjust" | "not_this_kind";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** One AI draft's answer inside a batch request: Accept or Reject, an optional why, and whether the editor called it the wrong kind of story. */
|
|
20
|
+
export type DraftDecision = { choice: "accept" | "reject"; title?: string; why?: string | null; notThisKind?: boolean; by: string; at: string };
|
|
21
|
+
export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string; drafts?: (DraftDecision & { id: string })[] };
|
|
20
22
|
export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
|
|
21
|
-
export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
|
|
23
|
+
export type ReviewLink = { label?: string; url?: string; type?: string; id?: string; decision?: DraftDecision };
|
|
22
24
|
|
|
23
25
|
export type ReviewRequest = {
|
|
24
26
|
id: string;
|
|
@@ -84,7 +86,11 @@ export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string,
|
|
|
84
86
|
return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
export type AnswerInput = {
|
|
89
|
+
export type AnswerInput = {
|
|
90
|
+
decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null;
|
|
91
|
+
/** A batch of AI drafts: each draft's decision, recorded on the answer and on the request's links. */
|
|
92
|
+
drafts?: (DraftDecision & { id: string })[]; links?: ReviewLink[];
|
|
93
|
+
};
|
|
88
94
|
|
|
89
95
|
/** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
|
|
90
96
|
export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
|
|
@@ -94,9 +100,12 @@ export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: str
|
|
|
94
100
|
const steer = input.steer ?? null;
|
|
95
101
|
if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
|
|
96
102
|
const at = now.toISOString();
|
|
97
|
-
const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
|
|
103
|
+
const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at, ...(input.drafts?.length ? { drafts: input.drafts } : {}) };
|
|
98
104
|
const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
|
|
99
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history),
|
|
107
|
+
...(input.links ? { links: JSON.stringify(input.links) } : {}),
|
|
108
|
+
};
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
// --- words ------------------------------------------------------------------------------------------------
|
|
@@ -112,30 +121,49 @@ export function modeLabel(mode: ReviewMode): string {
|
|
|
112
121
|
return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
/**
|
|
116
|
-
export function
|
|
117
|
-
|
|
124
|
+
/** The proposal ids a request is about: its proposal links, or its subject when that is a proposal. Empty for every other request. */
|
|
125
|
+
export function draftIdsOf(request: Pick<ReviewRequest, "subject" | "links">): string[] {
|
|
126
|
+
const linked = request.links.filter((link) => link.type === "proposal" && link.id).map((link) => String(link.id));
|
|
127
|
+
const ids = linked.length ? linked : request.subject.type === "proposal" ? [request.subject.id] : [];
|
|
128
|
+
return [...new Set(ids)];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A review of AI drafts: each draft is shown and decided in the card. Flags are never drafts to decide. */
|
|
132
|
+
export function isDraftReview(request: Pick<ReviewRequest, "mode" | "subject" | "links">): boolean {
|
|
133
|
+
return request.mode !== "flag" && draftIdsOf(request).length > 0;
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
/** What happens next without the reader. Only a hard gate says "Waiting for you". */
|
|
121
137
|
export function nextText(request: ReviewRequest): string {
|
|
138
|
+
const drafts = isDraftReview(request);
|
|
122
139
|
if (request.state === "open") {
|
|
123
|
-
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you
|
|
124
|
-
if (request.mode === "soft_review")
|
|
140
|
+
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you accept or reject.";
|
|
141
|
+
if (request.mode === "soft_review") {
|
|
142
|
+
if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The drafts stay in Proposals.` : "No deadline. The drafts stay in Proposals until an editor decides them.";
|
|
143
|
+
return request.deadline ? `If nobody answers by ${formatWhen(request.deadline)}, this closes and ${request.raisedBy} carries on without a review.` : "No deadline. Stays open until someone answers it.";
|
|
144
|
+
}
|
|
125
145
|
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
126
146
|
}
|
|
127
|
-
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
147
|
+
if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The drafts stay in Proposals.` : `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
128
148
|
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
129
149
|
const answer = request.answer;
|
|
130
150
|
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
|
|
134
|
-
if (answer.
|
|
154
|
+
if (answer.drafts?.length) {
|
|
155
|
+
const accepted = answer.drafts.filter((draft) => draft.choice === "accept").length;
|
|
156
|
+
const rejected = answer.drafts.length - accepted;
|
|
157
|
+
const noun = (count: number) => `${count} ${count === 1 ? "draft" : "drafts"}`;
|
|
158
|
+
if (!rejected) return `Accepted ${noun(accepted)}`;
|
|
159
|
+
if (!accepted) return `Rejected ${noun(rejected)}`;
|
|
160
|
+
return `Accepted ${noun(accepted)} and rejected ${rejected}`;
|
|
161
|
+
}
|
|
135
162
|
if (answer.decision === "acknowledge") return "Acknowledged";
|
|
136
|
-
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "
|
|
137
|
-
if (answer.decision === "reject") return "Rejected";
|
|
138
|
-
return "
|
|
163
|
+
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Accepted";
|
|
164
|
+
if (answer.decision === "reject") return answer.steer === "not_this_kind" ? "Rejected as not this kind of thing" : "Rejected";
|
|
165
|
+
if (answer.steer === "not_this_kind") return "Rejected as not this kind of thing";
|
|
166
|
+
return "Answered with a note";
|
|
139
167
|
}
|
|
140
168
|
|
|
141
169
|
/** "You steered X, applied in run Y": what became of a person's input. */
|
|
@@ -153,11 +181,16 @@ export function subjectLabel(subject: { type: string; id: string }, titles: Read
|
|
|
153
181
|
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
/** The Proposals panel, opened on one proposal. */
|
|
185
|
+
export function proposalHref(proposalId: string, demo = false): string {
|
|
186
|
+
return getNewsroomNavHref(`/newsroom/administration/proposals?proposal=${encodeURIComponent(proposalId)}`, demo);
|
|
187
|
+
}
|
|
188
|
+
|
|
156
189
|
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
157
190
|
const id = encodeURIComponent(subject.id);
|
|
158
191
|
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
|
159
192
|
if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
|
|
160
|
-
if (subject.type === "proposal") return
|
|
193
|
+
if (subject.type === "proposal") return proposalHref(subject.id, demo);
|
|
161
194
|
if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
|
|
162
195
|
return null;
|
|
163
196
|
}
|
|
@@ -210,6 +243,9 @@ export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: R
|
|
|
210
243
|
|
|
211
244
|
export type NotificationSections = {
|
|
212
245
|
hardGates: ReviewRequest[];
|
|
246
|
+
/** Open soft reviews of AI drafts (each draft is decided in its card). */
|
|
247
|
+
draftBatches: ReviewRequest[];
|
|
248
|
+
/** Every other open soft review. */
|
|
213
249
|
softReviews: ReviewRequest[];
|
|
214
250
|
flags: ReviewRequest[];
|
|
215
251
|
/** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
|
|
@@ -221,26 +257,30 @@ export function sectionReviews(requests: ReviewRequest[]): NotificationSections
|
|
|
221
257
|
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
222
258
|
// Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
|
|
223
259
|
const bySoonestDeadline = (a: ReviewRequest, b: ReviewRequest) => (a.deadline && b.deadline ? a.deadline.localeCompare(b.deadline) : a.deadline ? -1 : b.deadline ? 1 : byRaised(a, b));
|
|
260
|
+
const soft = open.filter((request) => request.mode === "soft_review");
|
|
224
261
|
return {
|
|
225
262
|
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
226
|
-
|
|
263
|
+
draftBatches: soft.filter(isDraftReview).sort(bySoonestDeadline),
|
|
264
|
+
softReviews: soft.filter((request) => !isDraftReview(request)).sort(bySoonestDeadline),
|
|
227
265
|
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
228
266
|
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
229
267
|
};
|
|
230
268
|
}
|
|
231
269
|
|
|
232
|
-
export type NotificationSummary = { waiting: number;
|
|
270
|
+
export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
|
|
233
271
|
|
|
234
272
|
/**
|
|
235
|
-
* "N waiting for you · N
|
|
236
|
-
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts).
|
|
273
|
+
* "N waiting for you · N AI drafts to review · N other reviews open · N recently decided by the system".
|
|
274
|
+
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). `draftsToDecide` is the number of
|
|
275
|
+
* drafts in open draft reviews that nobody has decided yet.
|
|
237
276
|
*/
|
|
238
|
-
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
239
|
-
|
|
277
|
+
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number, draftsToDecide?: number): NotificationSummary {
|
|
278
|
+
const drafts = draftsToDecide ?? sections.draftBatches.reduce((total, request) => total + draftIdsOf(request).length, 0);
|
|
279
|
+
return { waiting: sections.hardGates.length + held.length, drafts, otherReviews: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
|
|
240
280
|
}
|
|
241
281
|
|
|
242
282
|
export function summaryText(summary: NotificationSummary): string {
|
|
243
|
-
return `${summary.waiting} waiting for you · ${summary.
|
|
283
|
+
return `${summary.waiting} waiting for you · ${summary.drafts} AI ${summary.drafts === 1 ? "draft" : "drafts"} to review · ${summary.otherReviews} other ${summary.otherReviews === 1 ? "review" : "reviews"} open · ${summary.decidedBySystem} recently decided by the system`;
|
|
244
284
|
}
|
|
245
285
|
|
|
246
286
|
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who counts as newsroom staff. `admin` has every editor right: a site owner who is in `admin` but not `editor` can do
|
|
3
|
+
* everything an editor can. Every editor check in the browser and in the Lambdas reads this one list.
|
|
4
|
+
*/
|
|
5
|
+
export const STAFF_GROUPS = ["editor", "admin"] as const;
|
|
6
|
+
|
|
7
|
+
/** True when any group is `editor` or `admin`. */
|
|
8
|
+
export function hasEditorRights(groups: Iterable<unknown> | null | undefined): boolean {
|
|
9
|
+
if (!groups) return false;
|
|
10
|
+
for (const group of groups) {
|
|
11
|
+
if (typeof group === "string" && (STAFF_GROUPS as readonly string[]).includes(group.trim().toLowerCase())) return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cognito groups from a `cognito:groups` claim, which arrives as an array, a JSON array, a bracketed list
|
|
18
|
+
* (`[admin Google_123]`), or a comma or space separated string depending on who forwarded it.
|
|
19
|
+
*/
|
|
20
|
+
export function claimGroups(value: unknown): string[] {
|
|
21
|
+
const raw = Array.isArray(value) ? value.map((entry) => String(entry)) : typeof value === "string" ? value.split(/[\s,]+/) : [];
|
|
22
|
+
return raw.map((entry) => entry.trim().replace(/^["'[\](){}]+|["'[\](){}]+$/g, "")).filter(Boolean);
|
|
23
|
+
}
|
package/lib/staging-gate.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { fetchAuthSession } from "aws-amplify/auth/server";
|
|
3
3
|
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
4
|
+
import { hasEditorRights } from "./staff-groups";
|
|
4
5
|
|
|
5
6
|
export type StagingAccess = "anonymous" | "forbidden" | "ok";
|
|
6
7
|
|
|
7
|
-
const STAGING_ALLOWED_GROUPS = ["editor", "admin"];
|
|
8
8
|
|
|
9
9
|
export function groupsFromIdTokenPayload(payload: Record<string, unknown> | undefined): string[] {
|
|
10
10
|
const raw = payload?.["cognito:groups"];
|
|
@@ -15,7 +15,7 @@ export function groupsFromIdTokenPayload(payload: Record<string, unknown> | unde
|
|
|
15
15
|
|
|
16
16
|
export function accessForGroups(groups: string[] | null): StagingAccess {
|
|
17
17
|
if (groups === null) return "anonymous";
|
|
18
|
-
return groups
|
|
18
|
+
return hasEditorRights(groups) ? "ok" : "forbidden";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function getStagingAccess(request: NextRequest, response: NextResponse): Promise<StagingAccess> {
|
package/package.json
CHANGED
|
@@ -293,6 +293,7 @@
|
|
|
293
293
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
294
294
|
"./lib/priming-proof-report": "./lib/priming-proof-report.ts",
|
|
295
295
|
"./lib/proposals-admin": "./lib/proposals-admin.ts",
|
|
296
|
+
"./lib/proposals-client": "./lib/proposals-client.ts",
|
|
296
297
|
"./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
|
|
297
298
|
"./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
|
|
298
299
|
"./lib/prospectus-session": "./lib/prospectus-session.ts",
|
|
@@ -325,6 +326,7 @@
|
|
|
325
326
|
"./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
|
|
326
327
|
"./lib/revalidate-secret": "./lib/revalidate-secret.ts",
|
|
327
328
|
"./lib/review-client": "./lib/review-client.ts",
|
|
329
|
+
"./lib/review-drafts": "./lib/review-drafts.ts",
|
|
328
330
|
"./lib/review-fixture": "./lib/review-fixture.ts",
|
|
329
331
|
"./lib/review-requests": "./lib/review-requests.ts",
|
|
330
332
|
"./lib/robots-policy": "./lib/robots-policy.ts",
|
|
@@ -341,6 +343,7 @@
|
|
|
341
343
|
"./lib/site-renderer": "./lib/site-renderer.ts",
|
|
342
344
|
"./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
|
|
343
345
|
"./lib/site-stack": "./lib/site-stack.ts",
|
|
346
|
+
"./lib/staff-groups": "./lib/staff-groups.ts",
|
|
344
347
|
"./lib/staging-gate": "./lib/staging-gate.ts",
|
|
345
348
|
"./lib/staging-gated-path": "./lib/staging-gated-path.ts",
|
|
346
349
|
"./lib/staging-preview-object": "./lib/staging-preview-object.ts",
|
|
@@ -467,7 +470,7 @@
|
|
|
467
470
|
"optional": true
|
|
468
471
|
}
|
|
469
472
|
},
|
|
470
|
-
"version": "5.
|
|
473
|
+
"version": "5.44.0",
|
|
471
474
|
"dependencies": {
|
|
472
475
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
473
476
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED