@anthusai/papyrus 5.46.0 → 5.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -12,10 +12,6 @@ import { draftDecisionOf, type DraftView } from "./review-drafts";
12
12
  import { draftIdsOf, isDraftReview, proposalHref, sectionReviews, type DraftDecision, type ReviewRequest } from "./review-requests";
13
13
  import { getNewsroomNavHref } from "./newsroom-nav";
14
14
 
15
- export type NotificationFilter = "all" | "drafts" | "references" | "other";
16
- export const NOTIFICATION_FILTERS: readonly NotificationFilter[] = ["all", "drafts", "references", "other"];
17
- export const FILTER_LABELS: Record<NotificationFilter, string> = { all: "All", drafts: "Drafts", references: "References", other: "Other" };
18
-
19
15
  export type DraftItem = {
20
16
  kind: "draft";
21
17
  /** `<request id>#<draft id>`. */
@@ -36,11 +32,8 @@ export type NotificationItems = {
36
32
  reviews: RequestItem[];
37
33
  /** For your information: flags. */
38
34
  flags: RequestItem[];
39
- counts: NotificationCounts;
40
35
  };
41
36
 
42
- export type NotificationCounts = { drafts: number; references: number; other: number };
43
-
44
37
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
45
38
 
46
39
  /** "Oct 27", in UTC so the words do not change with the reader's time zone. */
@@ -85,7 +78,6 @@ export function buildNotificationItems(input: {
85
78
  draftsState: "loading" | "ready" | "failed";
86
79
  /** Draft keys and request ids decided in this visit. */
87
80
  stay: ReadonlySet<string>;
88
- referenceCount: number;
89
81
  }): NotificationItems {
90
82
  const sections = sectionReviews(input.requests);
91
83
  const closedHere = input.requests.filter((request) => request.state !== "open" && input.stay.has(request.id));
@@ -119,39 +111,9 @@ export function buildNotificationItems(input: {
119
111
  drafts: draftItems,
120
112
  reviews: [...new Map(reviews.map((item) => [item.key, item])).values()],
121
113
  flags: [...new Map(flags.map((item) => [item.key, item])).values()],
122
- counts: {
123
- drafts: draftItems.filter((item) => !item.decision).length,
124
- references: input.referenceCount,
125
- other: [...reviews, ...flags].filter((item) => item.request.state === "open").length,
126
- },
127
114
  };
128
115
  }
129
116
 
130
- function plural(count: number, one: string, many = `${one}s`): string {
131
- return `${count} ${count === 1 ? one : many}`;
132
- }
133
-
134
- /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
135
- export function waitingText(counts: NotificationCounts): string {
136
- const parts = [
137
- counts.drafts ? plural(counts.drafts, "draft") : null,
138
- counts.references ? plural(counts.references, "reference") : null,
139
- counts.other ? plural(counts.other, "other item") : null,
140
- ].filter((part): part is string => part !== null);
141
- if (!parts.length) return "All caught up";
142
- const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
143
- return `${list} waiting for you`;
144
- }
145
-
146
- export function filterCount(filter: NotificationFilter, counts: NotificationCounts): number {
147
- return filter === "all" ? counts.drafts + counts.references + counts.other : counts[filter];
148
- }
149
-
150
- /** Which sections a filter shows. */
151
- export function showsSection(filter: NotificationFilter, section: "drafts" | "references" | "other"): boolean {
152
- return filter === "all" || filter === section;
153
- }
154
-
155
117
  // --- sources, pictures ----------------------------------------------------------------------------------------
156
118
 
157
119
  function normalizedAddress(address: string): string {
@@ -103,10 +103,10 @@ export function dekAddsInformation(title: string, dek: string | null, imageUrl:
103
103
  return shared / dekWords.length < 0.6 && covered < 0.8;
104
104
  }
105
105
 
106
- /** Pictures that are a text card, not a photograph: a repository social card, or any picture whose ratio is almost exactly 2:1. Cropping cuts the words off. */
106
+ /** Pictures that are a text card, not a photograph: a repository social card, or any picture whose ratio is almost exactly 2:1 or the 1.91:1 Open Graph card (1200x630). Cropping cuts the words off. */
107
107
  export function isCardStyleImage(url: string | null, naturalRatio: number | null): boolean {
108
108
  let host = "";
109
109
  try { host = url ? new URL(url).hostname : ""; } catch { /* no host */ }
110
110
  if (/(^|\.)(opengraph\.githubassets\.com|repository-images\.githubusercontent\.com|github\.com)$/i.test(host)) return true;
111
- return naturalRatio !== null && Math.abs(naturalRatio - 2) < 0.04;
111
+ return naturalRatio !== null && (Math.abs(naturalRatio - 2) < 0.04 || Math.abs(naturalRatio - 1200 / 630) < 0.04);
112
112
  }
@@ -100,24 +100,37 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
100
100
  }
101
101
 
102
102
  const MAX_TITLE_LOOKUPS = 40;
103
+ /** The opening of a story, for a reviewer who has not opened it: the deck, then the first paragraphs of the body. */
104
+ const EXCERPT_CHARACTERS = 900;
105
+
106
+ export function storyExcerpt(deck: string | null | undefined, body: readonly (string | null)[] | null | undefined): string | null {
107
+ const text = [deck, ...(body ?? [])].map((part) => (part ?? "").trim()).filter(Boolean).join("\n\n");
108
+ if (!text) return null;
109
+ if (text.length <= EXCERPT_CHARACTERS) return text;
110
+ const cut = text.slice(0, EXCERPT_CHARACTERS);
111
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), EXCERPT_CHARACTERS - 80))}…`;
112
+ }
103
113
 
104
114
  /** Titles of the stories that review requests are about, by lineage id, and the ids of stories that no longer exist (no version of the lineage remains). A story that cannot be read simply has no entry (the card falls back to the id). */
105
- export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[] }> {
115
+ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }> {
106
116
  const titles: Record<string, string> = {};
117
+ const excerpts: Record<string, string> = {};
107
118
  const deleted: string[] = [];
108
119
  await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
109
120
  const result = await run(
110
- `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
121
+ `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title deck body } } }`,
111
122
  { lineageId: id },
