@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41
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 +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +16 -8
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -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/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +189 -0
- 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/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +90 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
/** The draft's own words: the body without its Sources and Signals lists (those are shown as chips) or its closing "Section:" line. */
|
|
32
|
+
bodyText: string;
|
|
33
|
+
section: string | null;
|
|
34
|
+
/** The evidence in words: the signals it rests on, or the monitor that found its source. */
|
|
35
|
+
evidence: string[];
|
|
36
|
+
sources: DraftSource[];
|
|
37
|
+
moreSources: number;
|
|
38
|
+
state: string;
|
|
39
|
+
held: boolean;
|
|
40
|
+
href: string;
|
|
41
|
+
/** What the Proposal's own state says an editor already decided, or null while it is open. */
|
|
42
|
+
decided: DraftChoice | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const MAXIMUM_SOURCES = 5;
|
|
46
|
+
const MAXIMUM_EVIDENCE = 3;
|
|
47
|
+
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" };
|
|
48
|
+
|
|
49
|
+
/** JSON fields arrive as strings, and a field written through `JSON.stringify` of a string arrives encoded twice: unwrap until it is not text. */
|
|
50
|
+
function parseJson(value: unknown, depth = 0): unknown {
|
|
51
|
+
if (typeof value !== "string") return value ?? null;
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(value);
|
|
54
|
+
return typeof parsed === "string" && depth < 2 ? parseJson(parsed, depth + 1) : parsed;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The lines of a "## Heading" section of the body, up to the next heading or the closing "Section:" line. */
|
|
61
|
+
function bodySection(body: string, heading: RegExp): string[] {
|
|
62
|
+
const lines = body.split(/\r?\n/);
|
|
63
|
+
const start = lines.findIndex((line) => heading.test(line.trim()));
|
|
64
|
+
if (start < 0) return [];
|
|
65
|
+
const found: string[] = [];
|
|
66
|
+
for (const line of lines.slice(start + 1)) {
|
|
67
|
+
const text = line.trim();
|
|
68
|
+
if (/^#{1,6}\s/.test(text) || /^Section:/.test(text)) break;
|
|
69
|
+
if (text) found.push(text);
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseSource(line: string): DraftSource | null {
|
|
75
|
+
const numbered = line.match(/^\d+\.\s+(.+)$/);
|
|
76
|
+
if (!numbered) return null;
|
|
77
|
+
let rest = numbered[1].trim();
|
|
78
|
+
let url: string | null = null;
|
|
79
|
+
const link = rest.match(/^(.*?)\s*(https?:\/\/\S+)$/);
|
|
80
|
+
if (link) {
|
|
81
|
+
rest = link[1].trim();
|
|
82
|
+
url = link[2];
|
|
83
|
+
}
|
|
84
|
+
let date: string | null = null;
|
|
85
|
+
const dated = rest.match(/^(.*?)\s*\((\d{4}-\d{2}-\d{2})\)$/);
|
|
86
|
+
if (dated) {
|
|
87
|
+
rest = dated[1].trim();
|
|
88
|
+
date = dated[2];
|
|
89
|
+
}
|
|
90
|
+
return rest ? { title: rest, date, url } : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function evidenceLines(row: DraftRow): string[] {
|
|
94
|
+
const body = row.body ?? "";
|
|
95
|
+
const signals = bodySection(body, /^##\s+Signals cited\s*$/i)
|
|
96
|
+
.map((line) => line.match(/^\d+\.\s+(.+?)(?:\s+\(https?:\/\/[^)\s]*\))?\s*(?:`[^`]*`)?\s*$/)?.[1]?.trim())
|
|
97
|
+
.filter((line): line is string => Boolean(line));
|
|
98
|
+
if (signals.length) return signals.slice(0, MAXIMUM_EVIDENCE);
|
|
99
|
+
const evidence = parseJson(row.evidence);
|
|
100
|
+
const source = evidence && typeof evidence === "object" && typeof (evidence as { source?: unknown }).source === "string" ? String((evidence as { source: string }).source) : "";
|
|
101
|
+
if (!source) return [];
|
|
102
|
+
const monitor = source.match(/^(.+?) monitor:/);
|
|
103
|
+
const lines = [monitor ? `Found by the ${monitor[1]} monitor.` : source];
|
|
104
|
+
const check = body.match(/relevance check labelled it (\w+) with a score of (\d+(?:\.\d+)?)\./i);
|
|
105
|
+
if (check) lines.push(`Relevance check: ${check[1]}, score ${check[2]}.`);
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The prose of a draft: every line except the Sources and Signals cited lists and the closing "Section: ..." line. */
|
|
110
|
+
export function draftBodyText(body: string): string {
|
|
111
|
+
const kept: string[] = [];
|
|
112
|
+
let skipping = false;
|
|
113
|
+
for (const line of body.split(/\r?\n/)) {
|
|
114
|
+
const text = line.trim();
|
|
115
|
+
if (/^#{1,6}\s/.test(text)) skipping = /^##\s+(Sources?|Signals cited)\s*$/i.test(text);
|
|
116
|
+
if (skipping || /^Section:\s.*Drafted by AI/i.test(text)) continue;
|
|
117
|
+
kept.push(line);
|
|
118
|
+
}
|
|
119
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function draftChoiceOf(state: string): DraftChoice | null {
|
|
123
|
+
return state === "approved" ? "accept" : state === "declined" ? "reject" : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What an editor reads before deciding: headline, dek, section, evidence and the cited sources, from the Proposal row. */
|
|
127
|
+
export function draftView(row: DraftRow, demo = false): DraftView {
|
|
128
|
+
const body = row.body ?? "";
|
|
129
|
+
const sources = [...bodySection(body, /^##\s+Sources?\s*$/i)].map(parseSource).filter((source): source is DraftSource => source !== null);
|
|
130
|
+
const section = body.match(/^Section:\s*(.+?)\.\s+Drafted by AI/m)?.[1]?.trim() ?? null;
|
|
131
|
+
const override = parseJson(row.override) as { result?: string } | null;
|
|
132
|
+
return {
|
|
133
|
+
id: row.id,
|
|
134
|
+
kindLabel: KIND_WORDS[row.kind] ?? row.kind,
|
|
135
|
+
headline: row.title,
|
|
136
|
+
dek: row.summary?.trim() || null,
|
|
137
|
+
bodyText: draftBodyText(body),
|
|
138
|
+
section,
|
|
139
|
+
evidence: evidenceLines(row),
|
|
140
|
+
sources: sources.slice(0, MAXIMUM_SOURCES),
|
|
141
|
+
moreSources: Math.max(0, sources.length - MAXIMUM_SOURCES),
|
|
142
|
+
state: row.state,
|
|
143
|
+
held: Boolean(row.held),
|
|
144
|
+
href: proposalHref(row.id, demo),
|
|
145
|
+
decided: draftChoiceOf(row.state) ?? (row.state === "overridden_by_editor" ? (override?.result === "declined" ? "reject" : "accept") : null),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- moving the Proposal ----------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The states Accept walks through, one `transitionProposal` call each, ending at "approved" (the same state the Proposals panel's Approve reaches).
|
|
153
|
+
* A draft goes through steering and editor review on the way, as the panel's own "Move to editor review" then "Approve" do.
|
|
154
|
+
*/
|
|
155
|
+
export function acceptPath(state: string): string[] | null {
|
|
156
|
+
if (state === "draft") return ["in_steering", "editor_review", "approved"];
|
|
157
|
+
if (state === "in_steering") return ["editor_review", "approved"];
|
|
158
|
+
if (state === "editor_review") return ["approved"];
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reject ends at "declined". */
|
|
163
|
+
export function rejectPath(state: string): string[] | null {
|
|
164
|
+
if (state === "draft") return ["declined"];
|
|
165
|
+
if (state === "in_steering") return ["editor_review", "declined"];
|
|
166
|
+
if (state === "editor_review") return ["declined"];
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The sentence for a draft that cannot move, or null when it can. A hold is named, never hidden. */
|
|
171
|
+
export function cannotMoveReason(view: Pick<DraftView, "state" | "held" | "decided">): string | null {
|
|
172
|
+
if (view.decided) return null;
|
|
173
|
+
if (view.held) return "This draft is on hold in Proposals. Release the hold there before you decide it.";
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the request's answer ---------------------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export type DraftLookup = Readonly<Record<string, DraftView | undefined>>;
|
|
180
|
+
|
|
181
|
+
/** The decision recorded on a link, else what the Proposal's own state says (decided in Proposals), else null. */
|
|
182
|
+
export function draftDecisionOf(link: ReviewLink | undefined, view: DraftView | undefined, fallback: { by: string; at: string }): DraftDecision | null {
|
|
183
|
+
if (link?.decision) return link.decision;
|
|
184
|
+
return view?.decided ? { choice: view.decided, title: view.headline, why: null, notThisKind: false, ...fallback } : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Ids of the request's drafts that exist and are not decided yet. */
|
|
188
|
+
export function undecidedDraftIds(request: ReviewRequest, drafts: DraftLookup): string[] {
|
|
189
|
+
return draftIdsOf(request).filter((id) => {
|
|
190
|
+
const view = drafts[id];
|
|
191
|
+
if (!view) return false;
|
|
192
|
+
return draftDecisionOf(request.links.find((link) => link.id === id), view, { by: "", at: "" }) === null;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** How many drafts in these requests still wait for a decision (drafts not yet loaded count as waiting). */
|
|
197
|
+
export function draftsToDecide(requests: ReviewRequest[], drafts: DraftLookup, missing: ReadonlySet<string> = new Set()): number {
|
|
198
|
+
return requests.reduce((total, request) => total + draftIdsOf(request).filter((id) => !missing.has(id) && (!drafts[id] || undecidedDraftIds(request, drafts).includes(id))).length, 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The links with one draft's decision recorded. Links for drafts the request did not name are not invented. */
|
|
202
|
+
export function withDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): ReviewLink[] {
|
|
203
|
+
const named = request.links.some((link) => link.id === draftId);
|
|
204
|
+
const links = named ? request.links : [...request.links, { type: "proposal", id: draftId }];
|
|
205
|
+
return links.map((link) => (link.id === draftId ? { ...link, decision } : link));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The update that records a decision on one draft while the request stays open. Applied with the condition state = open. */
|
|
209
|
+
export function draftDecisionUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision): Record<string, unknown> {
|
|
210
|
+
const history = [...request.history, { at: decision.at, event: "draft_decided", by: decision.by, note: `${decision.choice} ${draftId}` }].slice(-50);
|
|
211
|
+
return { id: request.id, links: JSON.stringify(withDecision(request, draftId, decision)), updatedAt: decision.at, history: JSON.stringify(history) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function quote(text: string): string {
|
|
215
|
+
return `"${text.replace(/"/g, "'")}"`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The words of an answer that covers several drafts: each rejection and each Why in a sentence, the plain Accepts of a mixed batch in one more. */
|
|
219
|
+
export function draftFeedback(decisions: readonly (DraftDecision & { id: string })[]): string | null {
|
|
220
|
+
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
221
|
+
const lines = decisions
|
|
222
|
+
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
223
|
+
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
224
|
+
if (rejected.length > 0 && rejected.length < decisions.length) {
|
|
225
|
+
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
226
|
+
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join(" ") || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The update that changes only the Why of a draft that is already decided. The Why is stored where the decision is: on the request's link
|
|
233
|
+
* for the draft and, once the request has closed, in its answer (`drafts` and the `feedback` sentence the idea pipeline reads back).
|
|
234
|
+
* The choice, the steering chip, who decided and when stay as they were. Applied with the condition that the state is unchanged.
|
|
235
|
+
*/
|
|
236
|
+
export function draftReasonUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision, editor: string, now: Date): Record<string, unknown> {
|
|
237
|
+
const at = now.toISOString();
|
|
238
|
+
const why = decision.why?.trim() || null;
|
|
239
|
+
const next: DraftDecision = { ...decision, why };
|
|
240
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: `${decision.choice} ${draftId}` }].slice(-50);
|
|
241
|
+
const update: Record<string, unknown> = { id: request.id, links: JSON.stringify(withDecision(request, draftId, next)), updatedAt: at, history: JSON.stringify(history) };
|
|
242
|
+
if (request.answer?.drafts?.length && request.state === "answered") {
|
|
243
|
+
const known = request.answer.drafts;
|
|
244
|
+
const drafts = known.some((entry) => entry.id === draftId) ? known.map((entry) => (entry.id === draftId ? { ...entry, why } : entry)) : [...known, { ...next, id: draftId }];
|
|
245
|
+
update.answer = JSON.stringify({ ...request.answer, drafts, feedback: draftFeedback(drafts) });
|
|
246
|
+
}
|
|
247
|
+
return update;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 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,
|
|
252
|
+
* 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
|
|
253
|
+
* the idea pipeline reads back. Null while a draft is undecided, or when no draft of the request could be loaded.
|
|
254
|
+
*/
|
|
255
|
+
export function batchAnswer(request: ReviewRequest, drafts: DraftLookup, fallback: { by: string; at: string }): AnswerInput | null {
|
|
256
|
+
const ids = draftIdsOf(request).filter((id) => drafts[id]);
|
|
257
|
+
if (!ids.length) return null;
|
|
258
|
+
const decisions: (DraftDecision & { id: string })[] = [];
|
|
259
|
+
for (const id of ids) {
|
|
260
|
+
const decision = draftDecisionOf(request.links.find((link) => link.id === id), drafts[id], fallback);
|
|
261
|
+
if (!decision) return null;
|
|
262
|
+
decisions.push({ ...decision, title: decision.title ?? drafts[id]?.headline, id });
|
|
263
|
+
}
|
|
264
|
+
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
265
|
+
const mixed = rejected.length > 0 && rejected.length < decisions.length;
|
|
266
|
+
const links = request.links.some((link) => link.type === "proposal") ? request.links.map((link) => {
|
|
267
|
+
const decision = decisions.find((entry) => entry.id === link.id);
|
|
268
|
+
return decision ? { ...link, decision } : link;
|
|
269
|
+
}) : [...request.links, ...decisions.map((decision) => ({ type: "proposal", id: decision.id, decision }))];
|
|
270
|
+
return {
|
|
271
|
+
decision: !rejected.length ? "approve" : mixed ? "feedback" : "reject",
|
|
272
|
+
feedback: draftFeedback(decisions),
|
|
273
|
+
steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
|
|
274
|
+
drafts: decisions,
|
|
275
|
+
links,
|
|
276
|
+
};
|
|
277
|
+
}
|
package/lib/review-fixture.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import type { MonitoringEmailDetails, MonitoringEmailSummary } from "./monitoring-overview";
|
|
2
|
+
import type { DraftRow } from "./review-drafts";
|
|
1
3
|
import type { ReviewRequest } from "./review-requests";
|
|
2
4
|
|
|
3
5
|
/** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
|
|
4
6
|
export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
5
7
|
{
|
|
6
|
-
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "
|
|
8
|
+
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Accept this ruling summary before it is published",
|
|
7
9
|
ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
|
|
8
10
|
state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
|
|
9
11
|
appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
|
|
10
12
|
},
|
|
11
13
|
{
|
|
12
14
|
id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
|
|
13
|
-
ask: "
|
|
15
|
+
ask: "Accept or reject each draft.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
|
|
14
16
|
deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
|
|
15
17
|
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
18
|
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
@@ -42,4 +44,56 @@ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
|
42
44
|
answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
|
|
43
45
|
links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
|
|
44
46
|
},
|
|
47
|
+
{
|
|
48
|
+
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?",
|
|
49
|
+
raisedBy: "copy-editor", raisedAt: "2026-10-23T06:00:00Z", deadline: "2026-10-31T07:00:00Z", state: "open", answer: null, links: [], history: [], appliedIn: null,
|
|
50
|
+
updatedAt: "2026-10-23T06:00:00Z",
|
|
51
|
+
},
|
|
45
52
|
];
|
|
53
|
+
|
|
54
|
+
const TREND_BODY = (dek: string, section: string) => [
|
|
55
|
+
`${dek} [1][3]`, "", "## Signals cited", "",
|
|
56
|
+
"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`",
|
|
57
|
+
"2. Open weights: cluster, steady, 58 items `cluster:open-weights`", "", "## Sources", "",
|
|
58
|
+
"3. A sparse-attention model ships open weights (2026-10-20) https://example.org/sparse-attention",
|
|
59
|
+
"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.`, "",
|
|
60
|
+
].join("\n");
|
|
61
|
+
|
|
62
|
+
/** Demo and test drafts for the batch fixtures: a trend story, a monitor story and a draft that already has a decision. */
|
|
63
|
+
export const FIXTURE_DRAFTS: DraftRow[] = [
|
|
64
|
+
{
|
|
65
|
+
id: "proposal-1", kind: "story_draft", title: "Sparse attention goes open", summary: "Open-weight models are adopting sparse attention faster than closed labs reported.",
|
|
66
|
+
body: TREND_BODY("Open-weight models are adopting sparse attention faster than closed labs reported.", "Research"), state: "draft", held: false,
|
|
67
|
+
evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
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.",
|
|
71
|
+
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", "",
|
|
72
|
+
"Section: Research. Drafted by AI from an item the arxiv-cs monitor found. An editor approves the copy before anything is published.", ""].join("\n"),
|
|
73
|
+
state: "draft", held: false, evidence: { signals: 1, source: "arxiv-cs monitor: https://example.org/quantisation" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "proposal-3", kind: "story_draft", title: "Eight new models this week", summary: "A list of the week's releases.",
|
|
77
|
+
body: TREND_BODY("A list of the week's releases.", "News"), state: "editor_review", held: false,
|
|
78
|
+
evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Demo and test data: email submissions held for review (placeholders only, no real addresses). */
|
|
83
|
+
export const FIXTURE_HELD_EMAILS: MonitoringEmailSummary[] = [
|
|
84
|
+
{
|
|
85
|
+
receiptId: "receipt-held-1", observedAt: "2026-10-26T15:20:00Z", status: "held", subject: "Interesting preprint on retrieval for legal research",
|
|
86
|
+
envelopeSender: "bounce@mailer.example.test", headerFrom: "A Reader <reader@example.test>", warnings: ["dkim_failed"],
|
|
87
|
+
verdicts: { spf: "PASS", dkim: "FAIL", dmarc: "FAIL", spam: "PASS", virus: "PASS" }, linkCount: 2, processedCount: 0, unresolvedLinkCount: 0,
|
|
88
|
+
reviewRequired: true, errorCode: null, quarantine: { state: "quarantined", decision: null, failure: null },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export const FIXTURE_EMAIL_DETAILS: Record<string, MonitoringEmailDetails> = {
|
|
93
|
+
"receipt-held-1": {
|
|
94
|
+
submission: { ...FIXTURE_HELD_EMAILS[0], corpusId: null },
|
|
95
|
+
safeBodyText: "Hi,\n\nThought you would want to see this preprint on retrieval-augmented drafting for legal research. The authors compare three retrievers on 4,000 rulings.\n\nhttps://example.org/papers/retrieval-legal\nhttps://example.org/papers/retrieval-legal/data\n\nBest,\nA Reader",
|
|
96
|
+
candidateLinks: [{ url: "https://example.org/papers/retrieval-legal", title: "Retrieval for legal research" }, { url: "https://example.org/papers/retrieval-legal/data", title: "Dataset" }],
|
|
97
|
+
sourceMaterialIsUntrusted: true,
|
|
98
|
+
},
|
|
99
|
+
};
|
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,37 @@ 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
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The update that changes only the note of a request that already has an answer (a hard gate, a stage review, an acknowledged flag).
|
|
113
|
+
* The decision, the steering signal, who answered and when stay as they were. Applied with the condition that the state is unchanged,
|
|
114
|
+
* so it cannot overwrite a request that timed out or was withdrawn in the meantime.
|
|
115
|
+
*/
|
|
116
|
+
export function answerReasonUpdate(request: ReviewRequest, why: string, editor: string, now: Date): Record<string, unknown> {
|
|
117
|
+
if (!request.answer) throw new Error("This request has no answer yet; answer it first.");
|
|
118
|
+
const at = now.toISOString();
|
|
119
|
+
const answer: ReviewAnswer = { ...request.answer, feedback: why.trim() || null };
|
|
120
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: request.answer.decision }].slice(-MAX_HISTORY);
|
|
121
|
+
return { id: request.id, answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A request with a stored update applied in memory, for the demo and the fixtures; the live data API returns the saved row instead. */
|
|
125
|
+
export function applyReviewUpdate(request: ReviewRequest, update: Record<string, unknown>): ReviewRequest {
|
|
126
|
+
return {
|
|
127
|
+
...request,
|
|
128
|
+
...(typeof update.state === "string" ? { state: update.state as ReviewState } : {}),
|
|
129
|
+
...(update.answer ? { answer: JSON.parse(String(update.answer)) } : {}),
|
|
130
|
+
...(update.links ? { links: JSON.parse(String(update.links)) } : {}),
|
|
131
|
+
history: update.history ? JSON.parse(String(update.history)) : request.history,
|
|
132
|
+
updatedAt: String(update.updatedAt ?? request.updatedAt),
|
|
133
|
+
};
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
// --- words ------------------------------------------------------------------------------------------------
|
|
@@ -112,30 +146,49 @@ export function modeLabel(mode: ReviewMode): string {
|
|
|
112
146
|
return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
|
|
113
147
|
}
|
|
114
148
|
|
|
115
|
-
/**
|
|
116
|
-
export function
|
|
117
|
-
|
|
149
|
+
/** The proposal ids a request is about: its proposal links, or its subject when that is a proposal. Empty for every other request. */
|
|
150
|
+
export function draftIdsOf(request: Pick<ReviewRequest, "subject" | "links">): string[] {
|
|
151
|
+
const linked = request.links.filter((link) => link.type === "proposal" && link.id).map((link) => String(link.id));
|
|
152
|
+
const ids = linked.length ? linked : request.subject.type === "proposal" ? [request.subject.id] : [];
|
|
153
|
+
return [...new Set(ids)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A review of AI drafts: each draft is shown and decided in the card. Flags are never drafts to decide. */
|
|
157
|
+
export function isDraftReview(request: Pick<ReviewRequest, "mode" | "subject" | "links">): boolean {
|
|
158
|
+
return request.mode !== "flag" && draftIdsOf(request).length > 0;
|
|
118
159
|
}
|
|
119
160
|
|
|
120
161
|
/** What happens next without the reader. Only a hard gate says "Waiting for you". */
|
|
121
162
|
export function nextText(request: ReviewRequest): string {
|
|
163
|
+
const drafts = isDraftReview(request);
|
|
122
164
|
if (request.state === "open") {
|
|
123
|
-
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you
|
|
124
|
-
if (request.mode === "soft_review")
|
|
165
|
+
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you accept or reject.";
|
|
166
|
+
if (request.mode === "soft_review") {
|
|
167
|
+
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.";
|
|
168
|
+
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.";
|
|
169
|
+
}
|
|
125
170
|
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
126
171
|
}
|
|
127
|
-
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
172
|
+
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
173
|
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
129
174
|
const answer = request.answer;
|
|
130
175
|
return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
|
|
134
|
-
if (answer.
|
|
179
|
+
if (answer.drafts?.length) {
|
|
180
|
+
const accepted = answer.drafts.filter((draft) => draft.choice === "accept").length;
|
|
181
|
+
const rejected = answer.drafts.length - accepted;
|
|
182
|
+
const noun = (count: number) => `${count} ${count === 1 ? "draft" : "drafts"}`;
|
|
183
|
+
if (!rejected) return `Accepted ${noun(accepted)}`;
|
|
184
|
+
if (!accepted) return `Rejected ${noun(rejected)}`;
|
|
185
|
+
return `Accepted ${noun(accepted)} and rejected ${rejected}`;
|
|
186
|
+
}
|
|
135
187
|
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 "
|
|
188
|
+
if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Accepted";
|
|
189
|
+
if (answer.decision === "reject") return answer.steer === "not_this_kind" ? "Rejected as not this kind of thing" : "Rejected";
|
|
190
|
+
if (answer.steer === "not_this_kind") return "Rejected as not this kind of thing";
|
|
191
|
+
return "Answered with a note";
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
/** "You steered X, applied in run Y": what became of a person's input. */
|
|
@@ -153,11 +206,16 @@ export function subjectLabel(subject: { type: string; id: string }, titles: Read
|
|
|
153
206
|
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
154
207
|
}
|
|
155
208
|
|
|
209
|
+
/** The Proposals panel, opened on one proposal. */
|
|
210
|
+
export function proposalHref(proposalId: string, demo = false): string {
|
|
211
|
+
return getNewsroomNavHref(`/newsroom/administration/proposals?proposal=${encodeURIComponent(proposalId)}`, demo);
|
|
212
|
+
}
|
|
213
|
+
|
|
156
214
|
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
157
215
|
const id = encodeURIComponent(subject.id);
|
|
158
216
|
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
|
159
217
|
if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
|
|
160
|
-
if (subject.type === "proposal") return
|
|
218
|
+
if (subject.type === "proposal") return proposalHref(subject.id, demo);
|
|
161
219
|
if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
|
|
162
220
|
return null;
|
|
163
221
|
}
|
|
@@ -210,6 +268,9 @@ export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: R
|
|
|
210
268
|
|
|
211
269
|
export type NotificationSections = {
|
|
212
270
|
hardGates: ReviewRequest[];
|
|
271
|
+
/** Open soft reviews of AI drafts (each draft is decided in its card). */
|
|
272
|
+
draftBatches: ReviewRequest[];
|
|
273
|
+
/** Every other open soft review. */
|
|
213
274
|
softReviews: ReviewRequest[];
|
|
214
275
|
flags: ReviewRequest[];
|
|
215
276
|
/** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
|
|
@@ -221,26 +282,30 @@ export function sectionReviews(requests: ReviewRequest[]): NotificationSections
|
|
|
221
282
|
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
222
283
|
// Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
|
|
223
284
|
const bySoonestDeadline = (a: ReviewRequest, b: ReviewRequest) => (a.deadline && b.deadline ? a.deadline.localeCompare(b.deadline) : a.deadline ? -1 : b.deadline ? 1 : byRaised(a, b));
|
|
285
|
+
const soft = open.filter((request) => request.mode === "soft_review");
|
|
224
286
|
return {
|
|
225
287
|
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
226
|
-
|
|
288
|
+
draftBatches: soft.filter(isDraftReview).sort(bySoonestDeadline),
|
|
289
|
+
softReviews: soft.filter((request) => !isDraftReview(request)).sort(bySoonestDeadline),
|
|
227
290
|
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
228
291
|
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
229
292
|
};
|
|
230
293
|
}
|
|
231
294
|
|
|
232
|
-
export type NotificationSummary = { waiting: number;
|
|
295
|
+
export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
|
|
233
296
|
|
|
234
297
|
/**
|
|
235
|
-
* "N waiting for you · N
|
|
236
|
-
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts).
|
|
298
|
+
* "N waiting for you · N AI drafts to review · N other reviews open · N recently decided by the system".
|
|
299
|
+
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). `draftsToDecide` is the number of
|
|
300
|
+
* drafts in open draft reviews that nobody has decided yet.
|
|
237
301
|
*/
|
|
238
|
-
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
239
|
-
|
|
302
|
+
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number, draftsToDecide?: number): NotificationSummary {
|
|
303
|
+
const drafts = draftsToDecide ?? sections.draftBatches.reduce((total, request) => total + draftIdsOf(request).length, 0);
|
|
304
|
+
return { waiting: sections.hardGates.length + held.length, drafts, otherReviews: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
|
|
240
305
|
}
|
|
241
306
|
|
|
242
307
|
export function summaryText(summary: NotificationSummary): string {
|
|
243
|
-
return `${summary.waiting} waiting for you · ${summary.
|
|
308
|
+
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
309
|
}
|
|
245
310
|
|
|
246
311
|
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
package/lib/site-brand.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type SiteBrandId = string;
|
|
|
26
26
|
export type SiteBrandComponents = {
|
|
27
27
|
/** The mark shown at the top of the newsroom CMS sidebar in place of the app title. */
|
|
28
28
|
NewsroomLogo?: ComponentType<{ className?: string }>;
|
|
29
|
+
/** A compact mark for the mobile newsroom top bar. Without it the bar scales down `NewsroomLogo`. */
|
|
30
|
+
NewsroomLogoCompact?: ComponentType<{ className?: string }>;
|
|
29
31
|
PictogramFigure?: ComponentType<PictogramFigureProps>;
|
|
30
32
|
BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
|
|
31
33
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a source page lets another site show it in a frame. Most news sites refuse (X-Frame-Options or a CSP frame-ancestors
|
|
6
|
+
* rule), and a refused frame is just an empty box, which the browser cannot detect from outside. So the newsroom asks here,
|
|
7
|
+
* on the server, before it shows a web page's live preview.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** True when the response headers allow framing by any site. A rule naming specific origins is treated as a refusal. */
|
|
11
|
+
export function frameableFromHeaders(headers: Headers): boolean {
|
|
12
|
+
const xfo = headers.get("x-frame-options")?.trim().toLowerCase();
|
|
13
|
+
if (xfo && (xfo.includes("deny") || xfo.includes("sameorigin") || xfo.startsWith("allow-from"))) return false;
|
|
14
|
+
const csp = headers.get("content-security-policy");
|
|
15
|
+
if (csp) {
|
|
16
|
+
for (const directive of csp.split(";")) {
|
|
17
|
+
const [name, ...sources] = directive.trim().split(/\s+/);
|
|
18
|
+
if (name?.toLowerCase() !== "frame-ancestors") continue;
|
|
19
|
+
if (!sources.some((source) => source === "*" || source === "https:" || source === "http:")) return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Loopback, private, link-local and other non-public addresses, which the server must never be made to fetch. */
|
|
26
|
+
export function isPrivateAddress(address: string): boolean {
|
|
27
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
28
|
+
if (isIP(v4) === 4) {
|
|
29
|
+
const [a, b] = v4.split(".").map(Number);
|
|
30
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
31
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
32
|
+
}
|
|
33
|
+
const v6 = address.toLowerCase();
|
|
34
|
+
return v6 === "::" || v6 === "::1" || v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe80");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A plain public http(s) address on the default port whose host resolves only to public addresses, else null. */
|
|
38
|
+
export async function publicHttpUrl(raw: string): Promise<URL | null> {
|
|
39
|
+
let url: URL;
|
|
40
|
+
try { url = new URL(raw); } catch { return null; }
|
|
41
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.port || url.username || url.password) return null;
|
|
42
|
+
try {
|
|
43
|
+
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
|
|
44
|
+
return addresses.length && addresses.every(({ address }) => !isPrivateAddress(address)) ? url : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** GETs a public address, following up to `maxRedirects` redirects and checking every hop with publicHttpUrl. Null on any failure. */
|
|
51
|
+
export async function fetchPublic(raw: string, { accept, timeoutMs = 5000, maxRedirects = 4 }: { accept: string; timeoutMs?: number; maxRedirects?: number }): Promise<Response | null> {
|
|
52
|
+
let next = await publicHttpUrl(raw);
|
|
53
|
+
for (let hop = 0; next && hop <= maxRedirects; hop += 1) {
|
|
54
|
+
let response: Response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(next, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs), headers: { accept } });
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const location = response.headers.get("location");
|
|
61
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
62
|
+
void response.body?.cancel();
|
|
63
|
+
next = await publicHttpUrl(new URL(location, next).toString());
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Asks the page itself. Anything that fails (bad address, error status, timeout, too many redirects) counts as not frameable. */
|
|
72
|
+
export async function checkSourceFrameable(raw: string, options: { timeoutMs?: number; maxRedirects?: number } = {}): Promise<boolean> {
|
|
73
|
+
const response = await fetchPublic(raw, { accept: "text/html", ...options });
|
|
74
|
+
if (!response) return false;
|
|
75
|
+
void response.body?.cancel();
|
|
76
|
+
return response.ok && frameableFromHeaders(response.headers);
|
|
77
|
+
}
|