@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
@@ -37,9 +37,45 @@ export type MonitoringEmailSummary = {
37
37
  receiptId: string; observedAt: string | null; status: string; subject: string | null;
38
38
  envelopeSender: string | null; headerFrom: string | null; warnings: string[];
39
39
  verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
40
- reviewRequired: boolean; errorCode: string | null;
40
+ reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
41
+ /** Present when the same email was received more than once: one item, every copy listed. One decision covers all copies. */
42
+ group?: EmailCopyGroup;
43
+ };
44
+ export type EmailCopyGroup = {
45
+ fingerprint: string; count: number;
46
+ copies: { receiptId: string; observedAt: string | null; status: string; linkCount: number; processedCount: number; quarantine: EmailQuarantine | null }[];
47
+ };
48
+
49
+ /** Where a held email submission stands. `null` means it never needed review. */
50
+ export type EmailQuarantineState =
51
+ | "quarantined" | "release_requested" | "released" | "discard_requested" | "discarded"
52
+ | "expired" | "decision_failed" | "release_failed";
53
+ export type EmailQuarantine = {
54
+ state: EmailQuarantineState;
55
+ decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string; viaGroup?: boolean } | null;
56
+ failure: string | null;
57
+ };
58
+ export type EmailDecisionResult =
59
+ | { ok: true; already: boolean; quarantine: EmailQuarantine }
60
+ | { error: "forbidden" | "bad_request" | "already_decided" | "not_quarantined" | "expired"; quarantine?: EmailQuarantine }
61
+ | { state: "unavailable" | "not_configured" };
62
+
63
+ export const QUARANTINE_LABELS: Record<EmailQuarantineState, string> = {
64
+ quarantined: "Held for review",
65
+ release_requested: "Accepted, being queued",
66
+ released: "Accepted",
67
+ discard_requested: "Rejected, being closed",
68
+ discarded: "Rejected",
69
+ expired: "Expired",
70
+ decision_failed: "Decision could not be applied",
71
+ release_failed: "Accepted, retrying",
41
72
  };
42
73
 