112
123
  );
113
124
  // Only a clean, empty answer means the story is gone; a failed lookup says nothing.
114
125
  if (!result.errors?.length && result.data?.listItemsByLineageAndVersion && !(result.data.listItemsByLineageAndVersion.items ?? []).filter(Boolean).length) deleted.push(id);
115
- const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
126
+ const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string; deck?: string | null; body?: (string | null)[] | null }[];
116
127
  const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
117
128
  const title = newest?.headline || newest?.title;
118
129
  if (title) titles[id] = title;
130
+ const excerpt = storyExcerpt(newest?.deck, newest?.body);
131
+ if (excerpt) excerpts[id] = excerpt;
119
132
  }));
120
- return { titles, deleted };
133
+ return { titles, deleted, excerpts };
121
134
  }
122
135
 
123
136
  const MAX_DRAFT_LOOKUPS = 60;
@@ -28,6 +28,8 @@ export type DraftView = {
28
28
  kindLabel: string;
29
29
  headline: string;
30
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;
31
33
  section: string | null;
32
34
  /** The evidence in words: the signals it rests on, or the monitor that found its source. */
33
35
  evidence: string[];
@@ -104,6 +106,19 @@ function evidenceLines(row: DraftRow): string[] {
104
106
  return lines;
105
107
  }
106
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
+
107
122
  export function draftChoiceOf(state: string): DraftChoice | null {
108
123
  return state === "approved" ? "accept" : state === "declined" ? "reject" : null;
109
124
  }
@@ -119,6 +134,7 @@ export function draftView(row: DraftRow, demo = false): DraftView {
119
134
  kindLabel: KIND_WORDS[row.kind] ?? row.kind,
120
135
  headline: row.title,
121
136
  dek: row.summary?.trim() || null,
137
+ bodyText: draftBodyText(body),
122
138
  section,
123
139
  evidence: evidenceLines(row),
124
140
  sources: sources.slice(0, MAXIMUM_SOURCES),
@@ -1,3 +1,4 @@
1
+ import type { MonitoringEmailDetails, MonitoringEmailSummary } from "./monitoring-overview";
1
2
  import type { DraftRow } from "./review-drafts";
2
3
  import type { ReviewRequest } from "./review-requests";
3
4
 
@@ -77,3 +78,22 @@ export const FIXTURE_DRAFTS: DraftRow[] = [
77
78
  evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
78
79
  },
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/package.json CHANGED
@@ -91,6 +91,11 @@
91
91
  "./components/moderation-panel": "./components/moderation-panel.tsx",
92
92
  "./components/monitoring-overview-view": "./components/monitoring-overview-view.tsx",
93
93
  "./components/monitoring-panel": "./components/monitoring-panel.tsx",
94
+ "./components/needs-me-bodies": "./components/needs-me-bodies.tsx",
95
+ "./components/needs-me-detail": "./components/needs-me-detail.tsx",
96
+ "./components/needs-me-feed": "./components/needs-me-feed.tsx",
97
+ "./components/needs-me-reference-detail": "./components/needs-me-reference-detail.tsx",
98
+ "./components/needs-me-shared": "./components/needs-me-shared.tsx",
94
99
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
95
100
  "./components/news-desk-page": "./components/news-desk-page.tsx",
96
101
  "./components/news-desk-taxonomy-client": "./components/news-desk-taxonomy-client.ts",
@@ -181,8 +186,10 @@
181
186
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
182
187
  "./components/use-console-visibility": "./components/use-console-visibility.ts",
183
188
  "./components/use-editorial-checks": "./components/use-editorial-checks.ts",
189
+ "./components/use-media-query": "./components/use-media-query.ts",
184
190
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
185
191
  "./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
192
+ "./components/use-reference-preview-attachments": "./components/use-reference-preview-attachments.ts",
186
193
  "./components/use-reference-ratings": "./components/use-reference-ratings.ts",
187
194
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
188
195
  "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
@@ -263,6 +270,8 @@
263
270
  "./lib/monitoring-overview-fixture": "./lib/monitoring-overview-fixture.ts",
264
271
  "./lib/monitoring-overview": "./lib/monitoring-overview.ts",
265
272
  "./lib/monitoring-paging": "./lib/monitoring-paging.ts",
273
+ "./lib/needs-me-items": "./lib/needs-me-items.ts",
274
+ "./lib/needs-me-ranking": "./lib/needs-me-ranking.ts",
266
275
  "./lib/news-desk-session": "./lib/news-desk-session.ts",
267
276
  "./lib/newsletter-api": "./lib/newsletter-api.ts",
268
277
  "./lib/newsletter-client": "./lib/newsletter-client.ts",
@@ -486,7 +495,7 @@
486
495
  "optional": true
487
496
  }
488
497
  },
489
- "version": "5.46.0",
498
+ "version": "5.48.0",
490
499
  "dependencies": {
491
500
  "@aws-amplify/adapter-nextjs": "^1.7.3",
492
501
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.46.0",
2
+ "version": "5.48.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",