@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21

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.
Files changed (85) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/category-action/handler.ts +2 -1
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  8. package/amplify/functions/monitoring-view/schema.js +16 -0
  9. package/amplify/functions/monitoring-view/schema.ts +16 -0
  10. package/amplify/functions/procedure-action/handler.ts +2 -10
  11. package/amplify/functions/proposal-actions/proposals.py +20 -3
  12. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  13. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  14. package/bin/papyrus-app.mjs +12 -1
  15. package/bin/papyrus-infra.mjs +21 -1
  16. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  17. package/components/monitoring-overview-view.tsx +143 -21
  18. package/components/monitoring-panel.tsx +64 -4
  19. package/components/needs-me-bodies.tsx +290 -0
  20. package/components/needs-me-detail.tsx +23 -0
  21. package/components/needs-me-feed.tsx +561 -0
  22. package/components/needs-me-reference-detail.tsx +77 -0
  23. package/components/needs-me-shared.tsx +39 -0
  24. package/components/news-desk-client-provider.tsx +24 -3
  25. package/components/news-desk-page.tsx +2 -1
  26. package/components/news-desk-taxonomy-client.ts +2 -1
  27. package/components/newsroom-access-gate.tsx +7 -5
  28. package/components/newsroom-client-shell.tsx +2 -1
  29. package/components/newsroom-ops-overview.tsx +64 -55
  30. package/components/newsroom-ops-shell.tsx +4 -2
  31. package/components/newsroom-organizer.css +60 -2
  32. package/components/newsroom-references-view.tsx +301 -138
  33. package/components/notification-cards.tsx +369 -0
  34. package/components/notification-center.tsx +324 -213
  35. package/components/papyrus-console-shell.tsx +2 -1
  36. package/components/proposals-panel.tsx +21 -24
  37. package/components/proposals-view.tsx +4 -2
  38. package/components/reference-capture-client.ts +110 -0
  39. package/components/reference-capture-panel.tsx +180 -0
  40. package/components/reference-feed.tsx +503 -0
  41. package/components/reference-review-card.tsx +110 -141
  42. package/components/references-page-loading.tsx +61 -0
  43. package/components/references-prefetch.ts +52 -0
  44. package/components/review-rating-grid.tsx +180 -0
  45. package/components/topic-steering-workspace.tsx +44 -9
  46. package/components/use-media-query.ts +16 -0
  47. package/components/use-reference-preview-attachments.ts +101 -0
  48. package/components/use-reference-ratings.ts +134 -0
  49. package/infra/amplify-app-shell.js +18 -2
  50. package/infra/amplify-app-shell.ts +18 -2
  51. package/infra/chat-stack.js +222 -0
  52. package/infra/chat-stack.ts +250 -0
  53. package/infra/chat.test.js +142 -0
  54. package/infra/chat.test.ts +151 -0
  55. package/infra/index.js +1 -0
  56. package/infra/site-config.js +58 -1
  57. package/infra/site-config.ts +78 -1
  58. package/lib/category-repository.ts +6 -1
  59. package/lib/content-actions-client.ts +18 -0
  60. package/lib/local-preview-feed-fixture.ts +63 -0
  61. package/lib/local-preview-synthetic-rejects.ts +41 -0
  62. package/lib/monitoring-client.ts +29 -2
  63. package/lib/monitoring-overview.ts +52 -1
  64. package/lib/monitoring-paging.ts +58 -0
  65. package/lib/needs-me-items.ts +289 -0
  66. package/lib/needs-me-ranking.ts +315 -0
  67. package/lib/newsroom-chat-identity.ts +2 -1
  68. package/lib/newsroom-nav.ts +1 -1
  69. package/lib/newsroom-organizer-nav.ts +2 -1
  70. package/lib/notification-feed.ts +212 -0
  71. package/lib/proposals-client.ts +29 -0
  72. package/lib/reader-subject.ts +3 -2
  73. package/lib/reference-capture.ts +293 -0
  74. package/lib/reference-feed-cache.ts +170 -0
  75. package/lib/reference-feed-ranking.ts +235 -0
  76. package/lib/reference-preview.ts +112 -0
  77. package/lib/review-client.ts +55 -5
  78. package/lib/review-drafts.ts +252 -0
  79. package/lib/review-fixture.ts +56 -2
  80. package/lib/review-requests.ts +64 -24
  81. package/lib/site-brand.ts +2 -0
  82. package/lib/staff-groups.ts +23 -0
  83. package/lib/staging-gate.ts +2 -2
  84. package/package.json +29 -1
  85. package/routes.manifest.json +1 -1
