@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) 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 +16 -8
  6. package/amplify/functions/content-actions/handler.py +8 -0
  7. package/amplify/functions/content-actions/requirements.txt +2 -0
  8. package/amplify/functions/content-actions/resource.js +1 -1
  9. package/amplify/functions/content-actions/resource.ts +1 -1
  10. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  11. package/amplify/functions/monitoring-view/schema.js +16 -0
  12. package/amplify/functions/monitoring-view/schema.ts +16 -0
  13. package/amplify/functions/procedure-action/handler.ts +2 -10
  14. package/amplify/functions/proposal-actions/proposals.py +20 -3
  15. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  16. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  17. package/app/api/newsroom/source-frameable/route.ts +12 -0
  18. package/app/api/newsroom/source-icon/route.ts +20 -0
  19. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  20. package/app/newsroom/[section]/page.tsx +1 -0
  21. package/bin/papyrus-app.mjs +12 -1
  22. package/bin/papyrus-infra.mjs +21 -1
  23. package/components/auto-accepted-badge.tsx +21 -0
  24. package/components/conversation-context.tsx +52 -0
  25. package/components/conversation-provider.tsx +20 -0
  26. package/components/conversation-thread.tsx +174 -0
  27. package/components/decision-control.tsx +306 -0
  28. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  29. package/components/markdown-context.tsx +412 -0
  30. package/components/monitoring-overview-view.tsx +143 -21
  31. package/components/monitoring-panel.tsx +64 -4
  32. package/components/needs-me-bodies.tsx +302 -0
  33. package/components/needs-me-detail.tsx +24 -0
  34. package/components/needs-me-feed.tsx +594 -0
  35. package/components/needs-me-reference-detail.tsx +79 -0
  36. package/components/needs-me-shared.tsx +48 -0
  37. package/components/news-desk-client-provider.tsx +24 -3
  38. package/components/news-desk-page.tsx +2 -1
  39. package/components/news-desk-taxonomy-client.ts +253 -31
  40. package/components/newsroom-access-gate.tsx +13 -20
  41. package/components/newsroom-board-skeleton.tsx +20 -0
  42. package/components/newsroom-board-view.tsx +2 -6
  43. package/components/newsroom-client-shell.tsx +2 -1
  44. package/components/newsroom-ops-overview.tsx +88 -73
  45. package/components/newsroom-ops-shell.tsx +8 -28
  46. package/components/newsroom-organizer.css +127 -3
  47. package/components/newsroom-references-view.tsx +334 -158
  48. package/components/notification-cards.tsx +370 -0
  49. package/components/notification-center.tsx +376 -213
  50. package/components/papyrus-console-shell.tsx +2 -1
  51. package/components/proposals-panel.tsx +21 -24
  52. package/components/proposals-view.tsx +4 -2
  53. package/components/reader-auth-control.tsx +3 -1
  54. package/components/reference-capture-client.ts +138 -0
  55. package/components/reference-capture-panel.tsx +268 -0
  56. package/components/reference-conversation.tsx +43 -0
  57. package/components/reference-feed.tsx +514 -0
  58. package/components/reference-review-card.tsx +62 -197
  59. package/components/reference-source-preview.tsx +27 -5
  60. package/components/reference-stories.tsx +34 -0
  61. package/components/references-page-loading.tsx +61 -0
  62. package/components/references-prefetch.ts +52 -0
  63. package/components/story-reporting-trail-client.ts +45 -8
  64. package/components/story-reporting-trail.tsx +24 -13
  65. package/components/topic-steering-workspace.tsx +50 -439
  66. package/components/ui/auto-grow-textarea.tsx +18 -0
  67. package/components/use-conversation.ts +84 -0
  68. package/components/use-media-query.ts +16 -0
  69. package/components/use-reference-preview-attachments.ts +101 -0
  70. package/components/use-reference-ratings.ts +189 -0
  71. package/infra/amplify-app-shell.js +18 -2
  72. package/infra/amplify-app-shell.ts +18 -2
  73. package/infra/chat-stack.js +222 -0
  74. package/infra/chat-stack.ts +250 -0
  75. package/infra/chat.test.js +142 -0
  76. package/infra/chat.test.ts +151 -0
  77. package/infra/index.js +1 -0
  78. package/infra/site-config.js +58 -1
  79. package/infra/site-config.ts +78 -1
  80. package/lib/category-repository.ts +6 -1
  81. package/lib/content-actions-client.ts +20 -0
  82. package/lib/conversation.ts +154 -0
  83. package/lib/local-preview-feed-fixture.ts +63 -0
  84. package/lib/local-preview-seed.ts +24 -2
  85. package/lib/local-preview-synthetic-rejects.ts +41 -0
  86. package/lib/monitoring-client.ts +29 -2
  87. package/lib/monitoring-overview.ts +52 -1
  88. package/lib/monitoring-paging.ts +58 -0
  89. package/lib/needs-me-items.ts +289 -0
  90. package/lib/needs-me-ranking.ts +315 -0
  91. package/lib/newsroom-all-pages.ts +21 -0
  92. package/lib/newsroom-chat-identity.ts +2 -1
  93. package/lib/newsroom-index-filters.ts +18 -0
  94. package/lib/newsroom-nav.ts +1 -1
  95. package/lib/newsroom-organizer-nav.ts +2 -1
  96. package/lib/newsroom-references.ts +25 -1
  97. package/lib/notification-feed.ts +212 -0
  98. package/lib/proposals-client.ts +29 -0
  99. package/lib/reader-subject.ts +3 -2
  100. package/lib/reference-capture.ts +294 -0
  101. package/lib/reference-feed-cache.ts +170 -0
  102. package/lib/reference-feed-ranking.ts +236 -0
  103. package/lib/reference-policy.ts +90 -0
  104. package/lib/reference-preview.ts +112 -0
  105. package/lib/relevance-decisions.ts +6 -2
  106. package/lib/review-client.ts +71 -5
  107. package/lib/review-drafts.ts +277 -0
  108. package/lib/review-fixture.ts +56 -2
  109. package/lib/review-requests.ts +89 -24
  110. package/lib/site-brand.ts +2 -0
  111. package/lib/source-frameable.ts +77 -0
  112. package/lib/source-icon.ts +104 -0
  113. package/lib/staff-groups.ts +23 -0
  114. package/lib/staging-gate.ts +2 -2
  115. package/lib/story-reporting-trail.ts +33 -2
  116. package/package.json +45 -2
  117. package/routes.manifest.json +23 -1
  118. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { normalizeReviewRow } from "./review-requests";