74
+ /** Links and import are available only once a message is accepted (or never needed review). */
75
+ export function emailSubmissionAccepted(quarantine: EmailQuarantine | null): boolean {
76
+ return quarantine === null || quarantine.state === "released";
77
+ }
78
+
43
79
  export type MonitoringEmailDetails = {
44
80
  submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
45
81
  candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
@@ -47,6 +83,8 @@ export type MonitoringEmailDetails = {
47
83
 
48
84
  export type MonitoringEmailArticleResolution = {
49
85
  url: string; title: string; resolved: true;
86
+ /** Present when the original link could not be followed and the destination was found by searching the headline. */
87
+ foundVia?: "search"; search?: { query: string; matchScore: number; provider: string; matchedTitle?: string };
50
88
  } | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
51
89
 
52
90
  export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
@@ -112,6 +150,17 @@ export type MonitoringFeed = {
112
150
  nextDueAt: string | null;
113
151
  };
114
152
 
153
+ /** The lists on the page that are paged. Page sizes are the server's; there is no total. */
154
+ export type MonitoringListName = "runs" | "routerRuns" | "items" | "emailSubmissions";
155
+ export type MonitoringCursors = Partial<Record<MonitoringListName, string | null>>;
156
+ export type MonitoringListRows = {
157
+ runs: MonitoringRun; routerRuns: MonitoringRouterRun; items: MonitoringItem; emailSubmissions: MonitoringEmailSummary;
158
+ };
159
+ /** One more page of one list. `cursor` is null when the list has ended. */
160
+ export type MonitoringPage<L extends MonitoringListName = MonitoringListName> =
161
+ | { list: L; rows: MonitoringListRows[L][]; cursor: string | null; hiddenNotRelevant?: number }
162
+ | { error: "forbidden" | "bad_request" } | { state: "unavailable" | "not_configured" };
163
+
115
164
  export type MonitoringOverview = {
116
165
  state: "ready" | "empty";
117
166
  asOf: string;
@@ -132,6 +181,8 @@ export type MonitoringOverview = {
132
181
  otherMonitors?: string[];
133
182
  emailSubmissions?: MonitoringEmailSummary[];
134
183
  hiddenNotRelevant: number;
184
+ /** Where each list continues; null or missing when it has no more rows. */
185
+ cursors?: MonitoringCursors;
135
186
  };
136
187
 
137
188
  export type MonitoringResult =
@@ -0,0 +1,58 @@
1
+ /** Merging loaded pages into the Monitoring lists. Kept free of other imports so the panel's tests can load it alone. */
2
+ import type {
3
+ MonitoringCursors,
4
+ MonitoringItem,
5
+ MonitoringListName,
6
+ MonitoringListRows,
7
+ MonitoringOverview,
8
+ } from "./monitoring-overview";
9
+
10
+ /** Rows the editor has loaded beyond the first page of each list, and where each list continues. */
11
+ export type MonitoringLoadedPages = Partial<{ [L in MonitoringListName]: { rows: MonitoringListRows[L][]; cursor: string | null } }>;
12
+
13
+ const ROW_KEYS: { [L in MonitoringListName]: (row: MonitoringListRows[L]) => string } = {
14
+ runs: (row) => `${row.runId}`,
15
+ routerRuns: (row) => `${row.runId}`,
16
+ items: (row) => `${row.monitorId ?? ""}|${row.sourceId}`,
17
+ emailSubmissions: (row) => row.receiptId,
18
+ };
19
+
20
+ /**
21
+ * The first page followed by loaded pages, with a row seen twice kept once. The refresh re-reads the first page, so
22
+ * a row that moved onto it from a later page must not be listed again; for items the highest revision wins.
23
+ */
24
+ export function mergeListPages<L extends MonitoringListName>(list: L, first: MonitoringListRows[L][], loaded: MonitoringListRows[L][]): MonitoringListRows[L][] {
25
+ const keyOf = ROW_KEYS[list] as (row: MonitoringListRows[L]) => string;
26
+ const seen = new Map<string, number>();
27
+ const merged: MonitoringListRows[L][] = [];
28
+ for (const row of [...first, ...loaded]) {
29
+ const key = keyOf(row);
30
+ const at = seen.get(key);
31
+ if (at === undefined) {
32
+ seen.set(key, merged.length);
33
+ merged.push(row);
34
+ } else if (list === "items" && (row as MonitoringItem).revision > (merged[at] as MonitoringItem).revision) {
35
+ merged[at] = row;
36
+ }
37
+ }
38
+ return merged;
39
+ }
40
+
41
+ /**
42
+ * What the page shows: the refreshed first page of every list plus whatever the editor loaded, with each list's
43
+ * cursor. With `held` the email list is replaced by the held-for-review list (its own pages).
44
+ */
45
+ export function withLoadedPages(overview: MonitoringOverview, loaded: MonitoringLoadedPages, held: MonitoringLoadedPages["emailSubmissions"] | null): MonitoringOverview {
46
+ const cursors: MonitoringCursors = {};
47
+ const lists: Partial<Record<MonitoringListName, unknown[]>> = {};
48
+ for (const list of ["runs", "routerRuns", "items", "emailSubmissions"] as const) {
49
+ const extra = loaded[list];
50
+ lists[list] = mergeListPages(list, (overview[list] ?? []) as never[], (extra?.rows ?? []) as never[]) as unknown[];
51
+ cursors[list] = extra ? extra.cursor : overview.cursors?.[list] ?? null;
52
+ }
53
+ if (held) {
54
+ lists.emailSubmissions = held.rows;
55
+ cursors.emailSubmissions = held.cursor;
56
+ }
57
+ return { ...overview, ...(lists as Pick<MonitoringOverview, MonitoringListName>), cursors };
58
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The adapters that turn what already exists into `NeedsMeItem`s, the one model the "What needs me" feed ranks and renders.
3
+ * Each adapter only reads; deciding is routed back to the existing action for that source (see components/notification-center.tsx).
4
+ *
5
+ * reference a pending Reference reads the Reference, the Cyclotron's decision on it, its preview
6
+ * incomplete_capture a pending Reference whose captured text is reads the same, plus the Reference's capture status (the
7
+ * missing or cut off `curation` card, else the extracted-text attachments)
8
+ * draft one AI draft in an open draft review reads the review request and the Proposal (headline, dek, sources)
9
+ * stage_review an open hard gate or soft review that is reads the review request (reason, ask, deadline, subject) and the
10
+ * not about drafts subject story's title and excerpt
11
+ * flag an open flag reads the review request
12
+ * held_email an email submission held for review reads the monitoring email summary (subject, sender, verdicts)
13
+ * concept_proposal a proposed taxonomy concept or reader STUB: typed, returns nothing until those sources reach the page
14
+ * proposal
15
+ */
16
+ import type { ReferenceRecord } from "./category-repository";
17
+ import type { MonitoringEmailSummary } from "./monitoring-overview";
18
+ import { buildNotificationItems, draftPicture, requestSource, type DraftItem, type RequestItem } from "./notification-feed";
19
+ import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
20
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
21
+ import { dekAddsInformation, referenceDek, referenceSourceHost, type ReferencePreview } from "./reference-preview";
22
+ import { referenceArrivalTime, referenceDisplayTime } from "./reference-feed-ranking";
23
+ import type { CaptureStatus } from "./reference-capture";
24
+ import type { RelevanceDecisionView } from "./relevance-decisions";
25
+ import type { DraftView } from "./review-drafts";
26
+ import { nextText, type ReviewRequest } from "./review-requests";
27
+ import { predictionOf, uncertaintyOf, type NeedsMeCounts, type NeedsMeDecisionState, type NeedsMeItem } from "./needs-me-ranking";
28
+
29
+ export type NeedsMePayload =
30
+ | { kind: "reference"; reference: ReferenceRecord; lineageId: string; decision: RelevanceDecisionView | undefined; preview: ReferencePreview | undefined; capture: CaptureStatus | undefined; displayTime: number | null }
31
+ | { kind: "draft"; item: DraftItem; loading: boolean }
32
+ | { kind: "request"; request: ReviewRequest; storyTitle: string | null; excerpt: string | null; storyDeleted: boolean }
33
+ | { kind: "email"; summary: MonitoringEmailSummary }
34
+ | { kind: "concept"; row: ConceptProposalRow };
35
+
36
+ export type NeedsMeEntry = NeedsMeItem<NeedsMePayload>;
37
+
38
+ const ACCEPT_WORD = { accept: "Accept", reject: "Reject" } as const;
39
+
40
+ function timeOf(value: string | null | undefined): number | null {
41
+ const parsed = value ? Date.parse(value) : NaN;
42
+ return Number.isFinite(parsed) ? parsed : null;
43
+ }
44
+
45
+ function percent(confidence: number | null): string {
46
+ return confidence === null ? "" : ` ${Math.round(confidence * 100)}%`;
47
+ }
48
+
49
+ // --- references and captures ----------------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Whether a capture was attempted and came up short, and nobody has said otherwise: text that is thin (too short, or showing signs of
53
+ * being cut off or paywalled), or a curation card that records a failed capture. A Reference nobody has tried to capture yet (the
54
+ * address only, as monitoring discovers it) is not "incomplete": there is nothing to check.
55
+ */
56
+ export function captureIsIncomplete(capture: CaptureStatus | undefined): boolean {
57
+ if (!capture || capture.assessment) return false;
58
+ return capture.state === "thin" || (capture.state === "none" && capture.source === "curation");
59
+ }
60
+
61
+ export function referenceDefaultOutcome(decision: RelevanceDecisionView | undefined): string {
62
+ const prediction = predictionOf(decision);
63
+ return prediction
64
+ ? `If you leave it, it stays pending with the Cyclotron's suggestion: ${ACCEPT_WORD[prediction.label]}${percent(prediction.confidence)}.`
65
+ : "If you leave it, it stays pending. Nothing uses it until someone rates it.";
66
+ }
67
+
68
+ export type ReferenceSources = {
69
+ references: ReferenceRecord[];
70
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined;
71
+ previews: ReadonlyMap<string, ReferencePreview>;
72
+ captures: ReadonlyMap<string, CaptureStatus> | undefined;
73
+ /** Lineage ids rated in this visit: they keep their place showing the answer. */
74
+ visitRated: ReadonlySet<string>;
75
+ };
76
+
77
+ /** Pending References (and the ones rated in this visit), as `reference` items, or `incomplete_capture` when the capture is cut off. */
78
+ export function referenceItems(input: ReferenceSources): NeedsMeEntry[] {
79
+ const items: NeedsMeEntry[] = [];
80
+ for (const reference of input.references) {
81
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
82
+ const lineageId = referenceLineageId(reference);
83
+ if (status === "archived") continue;
84
+ const decided = status === "accepted" || status === "rejected";
85
+ if (decided && !input.visitRated.has(lineageId)) continue;
86
+ const decision = input.decisions?.get(lineageId);
87
+ const preview = input.previews.get(lineageId);
88
+ const capture = input.captures?.get(lineageId);
89
+ const prediction = predictionOf(decision);
90
+ const title = referenceDisplayTitle(reference);
91
+ const dek = referenceDek(reference, preview);
92
+ const host = referenceSourceHost(reference);
93
+ const incomplete = !decided && captureIsIncomplete(capture);
94
+ items.push({
95
+ type: incomplete ? "incomplete_capture" : "reference",
96
+ id: lineageId,
97
+ title,
98
+ dek: incomplete ? "The captured text looks cut off or missing, so the Cyclotron cannot judge it well." : dekAddsInformation(title, dek, preview?.imageUrl ?? null) ? dek : null,
99
+ image: preview?.imageUrl ?? null,
100
+ source: preview?.siteName ?? host ?? "Unknown source",
101
+ host,
102
+ createdAt: referenceArrivalTime(reference) ?? referenceDisplayTime(reference),
103
+ ...(prediction ? { prediction } : {}),
104
+ importance: 1,
105
+ uncertainty: uncertaintyOf(prediction?.pAccept),
106
+ decisionState: status === "accepted" ? "accepted" : status === "rejected" ? "rejected" : "open",
107
+ defaultOutcome: incomplete ? "If you leave it, the Reference stays pending with the text it has." : referenceDefaultOutcome(decision),
108
+ hardGate: false,
109
+ askedForReview: Boolean(decision?.reviewRecommended),
110
+ payload: { kind: "reference", reference, lineageId, decision, preview, capture, displayTime: referenceDisplayTime(reference) },
111
+ });
112
+ }
113
+ return items;
114
+ }
115
+
116
+ // --- drafts, stories, flags -------------------------------------------------------------------------------------
117
+
118
+ function choiceState(choice: "accept" | "reject" | null | undefined): NeedsMeDecisionState {
119
+ return choice === "accept" ? "accepted" : choice === "reject" ? "rejected" : "open";
120
+ }
121
+
122
+ export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, ReferencePreview>): NeedsMeEntry {
123
+ const { view, request } = item;
124
+ const picture = draftPicture(view, bySource);
125
+ const link = request.links.find((entry) => entry.id === item.draftId);
126
+ return {
127
+ type: "draft",
128
+ id: item.key,
129
+ title: view?.headline ?? link?.label ?? "Draft",
130
+ dek: view ? view.dek : "Loading the draft…",
131
+ image: picture.imageUrl,
132
+ source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "AI draft"),
133
+ host: picture.host,
134
+ createdAt: timeOf(request.raisedAt),
135
+ importance: 1,
136
+ uncertainty: uncertaintyOf(undefined),
137
+ decisionState: choiceState(item.decision?.choice),
138
+ defaultOutcome: nextText(request),
139
+ hardGate: request.mode === "hard_gate",
140
+ askedForReview: true,
141
+ payload: { kind: "draft", item, loading: !view },
142
+ };
143
+ }
144
+
145
+ function requestDecisionState(request: ReviewRequest): NeedsMeDecisionState {
146
+ if (request.state === "open") return "open";
147
+ const answer = request.answer;
148
+ if (!answer) return "accepted";
149
+ if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return "acknowledged";
150
+ if (answer.decision === "reject" || (answer.decision === "feedback" && answer.steer === "not_this_kind")) return "rejected";
151
+ return "accepted";
152
+ }
153
+
154
+ export function requestItem(
155
+ item: RequestItem,
156
+ context: { titles: Readonly<Record<string, string>>; excerpts: Readonly<Record<string, string>>; deleted: ReadonlySet<string> },
157
+ ): NeedsMeEntry {
158
+ const { request } = item;
159
+ const storyId = request.subject.type === "story" ? request.subject.id : null;
160
+ const flag = request.mode === "flag";
161
+ return {
162
+ type: flag ? "flag" : "stage_review",
163
+ id: request.id,
164
+ title: request.reason,
165
+ dek: request.ask,
166
+ image: null,
167
+ source: requestSource(request),
168
+ host: null,
169
+ createdAt: timeOf(request.raisedAt),
170
+ importance: 1,
171
+ uncertainty: uncertaintyOf(undefined),
172
+ decisionState: requestDecisionState(request),
173
+ defaultOutcome: nextText(request),
174
+ hardGate: request.mode === "hard_gate",
175
+ askedForReview: true,
176
+ payload: {
177
+ kind: "request",
178
+ request,
179
+ storyTitle: storyId ? context.titles[storyId] ?? null : null,
180
+ excerpt: storyId ? context.excerpts[storyId] ?? null : null,
181
+ storyDeleted: storyId ? context.deleted.has(storyId) : false,
182
+ },
183
+ };
184
+ }
185
+
186
+ // --- held email --------------------------------------------------------------------------------------------------
187
+
188
+ function senderDomain(summary: MonitoringEmailSummary): string | null {
189
+ const from = summary.headerFrom || summary.envelopeSender || "";
190
+ const address = from.match(/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/);
191
+ return address ? address[1].toLowerCase() : null;
192
+ }
193
+
194
+ /** The decision recorded on a receipt, else the one made in this visit. */
195
+ export function emailDecisionState(summary: MonitoringEmailSummary, decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeDecisionState {
196
+ const made = decided.get(summary.receiptId) ?? summary.quarantine?.decision?.action;
197
+ return choiceState(made);
198
+ }
199
+
200
+ /** Held email submissions: untrusted, so only the subject and sender facts are used; the body is read in the detail pane as inert text. */
201
+ export function heldEmailItems(summaries: readonly MonitoringEmailSummary[], decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeEntry[] {
202
+ const items: NeedsMeEntry[] = [];
203
+ for (const summary of summaries) {
204
+ const held = summary.quarantine?.state === "quarantined";
205
+ const decision = emailDecisionState(summary, decided);
206
+ if (!held && !decided.has(summary.receiptId)) continue;
207
+ const domain = senderDomain(summary);
208
+ items.push({
209
+ type: "held_email",
210
+ id: `email#${summary.receiptId}`,
211
+ title: summary.subject?.trim() || "(no subject)",
212
+ dek: summary.warnings.length ? `Held for review: ${summary.warnings.join(", ")}.` : `Held for review. ${summary.linkCount} candidate ${summary.linkCount === 1 ? "link" : "links"}.`,
213
+ image: null,
214
+ source: domain ?? "Email submission",
215
+ host: domain,
216
+ createdAt: timeOf(summary.observedAt),
217
+ importance: 1,
218
+ uncertainty: uncertaintyOf(undefined),
219
+ decisionState: decision,
220
+ defaultOutcome: "If you leave it, it stays held privately. Nothing is routed from it unless you accept it.",
221
+ hardGate: false,
222
+ askedForReview: true,
223
+ payload: { kind: "email", summary },
224
+ });
225
+ }
226
+ return items;
227
+ }
228
+
229
+ // --- stubs ---------------------------------------------------------------------------------------------------------
230
+
231
+ /** A proposed taxonomy concept or a reader's proposal. Not on the page yet: the source is typed so the adapter has a place to land. */
232
+ export type ConceptProposalRow = { id: string; title: string; summary: string | null; proposedBy: "cyclotron" | "reader"; createdAt: string | null };
233
+
234
+ export function conceptProposalItems(_rows: readonly ConceptProposalRow[]): NeedsMeEntry[] {
235
+ return [];
236
+ }
237
+
238
+ // --- all of it -----------------------------------------------------------------------------------------------------
239
+
240
+ export type NeedsMeSources = ReferenceSources & {
241
+ requests: ReviewRequest[];
242
+ drafts: Readonly<Record<string, DraftView | undefined>>;
243
+ missingDrafts: ReadonlySet<string>;
244
+ draftsState: "loading" | "ready" | "failed";
245
+ /** Draft keys and request ids decided in this visit. */
246
+ stay: ReadonlySet<string>;
247
+ bySource: ReadonlyMap<string, ReferencePreview>;
248
+ storyTitles: Readonly<Record<string, string>>;
249
+ storyExcerpts: Readonly<Record<string, string>>;
250
+ deletedStories: ReadonlySet<string>;
251
+ emails: readonly MonitoringEmailSummary[];
252
+ emailsDecided: ReadonlyMap<string, "accept" | "reject">;
253
+ concepts?: readonly ConceptProposalRow[];
254
+ };
255
+
256
+ /** Every source, as one list of items (unordered: `rankNeedsMe` orders it). */
257
+ export function buildNeedsMeItems(sources: NeedsMeSources): NeedsMeEntry[] {
258
+ const fromRequests = buildNotificationItems({
259
+ requests: sources.requests, drafts: sources.drafts, missingDrafts: sources.missingDrafts, draftsState: sources.draftsState, stay: sources.stay,
260
+ });
261
+ const context = { titles: sources.storyTitles, excerpts: sources.storyExcerpts, deleted: sources.deletedStories };
262
+ return [
263
+ ...referenceItems(sources),
264
+ ...fromRequests.drafts.map((item) => draftItem(item, sources.bySource)),
265
+ ...fromRequests.reviews.map((item) => requestItem(item, context)),
266
+ ...fromRequests.flags.map((item) => requestItem(item, context)),
267
+ ...heldEmailItems(sources.emails, sources.emailsDecided),
268
+ ...conceptProposalItems(sources.concepts ?? []),
269
+ ];
270
+ }
271
+
272
+ function plural(count: number, one: string, many = `${one}s`): string {
273
+ return `${count} ${count === 1 ? one : many}`;
274
+ }
275
+
276
+ /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
277
+ export function waitingTextFor(counts: NeedsMeCounts): string {
278
+ const parts = [
279
+ counts.drafts ? plural(counts.drafts, "draft") : null,
280
+ counts.references ? plural(counts.references, "reference") : null,
281
+ counts.stories ? plural(counts.stories, "story review") : null,
282
+ counts.email ? plural(counts.email, "held email", "held emails") : null,
283
+ counts.captures ? plural(counts.captures, "capture to check", "captures to check") : null,
284
+ counts.other ? plural(counts.other, "other item") : null,
285
+ ].filter((part): part is string => part !== null);
286
+ if (!parts.length) return "All caught up";
287
+ const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
288
+ return `${list} waiting for you`;
289
+ }