@@ -0,0 +1,315 @@
1
+ /**
2
+ * How the "What needs me" feed is ordered, and which items the Cyclotron is confident enough about to decide on its own.
3
+ *
4
+ * The feed holds several kinds of item (a Reference, an AI draft, a story review, a held email, a capture that may be cut off, a flag).
5
+ * What they share is one question: where would a person's answer change the most? In plain words, as the page says them:
6
+ * 1. Hard gates first. Nothing continues until a person decides one, so it leads, oldest first.
7
+ * 2. Then the interim "value of your input" score:
8
+ * value = uncertainty x importance x freshness
9
+ * - uncertainty is 1 - |2P - 1|, where P is the Cyclotron's probability that you would Accept. A coin toss (P = 0.5) is 1, a
10
+ * certainty is 0. An item with no prediction is 0.5: we do not know how unsure we are.
11
+ * - importance is the item's own priority (a relevance score, or a story or section priority). It is 1 until a source gives one.
12
+ * - freshness is exp(-age / tau), the weight the References feed already uses; tau is learned from how old the References you
13
+ * accepted were when you accepted them (reference-feed-ranking.ts). With too few examples there is no decay.
14
+ * 3. Types are interleaved, never more than two of one type in a row while another type is waiting, so a long run of References
15
+ * does not bury the drafts and emails behind it.
16
+ * 4. Confident items do not ask for you. An item whose uncertainty is below the threshold goes to "Decided automatically", where it
17
+ * can be overruled. The threshold is the median uncertainty of the predictions you have historically confirmed; with fewer than
18
+ * 10 such examples there is no threshold and nothing is skipped. A hard gate, an item the Cyclotron itself sent to review, and
19
+ * an item with no prediction are never skipped.
20
+ *
21
+ * The scoring sits behind one interface, `AttentionModel`. A future Cyclotron "attention" decision (a model that predicts the value of
22
+ * an editor's input directly) replaces `interimAttention` and nothing else in the page changes.
23
+ */
24
+ import type { ReferenceRecord } from "./category-repository";
25
+ import { referenceLineageId } from "./newsroom-references";
26
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
27
+ import type { RelevanceDecisionView } from "./relevance-decisions";
28
+ import { acceptProbability, MINIMUM_FRESHNESS_EXAMPLES, type FeedFreshness } from "./reference-feed-ranking";
29
+
30
+ export type NeedsMeType = "reference" | "draft" | "stage_review" | "held_email" | "incomplete_capture" | "concept_proposal" | "flag";
31
+
32
+ export const NEEDS_ME_TYPES: readonly NeedsMeType[] = ["reference", "draft", "stage_review", "held_email", "incomplete_capture", "concept_proposal", "flag"];
33
+
34
+ /** The pills above the feed. */
35
+ export type NeedsMeFilter = "all" | "references" | "drafts" | "stories" | "email" | "captures" | "other";
36
+ export const NEEDS_ME_FILTERS: readonly NeedsMeFilter[] = ["all", "references", "drafts", "stories", "email", "captures", "other"];
37
+ export const NEEDS_ME_FILTER_LABELS: Record<NeedsMeFilter, string> = {
38
+ all: "All", references: "References", drafts: "Drafts", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
39
+ };
40
+
41
+ const FILTER_OF: Record<NeedsMeType, Exclude<NeedsMeFilter, "all">> = {
42
+ reference: "references", draft: "drafts", stage_review: "stories", held_email: "email", incomplete_capture: "captures", concept_proposal: "other", flag: "other",
43
+ };
44
+
45
+ export function filterOfType(type: NeedsMeType): Exclude<NeedsMeFilter, "all"> {
46
+ return FILTER_OF[type];
47
+ }
48
+
49
+ export function matchesFilter(item: Pick<NeedsMeItem, "type">, filter: NeedsMeFilter): boolean {
50
+ return filter === "all" || FILTER_OF[item.type] === filter;
51
+ }
52
+
53
+ /** The words for a type, used on the card and in the detail pane. */
54
+ export const NEEDS_ME_TYPE_LABELS: Record<NeedsMeType, string> = {
55
+ reference: "Reference", draft: "AI draft", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
56
+ };
57
+
58
+ export type NeedsMePrediction = {
59
+ label: "accept" | "reject";
60
+ /** P(the editor accepts). */
61
+ pAccept: number;
62
+ /** The confidence the Cyclotron recorded, when it did. */
63
+ confidence: number | null;
64
+ };
65
+
66
+ /** Open until a person decides; the three answers are the page's one vocabulary (a flag is acknowledged). */
67
+ export type NeedsMeDecisionState = "open" | "accepted" | "rejected" | "acknowledged";
68
+
69
+ export type NeedsMeItem<Payload = unknown> = {
70
+ type: NeedsMeType;
71
+ /** Unique across types. */
72
+ id: string;
73
+ title: string;
74
+ dek: string | null;
75
+ /** The picture for the card, or null for the source's fallback tile. */
76
+ image: string | null;
77
+ source: string;
78
+ host: string | null;
79
+ /** When it reached the newsroom, in milliseconds; null when unknown. */
80
+ createdAt: number | null;
81
+ prediction?: NeedsMePrediction;
82
+ /** The item's own priority. 1 until a source supplies one (a relevance score, a story or section priority). */
83
+ importance: number;
84
+ /** 1 - |2P - 1| with a prediction, 0.5 without. */
85
+ uncertainty: number;
86
+ decisionState: NeedsMeDecisionState;
87
+ /** What happens if the editor ignores it, in words. */
88
+ defaultOutcome: string;
89
+ /** Nothing continues until a person decides it. */
90
+ hardGate: boolean;
91
+ /** The Cyclotron or the step that raised it asked for a person: never decided automatically. */
92
+ askedForReview: boolean;
93
+ payload: Payload;
94
+ };
95
+
96
+ // --- the scoring --------------------------------------------------------------------------------------------------------
97
+
98
+ export const UNKNOWN_UNCERTAINTY = 0.5;
99
+
100
+ /** 1 - |2P - 1|: 1 at a coin toss, 0 at a certainty. 0.5 when there is no prediction. */
101
+ export function uncertaintyOf(pAccept: number | undefined): number {
102
+ if (pAccept === undefined || !Number.isFinite(pAccept)) return UNKNOWN_UNCERTAINTY;
103
+ return 1 - Math.abs(2 * Math.min(1, Math.max(0, pAccept)) - 1);
104
+ }
105
+
106
+ export function predictionOf(decision: RelevanceDecisionView | undefined): NeedsMePrediction | undefined {
107
+ if (!decision) return undefined;
108
+ const pAccept = acceptProbability(decision);
109
+ return { label: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", pAccept, confidence: decision.confidence };
110
+ }
111
+
112
+ const DAY_MILLISECONDS = 86_400_000;
113
+
114
+ /** exp(-age / tau) at `now`; 1 without a learned tau or an unknown arrival. The References feed's own weight. */
115
+ export function freshnessAt(createdAt: number | null, freshness: FeedFreshness, now: number): number {
116
+ if (freshness.tauDays === null || createdAt === null) return 1;
117
+ return Math.exp(-Math.max(now - createdAt, 0) / DAY_MILLISECONDS / freshness.tauDays);
118
+ }
119
+
120
+ export type AttentionContext = { now: number; freshness: FeedFreshness };
121
+
122
+ /**
123
+ * The value of this editor's input on one item. Higher is earlier. The interim model below is a formula; a Cyclotron "attention"
124
+ * decision implements the same interface and is passed to `rankNeedsMe` in its place.
125
+ */
126
+ export interface AttentionModel {
127
+ readonly id: string;
128
+ value(item: NeedsMeItem, context: AttentionContext): number;
129
+ }
130
+
131
+ export const interimAttention: AttentionModel = {
132
+ id: "interim-value-of-input",
133
+ value: (item, { now, freshness }) => item.uncertainty * item.importance * freshnessAt(item.createdAt, freshness, now),
134
+ };
135
+
136
+ // --- confident items ----------------------------------------------------------------------------------------------------
137
+
138
+ export const MINIMUM_CONFIRMED_EXAMPLES = MINIMUM_FRESHNESS_EXAMPLES;
139
+
140
+ export type ConfidenceThreshold = {
141
+ /** Null with fewer than MINIMUM_CONFIRMED_EXAMPLES confirmed predictions: nothing is guessed, nothing is skipped. */
142
+ threshold: number | null;
143
+ /** How many predictions the editor has confirmed. */
144
+ examples: number;
145
+ };
146
+
147
+ /**
148
+ * Uncertainty below which the Cyclotron decides without you: the median uncertainty of the predictions you confirmed (the Reference you
149
+ * accepted or rejected agrees with what the Cyclotron said). Half of what you tend to confirm is then confident enough to skip.
150
+ * `ignore` keeps the item an editor just rated from moving the very feed they are looking at.
151
+ */
152
+ export function learnConfidenceThreshold(
153
+ references: ReferenceRecord[],
154
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
155
+ ignore: ReadonlySet<string> = new Set(),
156
+ ): ConfidenceThreshold {
157
+ const confirmed: number[] = [];
158
+ for (const reference of references) {
159
+ const lineageId = referenceLineageId(reference);
160
+ const decision = decisions?.get(lineageId);
161
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
162
+ if (!decision || ignore.has(lineageId) || (status !== "accepted" && status !== "rejected")) continue;
163
+ const prediction = predictionOf(decision);
164
+ if (prediction && prediction.label === (status === "accepted" ? "accept" : "reject")) confirmed.push(uncertaintyOf(prediction.pAccept));
165
+ }
166
+ if (confirmed.length < MINIMUM_CONFIRMED_EXAMPLES) return { threshold: null, examples: confirmed.length };
167
+ confirmed.sort((left, right) => left - right);
168
+ const middle = Math.floor(confirmed.length / 2);
169
+ return { threshold: confirmed.length % 2 ? confirmed[middle] : (confirmed[middle - 1] + confirmed[middle]) / 2, examples: confirmed.length };
170
+ }
171
+
172
+ /** True when the Cyclotron is sure enough that the item does not need a person. */
173
+ export function isConfident(item: NeedsMeItem, threshold: number | null): boolean {
174
+ return threshold !== null && item.decisionState === "open" && !item.hardGate && !item.askedForReview && item.prediction !== undefined && item.uncertainty < threshold;
175
+ }
176
+
177
+ // --- the order ----------------------------------------------------------------------------------------------------------
178
+
179
+ export const MAXIMUM_RUN = 2;
180
+
181
+ /**
182
+ * Apple-News-style mixing: keep the order, but never more than `maxRun` of one type in a row while another type is waiting. When a run
183
+ * is full, the highest-ranked item of a different type goes next. `prior` is what already sits in front of this list.
184
+ */
185
+ export function interleaveByType<T extends { type: NeedsMeType }>(ordered: readonly T[], maxRun = MAXIMUM_RUN, prior: readonly NeedsMeType[] = []): T[] {
186
+ const remaining = [...ordered];
187
+ const placed: T[] = [];
188
+ const history = [...prior];
189
+ while (remaining.length) {
190
+ const last = history[history.length - 1];
191
+ let run = 0;
192
+ for (let index = history.length - 1; index >= 0 && history[index] === last; index -= 1) run += 1;
193
+ let pick = 0;
194
+ if (last !== undefined && run >= maxRun) {
195
+ const alternative = remaining.findIndex((item) => item.type !== last);
196
+ if (alternative >= 0) pick = alternative;
197
+ }
198
+ const [next] = remaining.splice(pick, 1);
199
+ placed.push(next);
200
+ history.push(next.type);
201
+ }
202
+ return placed;
203
+ }
204
+
205
+ export type NeedsMeRanking<P = unknown> = {
206
+ /** Hard gates first, then everything that asks for a person, interleaved. */
207
+ feed: NeedsMeItem<P>[];
208
+ /** Confident items: decided by the Cyclotron unless overruled. Newest first. */
209
+ auto: NeedsMeItem<P>[];
210
+ scores: ReadonlyMap<string, number>;
211
+ threshold: ConfidenceThreshold;
212
+ };
213
+
214
+ function newestFirst(left: NeedsMeItem, right: NeedsMeItem): number {
215
+ return (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.id.localeCompare(right.id);
216
+ }
217
+
218
+ export function rankNeedsMe<P>(
219
+ items: readonly NeedsMeItem<P>[],
220
+ options: { now: number; freshness: FeedFreshness; threshold: ConfidenceThreshold; attention?: AttentionModel },
221
+ ): NeedsMeRanking<P> {
222
+ const attention = options.attention ?? interimAttention;
223
+ const context: AttentionContext = { now: options.now, freshness: options.freshness };
224
+ const scores = new Map(items.map((item) => [item.id, attention.value(item, context)]));
225
+ const score = (item: NeedsMeItem) => scores.get(item.id) ?? 0;
226
+ const gates = items.filter((item) => item.hardGate).sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0) || left.id.localeCompare(right.id));
227
+ const rest = items.filter((item) => !item.hardGate);
228
+ const auto = rest.filter((item) => isConfident(item, options.threshold.threshold)).sort(newestFirst);
229
+ const autoIds = new Set(auto.map((item) => item.id));
230
+ const asking = rest.filter((item) => !autoIds.has(item.id)).sort((left, right) => score(right) - score(left) || newestFirst(left, right));
231
+ const mixed = interleaveByType(asking, MAXIMUM_RUN, gates.map((item) => item.type));
232
+ return { feed: [...gates, ...mixed], auto, scores, threshold: options.threshold };
233
+ }
234
+
235
+ // --- one order per visit ------------------------------------------------------------------------------------------------
236
+
237
+ /**
238
+ * The order of the feed, taken once per visit when it has loaded and then left alone: selecting, deciding, a background refresh and a
239
+ * cache revalidation never re-rank it. Items that arrive later are counted as new and wait behind an "N new items: show" pill.
240
+ * The same rule as the References feed.
241
+ */
242
+ export type NeedsMeSnapshot = { known: ReadonlySet<string>; feed: string[]; auto: string[] };
243
+
244
+ export function takeNeedsMeSnapshot(ranking: Pick<NeedsMeRanking, "feed" | "auto">): NeedsMeSnapshot {
245
+ return {
246
+ known: new Set([...ranking.feed, ...ranking.auto].map((item) => item.id)),
247
+ feed: ranking.feed.map((item) => item.id),
248
+ auto: ranking.auto.map((item) => item.id),
249
+ };
250
+ }
251
+
252
+ /** The live items placed where the snapshot put them. Their content stays current; their places do not. */
253
+ export function applyNeedsMeSnapshot<P>(snapshot: NeedsMeSnapshot, live: Pick<NeedsMeRanking<P>, "feed" | "auto">): { feed: NeedsMeItem<P>[]; auto: NeedsMeItem<P>[]; newCount: number } {
254
+ const byId = new Map<string, NeedsMeItem<P>>();
255
+ for (const item of [...live.auto, ...live.feed]) byId.set(item.id, item);
256
+ const pick = (ids: string[]) => ids.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
257
+ const newCount = [...live.feed, ...live.auto].filter((item) => !snapshot.known.has(item.id) && item.decisionState === "open").length;
258
+ return { feed: pick(snapshot.feed), auto: pick(snapshot.auto), newCount };
259
+ }
260
+
261
+ // --- counts and "you're shaping this" -----------------------------------------------------------------------------------
262
+
263
+ export type NeedsMeCounts = Record<NeedsMeFilter, number>;
264
+
265
+ /** What waits for a person, by pill: only open items count. */
266
+ export function countNeedsMe(items: readonly Pick<NeedsMeItem, "type" | "decisionState">[]): NeedsMeCounts {
267
+ const counts: NeedsMeCounts = { all: 0, references: 0, drafts: 0, stories: 0, email: 0, captures: 0, other: 0 };
268
+ for (const item of items) {
269
+ if (item.decisionState !== "open") continue;
270
+ counts.all += 1;
271
+ counts[FILTER_OF[item.type]] += 1;
272
+ }
273
+ return counts;
274
+ }
275
+
276
+ /** Monday 00:00 UTC of the week that contains `now`: "this week" is a calendar week, not a rolling window. */
277
+ export function weekStartUtc(now: number): number {
278
+ const date = new Date(now);
279
+ const day = (date.getUTCDay() + 6) % 7; // Monday = 0
280
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() - day);
281
+ }
282
+
283
+ export type ShapingStats = {
284
+ /** Everything you decided this week, of any type. */
285
+ decisions: number;
286
+ /** Of the References you rated this week that had a prediction: you agreed with the Cyclotron. */
287
+ agreed: number;
288
+ /** ... and you overruled it. */
289
+ overruled: number;
290
+ };
291
+
292
+ /** The "You're shaping this" strip. `decidedAt` lists when each decision was made (see recentDecisions in notification-feed.ts). */
293
+ export function shapingThisWeek(input: {
294
+ decidedAt: readonly string[];
295
+ references: ReferenceRecord[];
296
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined;
297
+ now: number;
298
+ }): ShapingStats {
299
+ const since = weekStartUtc(input.now);
300
+ const thisWeek = (iso: string | null | undefined) => {
301
+ const time = iso ? Date.parse(iso) : NaN;
302
+ return Number.isFinite(time) && time >= since && time <= input.now;
303
+ };
304
+ let agreed = 0;
305
+ let overruled = 0;
306
+ for (const reference of input.references) {
307
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
308
+ if ((status !== "accepted" && status !== "rejected") || !thisWeek(reference.curationStatusUpdatedAt)) continue;
309
+ const prediction = predictionOf(input.decisions?.get(referenceLineageId(reference)));
310
+ if (!prediction) continue;
311
+ if (prediction.label === (status === "accepted" ? "accept" : "reject")) agreed += 1;
312
+ else overruled += 1;
313
+ }
314
+ return { decisions: input.decidedAt.filter(thisWeek).length, agreed, overruled };
315
+ }
@@ -2,6 +2,7 @@ import { CognitoJwtVerifier } from "aws-jwt-verify";
2
2
  import type { JwksCache } from "aws-jwt-verify/jwk";
