@anthusai/papyrus 5.42.1 → 5.43.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +22 -0
- package/amplify/functions/monitoring-view/schema.ts +22 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +13 -72
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +140 -23
- package/components/monitoring-panel.tsx +96 -6
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +139 -257
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +14 -42
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +107 -0
- package/components/proposals-view.tsx +95 -0
- package/components/reference-review-card.tsx +400 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -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/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +39 -3
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +47 -3
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +40 -0
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/relevance-decisions.ts +9 -0
- 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 +66 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +16 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.mjs +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveNewsroomChatIdentity, type NewsroomChatIdentityResolver } from "./newsroom-chat-identity";
|
|
1
2
|
import { NextResponse, type NextRequest } from "next/server";
|
|
2
3
|
import {
|
|
3
4
|
CYCLOTRON_LAB_PRODUCT,
|
|
@@ -36,6 +37,7 @@ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfigured
|
|
|
36
37
|
* unregistered deployment never half-signs anyone in.
|
|
37
38
|
*/
|
|
38
39
|
export interface ProspectusSignInRouteOptions {
|
|
40
|
+
resolveNewsroomIdentity?: NewsroomChatIdentityResolver;
|
|
39
41
|
resolveRuntime?: (siteOrigin: string) => Promise<ProspectusSignInRuntime | null>;
|
|
40
42
|
siteProduct?: string | null;
|
|
41
43
|
completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
|
|
@@ -137,12 +139,25 @@ export function handleProspectusSignInStart(request: NextRequest, options: Prosp
|
|
|
137
139
|
async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
|
|
138
140
|
const runtime = await signInRuntimeFor(request, options);
|
|
139
141
|
if (!runtime) return signInUnavailableResponse();
|
|
142
|
+
const newsroomConnection = request.nextUrl.searchParams.get("newsroomChat") === "true";
|
|
143
|
+
let newsroomSubject: string | undefined;
|
|
144
|
+
if (newsroomConnection) {
|
|
145
|
+
if (request.method !== "POST" || request.headers.get("origin") !== siteOriginFor(request, options)) {
|
|
146
|
+
return signInFailedResponse(403, "same_origin_required", "Connect chat from this newsroom.");
|
|
147
|
+
}
|
|
148
|
+
const identity = await (options.resolveNewsroomIdentity ?? resolveNewsroomChatIdentity)(request).catch(() => null);
|
|
149
|
+
if (!identity) return signInFailedResponse(401, "sign_in_required", "An authenticated newsroom editor is required.");
|
|
150
|
+
newsroomSubject = identity.subject;
|
|
151
|
+
}
|
|
140
152
|
const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
|
|
141
153
|
const pendingSignInCookie: PendingProspectusSignInCookie = {
|
|
142
154
|
...pendingSignIn,
|
|
143
|
-
|
|
155
|
+
...(newsroomSubject ? { newsroomSubject } : {}),
|
|
156
|
+
returnPath: sameSiteReturnPath(newsroomConnection ? request.nextUrl.searchParams.get("returnTo") : await requestedReturnPath(request)),
|
|
144
157
|
};
|
|
145
|
-
const response =
|
|
158
|
+
const response = newsroomConnection
|
|
159
|
+
? NextResponse.json({ authorizationUrl }, { headers: NO_STORE_HEADERS })
|
|
160
|
+
: NextResponse.redirect(authorizationUrl, { status: 303, headers: NO_STORE_HEADERS });
|
|
146
161
|
response.cookies.set(
|
|
147
162
|
PROSPECTUS_PENDING_SIGN_IN_COOKIE,
|
|
148
163
|
sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_PENDING_SIGN_IN_COOKIE, pendingSignInCookie, pendingSignIn.expiresAt),
|
|
@@ -194,11 +209,13 @@ async function completeCallback(
|
|
|
194
209
|
pendingSignIn,
|
|
195
210
|
returnedState,
|
|
196
211
|
authorizationCode,
|
|
212
|
+
expectedNewsroomSubject: pendingSignIn.newsroomSubject,
|
|
197
213
|
nowInSeconds,
|
|
198
214
|
...options.completionOverrides,
|
|
199
215
|
});
|
|
200
216
|
const session: ProspectusSignedInSession = {
|
|
201
217
|
personId: signedIn.person.id,
|
|
218
|
+
...(pendingSignIn.newsroomSubject ? { newsroomSubject: pendingSignIn.newsroomSubject } : {}),
|
|
202
219
|
accessToken: signedIn.accessToken,
|
|
203
220
|
accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
|
|
204
221
|
...(signedIn.person.siteSubject ? { siteSubject: signedIn.person.siteSubject } : {}),
|
|
@@ -281,6 +281,7 @@ export interface CompleteProspectusGoogleSignInInput {
|
|
|
281
281
|
pendingSignIn: PendingProspectusSignIn;
|
|
282
282
|
returnedState: string;
|
|
283
283
|
authorizationCode: string;
|
|
284
|
+
expectedNewsroomSubject?: string;
|
|
284
285
|
verifyCognitoTokens?: CognitoTokenVerifier;
|
|
285
286
|
fetcher?: typeof fetch;
|
|
286
287
|
nowInSeconds?: number;
|
|
@@ -320,6 +321,12 @@ export async function completeProspectusGoogleSignIn(
|
|
|
320
321
|
expiresInSeconds: Number(tokenBody.expires_in) || 0,
|
|
321
322
|
};
|
|
322
323
|
await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
|
|
324
|
+
if (input.expectedNewsroomSubject) {
|
|
325
|
+
const claims = JSON.parse(Buffer.from(tokens.accessToken.split(".")[1], "base64url").toString("utf8")) as { sub?: string };
|
|
326
|
+
if (claims.sub !== input.expectedNewsroomSubject) {
|
|
327
|
+
throw new ProspectusAccountsError(403, "newsroom_identity_mismatch", "Continue with the same Google account as your newsroom session.");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
323
330
|
const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
|
|
324
331
|
return {
|
|
325
332
|
person,
|
package/lib/reader-subject.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import type { ProspectusSignedInSession } from "./prospectus-session";
|
|
3
|
+
import { hasEditorRights, STAFF_GROUPS } from "./staff-groups";
|
|
3
4
|
|
|
4
5
|
export const SITE_SUBJECT_PATTERN = /^ss1_[A-Za-z0-9_-]{43}$/;
|
|
5
6
|
export const INTERIM_SUBJECT_PATTERN = /^ssdev_[A-Za-z0-9_-]{43}$/;
|
|
6
7
|
export const READER_DEV_SECRET_ENV = "PAPYRUS_READER_DEV_SECRET";
|
|
7
|
-
export const READER_STAFF_GROUPS =
|
|
8
|
+
export const READER_STAFF_GROUPS = STAFF_GROUPS;
|
|
8
9
|
|
|
9
10
|
const MINIMUM_DEV_SECRET_LENGTH = 16;
|
|
10
11
|
const INTERIM_SUBJECT_PREFIX = "ssdev_";
|
|
@@ -46,7 +47,7 @@ export function readerIsStaff(session: Pick<ProspectusSignedInSession, "accessTo
|
|
|
46
47
|
try {
|
|
47
48
|
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { "cognito:groups"?: unknown };
|
|
48
49
|
const groups = claims["cognito:groups"];
|
|
49
|
-
return Array.isArray(groups) && groups
|
|
50
|
+
return Array.isArray(groups) && hasEditorRights(groups);
|
|
50
51
|
} catch {
|
|
51
52
|
return false;
|
|
52
53
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
2
3
|
|
|
3
4
|
export const RELEVANCE_RELATION = "relevance_decision_is";
|
|
4
5
|
|
|
@@ -162,3 +163,11 @@ export function curationForRelevanceReview(decision: RelevanceDecisionView, revi
|
|
|
162
163
|
shareable: review.shareable,
|
|
163
164
|
};
|
|
164
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A pending reference with a current cyclotron suggestion is waiting for an editor's answer,
|
|
169
|
+
* whether or not the cyclotron flagged the decision itself for review.
|
|
170
|
+
*/
|
|
171
|
+
export function awaitsEditorReview(curationStatus: unknown, decision: RelevanceDecisionView | undefined): boolean {
|
|
172
|
+
return Boolean(decision) && normalizeReferenceCurationStatus(curationStatus) === "pending";
|
|
173
|
+
}
|
package/lib/review-client.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import { draftDecisionUpdate, draftView, type DraftRow, type DraftView } from "./review-drafts";
|
|
5
6
|
import {
|
|
6
7
|
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
-
type AnswerInput, type ReviewRequest,
|
|
8
|
+
type AnswerInput, type DraftDecision, type ReviewRequest,
|
|
8
9
|
} from "./review-requests";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -118,3 +119,39 @@ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<s
|
|
|
118
119
|
}));
|
|
119
120
|
return { titles, deleted };
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
const MAX_DRAFT_LOOKUPS = 60;
|
|
124
|
+
const DRAFT_FIELDS = "id kind title summary body state held evidence history override createdAt";
|
|
125
|
+
|
|
126
|
+
/** Records one draft's decision on the request while it stays open. Same condition as an answer: state = open. */
|
|
127
|
+
export async function saveDraftDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): Promise<ReviewRequest> {
|
|
128
|
+
const result = await run(
|
|
129
|
+
`mutation Decide($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
130
|
+
{ input: draftDecisionUpdate(request, draftId, decision), condition: { state: { eq: "open" } } },
|
|
131
|
+
);
|
|
132
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
133
|
+
throw new Error("Someone else answered this, or it closed at its deadline. Refresh to see the result.");
|
|
134
|
+
}
|
|
135
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("The draft was decided, but the request could not record it. Try again.");
|
|
136
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The Proposals that review requests are about, by id (editors and admins may read them). `missing` ids have no proposal (an idea kept
|
|
143
|
+
* only as JSON has no proposal to open); `failed` ids could not be read right now, which says nothing about whether they exist.
|
|
144
|
+
*/
|
|
145
|
+
export async function loadDrafts(ids: string[], demo = false): Promise<DraftLoad> {
|
|
146
|
+
const drafts: Record<string, DraftView> = {};
|
|
147
|
+
const missing: string[] = [];
|
|
148
|
+
const failed: string[] = [];
|
|
149
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_DRAFT_LOOKUPS).map(async (id) => {
|
|
150
|
+
const result = await run(`query Draft($id: ID!) { getProposal(id: $id) { ${DRAFT_FIELDS} } }`, { id });
|
|
151
|
+
const row = result.data?.getProposal as DraftRow | null | undefined;
|
|
152
|
+
if (row) drafts[id] = draftView(row, demo);
|
|
153
|
+
else if (result.errors?.length || !result.data) failed.push(id);
|
|
154
|
+
else missing.push(id);
|
|
155
|
+
}));
|
|
156
|
+
return { drafts, missing, failed };
|
|
157
|
+
}
|
|
@@ -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. */
|
|
@@ -219,26 +255,32 @@ export type NotificationSections = {
|
|
|
219
255
|
export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
|
|
220
256
|
const open = requests.filter((request) => request.state === "open");
|
|
221
257
|
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
258
|
+
// Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
|
|
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");
|
|
222
261
|
return {
|
|
223
262
|
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
224
|
-
|
|
263
|
+
draftBatches: soft.filter(isDraftReview).sort(bySoonestDeadline),
|
|
264
|
+
softReviews: soft.filter((request) => !isDraftReview(request)).sort(bySoonestDeadline),
|
|
225
265
|
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
226
266
|
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
227
267
|
};
|
|
228
268
|
}
|
|
229
269
|
|
|
230
|
-
export type NotificationSummary = { waiting: number;
|
|
270
|
+
export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
|
|
231
271
|
|
|
232
272
|
/**
|
|
233
|
-
* "N waiting for you · N
|
|
234
|
-
* 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.
|
|
235
276
|
*/
|
|
236
|
-
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
237
|
-
|
|
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 };
|
|
238
280
|
}
|
|
239
281
|
|
|
240
282
|
export function summaryText(summary: NotificationSummary): string {
|
|
241
|
-
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`;
|
|
242
284
|
}
|
|
243
285
|
|
|
244
286
|
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|