4
+ import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
5
+
6
+ /**
7
+ * Local preview only (server side): when PAPYRUS_FEED_FIXTURE names a JSON file written by
8
+ * scripts/export-reference-feed-fixture.py, the demo newsroom shows those References, their previews and the Cyclotron's
9
+ * decisions instead of the invented sample. Without the variable, or without the file, nothing changes.
10
+ */
11
+ export type ReferenceFeedFixture = {
12
+ references: ReferenceRecord[];
13
+ referenceAttachments: ReferenceAttachmentRecord[];
14
+ semanticRelations: SemanticRelationRecord[];
15
+ };
16
+
17
+ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env): ReferenceFeedFixture | null {
18
+ const configured = env.PAPYRUS_FEED_FIXTURE?.trim();
19
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
22
+ if (parsed?.schema !== "papyrus-reference-feed-fixture/1" || !Array.isArray(parsed.references)) return null;
23
+ return {
24
+ references: parsed.references,
25
+ referenceAttachments: Array.isArray(parsed.referenceAttachments) ? parsed.referenceAttachments : [],
26
+ semanticRelations: Array.isArray(parsed.semanticRelations) ? parsed.semanticRelations : [],
27
+ };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export type NotificationCenterFixture = NonNullable<CategorySteeringDashboard["notificationFixture"]>;
34
+
35
+ /**
36
+ * Local preview only: when PAPYRUS_NOTIFICATION_FIXTURE names a JSON file written by scripts/export-notification-center-fixture.py,
37
+ * the demo Notification Center shows those review requests and their drafts instead of the invented sample.
38
+ */
39
+ export function loadNotificationCenterFixture(env: NodeJS.ProcessEnv = process.env): NotificationCenterFixture | null {
40
+ const configured = env.PAPYRUS_NOTIFICATION_FIXTURE?.trim();
41
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
42
+ try {
43
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
44
+ if (parsed?.schema !== "papyrus-notification-center-fixture/1" || !Array.isArray(parsed.requests)) return null;
45
+ return { requests: parsed.requests.map(normalizeReviewRow), drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
52
+ const fixture = loadReferenceFeedFixture();
53
+ if (!fixture) return dashboard;
54
+ return {
55
+ ...dashboard,
56
+ notificationFixture: loadNotificationCenterFixture(),
57
+ references: fixture.references,
58
+ referenceAttachments: fixture.referenceAttachments,
59
+ semanticRelations: fixture.semanticRelations,
60
+ messages: [],
61
+ realDataPreview: true,
62
+ };
63
+ }
@@ -2,7 +2,7 @@ import type { AssignmentRecord, MessageRecord, ReferenceRecord, SemanticRelation
2
2
  import { SHADOW_AGREEMENT_SCHEMA, type ShadowAgreement } from "./cyclotron-suggestions";
3
3
  import type { EditableArticle } from "./newsroom-articles";
4
4
  import type { MediaAssetRecord } from "./newsroom-media";
5
- import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
5
+ import { EMPTY_STORY_REPORTING_TRAIL, type ReferenceStory, type StoryReportingTrail, type TrailReference, type TrailVersion } from "./story-reporting-trail";
6
6
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
7
7
  import { getContentItemStageDefinitions } from "./site-brand";
8
8
  import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
@@ -159,9 +159,31 @@ export function lineageForPreviewVersion(versionId: string): string | null {
159
159
  return previewLineageByVersionId.get(versionId) ?? null;
160
160
  }
161
161
 
162
+ /** The demo story that is based on a demo Reference, so the story view and the Reference view can show the link without a backend. */
163
+ const PREVIEW_BASED_ON = {
164
+ storyLineageId: "demo-article-published",
165
+ storyTitle: "Harbor Cranes Return",
166
+ referenceLineageId: "reference-knowledge-corpus-demo-source-history-002",
167
+ };
168
+ const PREVIEW_IMAGE = "data:image/svg+xml;utf8," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="96" height="72"><rect width="96" height="72" fill="#cbd5c0"/></svg>');
169
+
170
+ function previewBasedOnReference(): TrailReference {
171
+ return {
172
+ relationId: "preview-story-reference-relation", predicate: "uses_evidence", referenceId: "reference-knowledge-corpus-demo-source-history-002", referenceLineageId: PREVIEW_BASED_ON.referenceLineageId,
173
+ title: "Foundation Model Scaling Retrospective", sourceUri: "https://history.example.org/scaling-retrospective", siteName: "history.example.org", previewImageUrl: PREVIEW_IMAGE,
174
+ role: "based_on", note: null, citationKey: null, addedBy: "papyrus-cli", addedAt: isoTimestampHoursBeforeBase(30),
175
+ };
176
+ }
177
+
162
178
  export function createLocalPreviewReportingTrail(lineageId: string): StoryReportingTrail {
163
179
  const saved = previewSavedVersions.get(lineageId) ?? [];
164
- return { ...EMPTY_STORY_REPORTING_TRAIL, versions: saved };
180
+ const references = lineageId === PREVIEW_BASED_ON.storyLineageId ? [previewBasedOnReference()] : [];
181
+ return { ...EMPTY_STORY_REPORTING_TRAIL, versions: saved, references };
182
+ }
183
+
184
+ export function createLocalPreviewStoriesForReference(referenceLineageId: string): ReferenceStory[] {
185
+ if (referenceLineageId !== PREVIEW_BASED_ON.referenceLineageId) return [];
186
+ return [{ relationId: "preview-story-reference-relation", itemId: PREVIEW_BASED_ON.storyLineageId, lineageId: PREVIEW_BASED_ON.storyLineageId, title: PREVIEW_BASED_ON.storyTitle, stage: "copywriting", role: "based_on" }];
165
187
  }
166
188
 
167
189
  export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
@@ -0,0 +1,41 @@
1
+ import type { RelevanceDecisionView } from "./relevance-decisions";
2
+
3
+ /** Strong Reject confidences, strongest first, for the preview's made-up predictions. */
4
+ const SYNTHETIC_REJECT_CONFIDENCE = [0.95, 0.91, 0.87, 0.83, 0.79, 0.75, 0.71, 0.67];
5
+
6
+ /**
7
+ * Local preview only. A young corpus can have no Reject predictions at all, so the Reject end of the feed cannot be reviewed on real
8
+ * data. `?feedSynthRejects=N` turns the N weakest Accept predictions of pending items into made-up Rejects, and the page says so
9
+ * in a banner. Never reachable outside local preview.
10
+ */
11
+ export function chooseSyntheticRejects(
12
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
13
+ pendingLineageIds: ReadonlySet<string>,
14
+ count: number,
15
+ ): string[] {
16
+ return [...(decisions ?? new Map()).entries()]
17
+ .filter(([lineageId, decision]) => pendingLineageIds.has(lineageId) && decision.label === (decision.positiveLabel ?? "include"))
18
+ .sort((left, right) => (left[1].confidence ?? 0) - (right[1].confidence ?? 0))
19
+ .slice(0, Math.max(0, Math.min(count, SYNTHETIC_REJECT_CONFIDENCE.length)))
20
+ .map(([lineageId]) => lineageId);
21
+ }
22
+
23
+ /** Applies the chosen ids, weakest first getting the lowest of the made-up confidences. The choice is made once, so rating a card never moves it. */
24
+ export function withSyntheticRejects(
25
+ decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
26
+ chosen: readonly string[],
27
+ ): { decisions: Map<string, RelevanceDecisionView>; changed: number } {
28
+ const result = new Map(decisions ?? []);
29
+ let changed = 0;
30
+ chosen.forEach((lineageId, index) => {
31
+ const decision = result.get(lineageId);
32
+ if (!decision) return;
33
+ result.set(lineageId, {
34
+ ...decision,
35
+ label: decision.classes.find((label) => label !== (decision.positiveLabel ?? "include")) ?? "exclude",
36
+ confidence: SYNTHETIC_REJECT_CONFIDENCE[index],
37
+ });
38
+ changed += 1;
39
+ });
40
+ return { decisions: result, changed };
41
+ }
@@ -3,6 +3,9 @@
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import { emailReferenceBatches } from "./monitoring-overview";
5
5
  import type {
6
+ EmailDecisionResult,
7
+ MonitoringListName,
8
+ MonitoringPage,
6
9
  EmailReferenceImportItem,
7
10
  EmailReferenceImportOutcome,
8
11
  MonitoringEmailArticleResolution,
@@ -10,10 +13,10 @@ import type {
10
13
  MonitoringResult,
11
14
  } from "./monitoring-overview";
12
15
 
13
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
16
+ type QueryField = "monitoringOverview" | "monitoringPage" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
14
17
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
15
18
 
16
- async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
19
+ async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number | null>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
17
20
  const client = generateClient({ authMode: "userPool" });
18
21
  let result: GraphQLResult;
19
22
  try {
@@ -34,12 +37,36 @@ export async function loadMonitoringOverview(scope: "publication" | "all"): Prom
34
37
  `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
35
38
  }
36
39
 
40
+ /**
41
+ * One more page of one list after `cursor` (null starts the list from its newest row). `heldOnly` lists only held
42
+ * email submissions, so a held message is findable however old it is.
43
+ */
44
+ export async function loadMonitoringPage(
45
+ list: MonitoringListName, cursor: string | null, scope: "publication" | "all", heldOnly = false,
46
+ ): Promise<MonitoringPage> {
47
+ return query<MonitoringPage>("monitoringPage",
48
+ `query MonitoringPage($list: String!, $cursor: String, $filter: String, $scope: String) { monitoringPage(list: $list, cursor: $cursor, filter: $filter, scope: $scope) }`,
49
+ { list, cursor, filter: heldOnly ? "held" : null, scope });
50
+ }
51
+
37
52
  /** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
38
53
  export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
39
54
  return query<MonitoringEmailDetails>("monitoringEmailSubmission",
40
55
  `query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
41
56
  }
42
57
 
58
+ /**
59
+ * Accept or Reject one held email submission. "Accept" releases it into the normal intake and
60
+ * "Reject" discards it; the editor and the optional reason are recorded server-side.
61
+ */
62
+ export async function decideMonitoringEmailSubmission(
63
+ receiptId: string, decision: "accept" | "reject", reason: string,
64
+ ): Promise<EmailDecisionResult> {
65
+ return query<EmailDecisionResult>("monitoringDecideEmailSubmission",
66
+ `mutation MonitoringDecideEmailSubmission($receiptId: String!, $decision: String!, $reason: String) { monitoringDecideEmailSubmission(receiptId: $receiptId, decision: $decision, reason: $reason) }`,
67
+ { receiptId, decision: decision === "accept" ? "release" : "discard", reason });
68
+ }
69
+
43
70
  /** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
44
71
  export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
45
72
  return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
@@ -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
+ }