3
3
  import type { NextRequest } from "next/server";
4
4
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
5
+ import { STAFF_GROUPS } from "./staff-groups";
5
6
 
6
7
  export type NewsroomChatIdentity = { subject: string; issuer: string; clientId: string; expiresAt: number };
7
8
  export type NewsroomChatIdentityResolver = (request: NextRequest) => Promise<NewsroomChatIdentity | null>;
@@ -14,7 +15,7 @@ export function newsroomChatIdentityResolver(
14
15
  userPoolId: auth.user_pool_id,
15
16
  clientId: auth.user_pool_client_id,
16
17
  tokenUse: "access",
17
- groups: ["editor", "admin"],
18
+ groups: [...STAFF_GROUPS],
18
19
  }, jwksCache ? { jwksCache } : undefined);
19
20
  return async (request) => {
20
21
  const token = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
21
21
  };
22
22
 
23
23
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
- { id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
24
+ { id: "overview", label: "Needs me", detail: "Newsroom home", href: newsroomHref() },
25
25
  { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
26
26
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
27
27
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
@@ -8,9 +8,10 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
- { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
11
+ { key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
+ { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
14
15
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
16
  ] },
16
17
  { title: "Research & knowledge", items: [
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The Notification Center as a feed of cards ("What needs me?"): which cards there are, how they are counted, filtered and
3
+ * worded. Pure functions, so the page and its tests share them. A review request that covers several AI drafts is not shown as a
4
+ * batch: each draft is its own card, with a small caption saying which run it came from. The request still closes itself when
5
+ * its last draft is decided (components/notification-center.tsx).
6
+ */
7
+ import type { ReferenceRecord } from "./category-repository";
8
+ import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
9
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
10
+ import type { ReferencePreview } from "./reference-preview";
11
+ import { draftDecisionOf, type DraftView } from "./review-drafts";
12
+ import { draftIdsOf, isDraftReview, proposalHref, sectionReviews, type DraftDecision, type ReviewRequest } from "./review-requests";
13
+ import { getNewsroomNavHref } from "./newsroom-nav";
14
+
15
+ export type DraftItem = {
16
+ kind: "draft";
17
+ /** `<request id>#<draft id>`. */
18
+ key: string;
19
+ request: ReviewRequest;
20
+ draftId: string;
21
+ /** Undefined while the Proposal is still loading. */
22
+ view: DraftView | undefined;
23
+ decision: DraftDecision | null;
24
+ };
25
+
26
+ /** A request that is decided as a whole: a hard gate, a soft review of something that is not a draft, or a flag. */
27
+ export type RequestItem = { kind: "request"; key: string; request: ReviewRequest };
28
+
29
+ export type NotificationItems = {
30
+ drafts: DraftItem[];
31
+ /** Hard gates first, then the other soft reviews, soonest deadline first. */
32
+ reviews: RequestItem[];
33
+ /** For your information: flags. */
34
+ flags: RequestItem[];
35
+ };
36
+
37
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
38
+
39
+ /** "Oct 27", in UTC so the words do not change with the reader's time zone. */
40
+ export function shortDate(iso: string | null | undefined): string {
41
+ if (!iso) return "";
42
+ const date = new Date(iso);
43
+ if (Number.isNaN(date.getTime())) return "";
44
+ return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
45
+ }
46
+
47
+ function sentenceCase(slug: string): string {
48
+ const spaced = slug.replace(/[-_.]+/g, " ").trim();
49
+ return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : "";
50
+ }
51
+
52
+ /** Where a request came from, in words: the run its batch names ("Monitor stories run"), else the step that raised it. */
53
+ export function requestSource(request: Pick<ReviewRequest, "subject" | "raisedBy">): string {
54
+ if (request.subject.type === "batch") {
55
+ const named = request.subject.id.replace(/-\d{8}t\d{6}z.*$/i, "").replace(/-[0-9a-f]{6,}$/i, "");
56
+ if (/[a-z]{3,}/i.test(named)) return sentenceCase(named);
57
+ }
58
+ return sentenceCase(request.raisedBy) || "An automated step";
59
+ }
60
+
61
+ /** The small caption on a card that came out of a request: "from Monitor stories run · Oct 27". */
62
+ export function requestCaption(request: Pick<ReviewRequest, "subject" | "raisedBy" | "raisedAt">): string {
63
+ const when = shortDate(request.raisedAt);
64
+ return `from ${requestSource(request)}${when ? ` · ${when}` : ""}`;
65
+ }
66
+
67
+ const NO_FALLBACK = { by: "", at: "" } as const;
68
+
69
+ /**
70
+ * The cards for review requests. A draft review becomes one card per draft: undecided drafts of open requests, plus drafts decided in
71
+ * this visit (`stay`, keyed `<request id>#<draft id>`) so a card does not vanish under the editor's thumb. Drafts that are not Proposals
72
+ * on this site cannot be opened here; when none can, the request itself is one card that takes Accept or Reject.
73
+ */
74
+ export function buildNotificationItems(input: {
75
+ requests: ReviewRequest[];
76
+ drafts: Readonly<Record<string, DraftView | undefined>>;
77
+ missingDrafts: ReadonlySet<string>;
78
+ draftsState: "loading" | "ready" | "failed";
79
+ /** Draft keys and request ids decided in this visit. */
80
+ stay: ReadonlySet<string>;
81
+ }): NotificationItems {
82
+ const sections = sectionReviews(input.requests);
83
+ const closedHere = input.requests.filter((request) => request.state !== "open" && input.stay.has(request.id));
84
+ const draftItems: DraftItem[] = [];
85
+ const requestLevel: ReviewRequest[] = [];
86
+ const draftRequests = [...sections.draftBatches, ...input.requests.filter((request) => request.state !== "open" && isDraftReview(request) && draftIdsOf(request).some((id) => input.stay.has(`${request.id}#${id}`)))];
87
+ for (const request of draftRequests) {
88
+ const available = draftIdsOf(request).filter((id) => !input.missingDrafts.has(id));
89
+ if (request.state === "open" && input.draftsState === "ready" && available.length === 0) {
90
+ requestLevel.push(request);
91
+ continue;
92
+ }
93
+ for (const draftId of available) {
94
+ const key = `${request.id}#${draftId}`;
95
+ const view = input.drafts[draftId];
96
+ const decision = draftDecisionOf(request.links.find((link) => link.id === draftId), view, NO_FALLBACK);
97
+ const waiting = request.state === "open" && !decision;
98
+ if (waiting || input.stay.has(key)) draftItems.push({ kind: "draft", key, request, draftId, view, decision });
99
+ }
100
+ }
101
+ const asItem = (request: ReviewRequest): RequestItem => ({ kind: "request", key: request.id, request });
102
+ const standing = (request: ReviewRequest) => request.state === "open" || input.stay.has(request.id);
103
+ const reviews = [
104
+ ...sections.hardGates,
105
+ ...sections.softReviews,
106
+ ...requestLevel,
107
+ ...closedHere.filter((request) => request.mode !== "flag" && !isDraftReview(request)),
108
+ ].filter(standing).map(asItem);
109
+ const flags = [...sections.flags, ...closedHere.filter((request) => request.mode === "flag")].filter(standing).map(asItem);
110
+ return {
111
+ drafts: draftItems,
112
+ reviews: [...new Map(reviews.map((item) => [item.key, item])).values()],
113
+ flags: [...new Map(flags.map((item) => [item.key, item])).values()],
114
+ };
115
+ }
116
+
117
+ // --- sources, pictures ----------------------------------------------------------------------------------------
118
+
119
+ function normalizedAddress(address: string): string {
120
+ try {
121
+ const url = new URL(address);
122
+ return `${url.hostname.replace(/^www\./i, "").toLowerCase()}${url.pathname.replace(/\/+$/, "")}${url.search}`;
123
+ } catch {
124
+ return address.trim().toLowerCase();
125
+ }
126
+ }
127
+
128
+ /** The previews of References by the address they were saved from, so a draft's cited source finds its picture. */
129
+ export function previewsBySourceAddress(references: ReferenceRecord[], previews: ReadonlyMap<string, ReferencePreview>): Map<string, ReferencePreview> {
130
+ const found = new Map<string, ReferencePreview>();
131
+ for (const reference of references) {
132
+ const preview = previews.get(referenceLineageId(reference));
133
+ if (preview && reference.sourceUri) found.set(normalizedAddress(reference.sourceUri), preview);
134
+ }
135
+ return found;
136
+ }
137
+
138
+ /** The first cited source that has a picture, with the site name its preview gives. */
139
+ export function draftPicture(view: DraftView | undefined, bySource: ReadonlyMap<string, ReferencePreview>): { imageUrl: string | null; siteName: string | null; host: string | null } {
140
+ const sources = view?.sources ?? [];
141
+ const host = sources.find((source) => source.url)?.url ? sourceHost(sources.find((source) => source.url)!.url!) : null;
142
+ for (const source of sources) {
143
+ const preview = source.url ? bySource.get(normalizedAddress(source.url)) : undefined;
144
+ if (preview?.imageUrl) return { imageUrl: preview.imageUrl, siteName: preview.siteName, host: source.url ? sourceHost(source.url) : host };
145
+ }
146
+ return { imageUrl: null, siteName: null, host };
147
+ }
148
+
149
+ export function sourceHost(address: string): string | null {
150
+ try {
151
+ return new URL(address).hostname.replace(/^www\./i, "") || null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ // --- what you decided ------------------------------------------------------------------------------------------
158
+
159
+ export type RecentDecision = {
160
+ key: string;
161
+ kind: "draft" | "reference" | "request";
162
+ word: "Accepted" | "Rejected" | "Acknowledged" | "Answered";
163
+ choice: "accept" | "reject" | "acknowledge" | "answer";
164
+ title: string;
165
+ at: string;
166
+ href: string | null;
167
+ /** The editor's why, when there is one. */
168
+ note: string | null;
169
+ };
170
+
171
+ function requestDecision(request: ReviewRequest): Pick<RecentDecision, "word" | "choice"> | null {
172
+ const answer = request.answer;
173
+ if (request.state !== "answered" || !answer) return null;
174
+ if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return { word: "Acknowledged", choice: "acknowledge" };
175
+ if (answer.decision === "approve") return { word: "Accepted", choice: "accept" };
176
+ if (answer.decision === "reject") return { word: "Rejected", choice: "reject" };
177
+ return { word: "Answered", choice: "answer" };
178
+ }
179
+
180
+ /** What the editor decided most recently, newest first: drafts, other requests and References. "Last few", so a small default. */
181
+ export function recentDecisions(requests: ReviewRequest[], references: ReferenceRecord[], demo = false, limit = 5): RecentDecision[] {
182
+ const found = new Map<string, RecentDecision>();
183
+ for (const request of requests) {
184
+ for (const link of request.links) {
185
+ if (!link.decision || !link.id) continue;
186
+ found.set(`${request.id}#${link.id}`, {
187
+ key: `${request.id}#${link.id}`, kind: "draft", word: link.decision.choice === "accept" ? "Accepted" : "Rejected", choice: link.decision.choice,
188
+ title: link.decision.title ?? link.label ?? link.id, at: link.decision.at, href: proposalHref(link.id, demo), note: link.decision.why ?? null,
189
+ });
190
+ }
191
+ for (const entry of request.answer?.drafts ?? []) {
192
+ found.set(`${request.id}#${entry.id}`, {
193
+ key: `${request.id}#${entry.id}`, kind: "draft", word: entry.choice === "accept" ? "Accepted" : "Rejected", choice: entry.choice,
194
+ title: entry.title ?? entry.id, at: entry.at, href: proposalHref(entry.id, demo), note: entry.why ?? null,
195
+ });
196
+ }
197
+ const whole = requestDecision(request);
198
+ if (whole && !request.answer?.drafts?.length) {
199
+ found.set(request.id, { key: request.id, kind: "request", ...whole, title: request.reason, at: request.answer!.at, href: null, note: request.answer!.feedback });
200
+ }
201
+ }
202
+ for (const reference of references) {
203
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
204
+ if ((status !== "accepted" && status !== "rejected") || !reference.curationStatusUpdatedAt) continue;
205
+ const lineageId = referenceLineageId(reference);
206
+ found.set(`reference#${lineageId}`, {
207
+ key: `reference#${lineageId}`, kind: "reference", word: status === "accepted" ? "Accepted" : "Rejected", choice: status === "accepted" ? "accept" : "reject",
208
+ title: referenceDisplayTitle(reference), at: reference.curationStatusUpdatedAt, href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo), note: null,
209
+ });
210
+ }
211
+ return [...found.values()].filter((entry) => entry.at).sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
212
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { EDIT_PROPOSAL, TRANSITION_PROPOSAL, type ProposalMove, type ProposalReceipt } from "./proposals-admin";
5
+
6
+ type GraphResult = { data?: Record<string, ProposalReceipt | null>; errors?: { message?: string }[] };
7
+
8
+ async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
9
+ const client = generateClient({ authMode: "userPool" });
10
+ const result = (await client.graphql({ query, variables })) as GraphResult;
11
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
12
+ return result.data?.[field] ?? { status: "refused", message: "The action did not answer." };
13
+ }
14
+
15
+ /** Moves a proposal through `path`, one `transitionProposal` call per state, stopping at the first refusal. */
16
+ export async function transitionProposalPath(proposalId: string, path: readonly string[], reason = ""): Promise<ProposalReceipt> {
17
+ let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
18
+ for (const state of path) {
19
+ receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
20
+ if (receipt.status !== "ok") break;
21
+ }
22
+ return receipt;
23
+ }
24
+
25
+ /** One button of the Proposals panel: a path of states, or `editProposal` for pin and hold. */
26
+ export async function performProposalMove(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
27
+ if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
28
+ return transitionProposalPath(proposalId, move.path ?? [], reason);
29
+ }
@@ -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 = ["editor", "admin"] as const;
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.some((group) => (READER_STAFF_GROUPS as readonly unknown[]).includes(group));
50
+ return Array.isArray(groups) && hasEditorRights(groups);
50
51
  } catch {
51
52
  return false;
52
53
  }