@anthusai/papyrus 5.44.0 → 5.45.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,164 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, CircleCheckIcon, CircleXIcon, Loader2Icon, XIcon } from "lucide-react";
4
+ import { UNRECORDED_CONFIDENCE } from "../lib/reference-feed-ranking";
5
+ import { cn } from "../lib/utils";
6
+ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
7
+
8
+ /**
9
+ * The rating area of a card: one grid, three columns (label | Accept | Reject), and the two rows share its columns exactly.
10
+ * Cyclotron [ Accept 96% ] [ Reject ] read only; the predicted side is highlighted with its confidence
11
+ * [====== confidence bar, centred at 50% ======]
12
+ * You [ Accept ] [ Reject ] the real buttons, each directly under its matching cell
13
+ * Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
14
+ * green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
15
+ * filled. Flat tones and rounded shapes only, no borders.
16
+ */
17
+
18
+ export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
19
+
20
+ /** P(accept) from a prediction: its confidence is in the side it chose; an unrecorded confidence is a mild lean. */
21
+ export function predictionAcceptProbability(prediction: Extract<CardPrediction, { state: "ready" }>): number {
22
+ const confidence = prediction.confidence ?? UNRECORDED_CONFIDENCE;
23
+ return prediction.choice === "accept" ? confidence : 1 - confidence;
24
+ }
25
+
26
+ /** How far, in percent of the whole bar, the fill reaches from the centre: |P(accept) - 0.5| * 2, over the two halves. */
27
+ export function confidenceFillPercent(probability: number): number {
28
+ return Math.min(100, Math.abs(probability - 0.5) * 2 * 100);
29
+ }
30
+
31
+ const WORD: Record<ReviewChoice, string> = { accept: "Accept", reject: "Reject" };
32
+
33
+ const TONE = {
34
+ match: "bg-emerald-600/20 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-100",
35
+ miss: "bg-red-600/20 text-red-900 dark:bg-red-400/20 dark:text-red-100",
36
+ } as const;
37
+ const FILL = { match: "bg-emerald-600 dark:bg-emerald-400", miss: "bg-red-600 dark:bg-red-400", neutral: "bg-[var(--primary)]" } as const;
38
+
39
+ export function RatingGrid({
40
+ answer,
41
+ decided = answer,
42
+ disabled = false,
43
+ disabledReason = null,
44
+ feedback = { phase: "idle" },
45
+ onChoose,
46
+ prediction,
47
+ size = "compact",
48
+ surface = "footer",
49
+ }: {
50
+ /** The side the buttons show as chosen (it may be a pending choice that is still saving). */
51
+ answer: ReviewChoice | null;
52
+ /** The saved human answer, which the Match / Miss verdict compares against. */
53
+ decided?: ReviewChoice | null;
54
+ disabled?: boolean;
55
+ disabledReason?: string | null;
56
+ feedback?: RatingFeedback;
57
+ onChoose: (choice: ReviewChoice) => void;
58
+ prediction: CardPrediction;
59
+ size?: "compact" | "large";
60
+ /** The tone the grid sits on, so the bar's centre notch matches it. */
61
+ surface?: "footer" | "card";
62
+ }) {
63
+ const ready = prediction.state === "ready" ? prediction : null;
64
+ const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
65
+ const probability = ready ? predictionAcceptProbability(ready) : 0.5;
66
+ const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
67
+ const leansAccept = probability >= 0.5;
68
+ const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
69
+ const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
70
+ const label = size === "large" ? "text-sm" : "text-xs";
71
+
72
+ return (
73
+ <div className={cn("grid grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)] gap-x-2 gap-y-2.5", size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]")} data-rating-grid>
74
+ <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
75
+ <span className={cn("self-center font-semibold", label)}>Cyclotron</span>
76
+ {(["accept", "reject"] as const).map((choice) => {
77
+ const predicted = ready?.choice === choice;
78
+ const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
79
+ return (
80
+ <span
81
+ className={cn(
82
+ "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
83
+ cell,
84
+ !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
85
+ predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
86
+ predicted && verdict && cn("font-semibold", TONE[verdict]),
87
+ )}
88
+ data-cyclotron-choice={choice}
89
+ data-predicted={predicted || undefined}
90
+ data-verdict={predicted && verdict ? verdict : undefined}
91
+ key={choice}
92
+ >
93
+ <span className="inline-flex items-center gap-1.5">
94
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
95
+ {WORD[choice]}
96
+ {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
97
+ </span>
98
+ {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
99
+ {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
100
+ </span>
101
+ );
102
+ })}
103
+ <div className="col-span-2 col-start-2 flex flex-col gap-1" data-confidence>
104
+ <div
105
+ aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
106
+ className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
107
+ data-confidence-bar
108
+ data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
109
+ role="img"
110
+ >
111
+ {ready ? (
112
+ <span
113
+ className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
114
+ data-confidence-fill
115
+ data-fill-percent={Math.round(half * 2)}
116
+ style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
117
+ />
118
+ ) : null}
119
+ <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
120
+ </div>
121
+ {prediction.state === "ready" ? null : (
122
+ <small className="text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state}>
123
+ {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
124
+ </small>
125
+ )}
126
+ </div>
127
+ </div>
128
+
129
+ <div className="col-span-3 grid grid-cols-subgrid items-stretch" data-review-row="you">
130
+ <span className={cn("self-center font-semibold", label)}>You</span>
131
+ {(["accept", "reject"] as const).map((choice) => {
132
+ const on = answer === choice;
133
+ const Icon = choice === "accept" ? CheckIcon : XIcon;
134
+ return (
135
+ <button
136
+ aria-disabled={disabled ? true : undefined}
137
+ aria-pressed={on}
138
+ className={cn(
139
+ "inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
140
+ cell,
141
+ on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
142
+ disabled && "opacity-60",
143
+ )}
144
+ data-review-choice={choice}
145
+ key={choice}
146
+ onClick={() => onChoose(choice)}
147
+ type="button"
148
+ >
149
+ <Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
150
+ {WORD[choice]}
151
+ </button>
152
+ );
153
+ })}
154
+ </div>
155
+
156
+ <div className="col-span-3 min-h-5 text-xs text-muted-foreground" data-feed-saved role="status">
157
+ {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
158
+ {feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
159
+ </div>
160
+ {disabledReason ? <p className="col-span-3 text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
161
+ {feedback.phase === "error" ? <p className="col-span-3 text-xs text-destructive" role="alert">{feedback.message}</p> : null}
162
+ </div>
163
+ );
164
+ }
@@ -94,6 +94,9 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
94
94
  import { ReferenceSourcePreview } from "./reference-source-preview";
95
95
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
96
96
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
97
+ import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
98
+ import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
99
+ import { referencePreviewsByLineage } from "../lib/reference-preview";
97
100
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
98
101
  import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
99
102
  import { NewsroomReferencesView } from "./newsroom-references-view";
@@ -696,7 +699,7 @@ export function NewsDeskWorkspace({
696
699
  }
697
700
 
698
701
  if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
699
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
702
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
700
703
  }
701
704
 
702
705
  if (dashboard) {
@@ -716,7 +719,7 @@ export function NewsDeskWorkspace({
716
719
  );
717
720
  }
718
721
 
719
- return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
722
+ return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
720
723
  }
721
724
 
722
725
  function NewsDeskDashboard({
@@ -777,9 +780,16 @@ function NewsDeskDashboard({
777
780
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
778
781
  const [messages, setMessages] = useState(dashboard.messages);
779
782
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
783
+ const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
784
+ useEffect(() => {
785
+ // Display only: the last view this browser saved, shown until the live read arrives.
786
+ if (dashboard.isDemo) return;
787
+ const snapshot = loadFeedSnapshot(SITE_BRAND.id);
788
+ if (snapshot) setSavedFeedCopy(snapshot);
789
+ }, [dashboard.isDemo]);
780
790
  const cyclotronPreviewFixtures = useMemo(
781
- () => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
782
- [references],
791
+ () => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
792
+ [references, semanticRelations],
783
793
  );
784
794
  const relevanceDecisions = useMemo(
785
795
  () => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
@@ -1215,7 +1225,7 @@ function NewsDeskDashboard({
1215
1225
  useEffect(() => {
1216
1226
  if (!canLoadPrivateCorpus || hasHydratedReferences) return;
1217
1227
  let active = true;
1218
- void loadEditorReferencesData()
1228
+ void loadReferencesWithHeadStart()
1219
1229
  .then(({ references: nextReferences, referenceAttachments }) => {
1220
1230
  if (!active) return;
1221
1231
  setReferences(nextReferences);
@@ -1326,8 +1336,8 @@ function NewsDeskDashboard({
1326
1336
  const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1327
1337
  const referenceReviewData = useReferenceReviewData({
1328
1338
  enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1329
- loadRelations: loadEditorSemanticRelationsData,
1330
- loadMessages: loadEditorMessagesData,
1339
+ loadRelations: loadRelationsWithHeadStart,
1340
+ loadMessages: loadMessagesWithHeadStart,
1331
1341
  onRelations: setSemanticRelations,
1332
1342
  onMessages: setMessages,
1333
1343
  });
@@ -1351,6 +1361,18 @@ function NewsDeskDashboard({
1351
1361
  ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1352
1362
  : referenceReviewData.state;
1353
1363
  const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1364
+ useEffect(() => {
1365
+ // Keep a display copy for the next visit once everything the feed shows has been read.
1366
+ if (dashboard.isDemo || activeTab !== "references" || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
1367
+ const timer = window.setTimeout(() => {
1368
+ saveFeedSnapshot(SITE_BRAND.id, {
1369
+ references: selectCanonicalReferenceRecords(references),
1370
+ previews: referencePreviewsByLineage(referenceAttachments),
1371
+ decisions: relevanceDecisions,
1372
+ });
1373
+ }, 800);
1374
+ return () => window.clearTimeout(timer);
1375
+ }, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
1354
1376
  const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1355
1377
 
1356
1378
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
@@ -3035,7 +3057,7 @@ function NewsDeskDashboard({
3035
3057
  headerActions={(
3036
3058
  <>
3037
3059
  {dashboard.isDemo ? (
3038
- <Badge variant="outline">Demo</Badge>
3060
+ dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
3039
3061
  ) : (
3040
3062
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
3041
3063
  )}
@@ -3182,6 +3204,8 @@ function NewsDeskDashboard({
3182
3204
  options?.sectionId)}
3183
3205
  referenceAttachments={referenceAttachments}
3184
3206
  references={references}
3207
+ loading={!dashboard.isDemo && !hasHydratedReferences}
3208
+ savedCopy={savedFeedCopy}
3185
3209
  />
3186
3210
  </NewsroomCurationBootstrap>
3187
3211
  ) : null}
@@ -656,6 +656,8 @@ export type NewsroomSummaryFacets = {
656
656
 
657
657
  export type CategorySteeringDashboard = {
658
658
  isDemo?: boolean;
659
+ /** Local preview only: the demo newsroom is showing a real export (scripts/export-reference-feed-fixture.py), so it must not say "synthetic". */
660
+ realDataPreview?: boolean;
659
661
  isPublicSkeleton?: boolean;
660
662
  summaryStatus?: "loading" | "missing" | "ready";
661
663
  summary?: NewsroomSummaryRecord | null;
@@ -820,7 +822,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
820
822
  }
821
823
 
822
824
  function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
823
- if (!sectionsConfigPath) return [];
825
+ // A data-driven site names a seed file it does not ship: its Sections live in the backend, so there is nothing to seed.
826
+ if (!sectionsConfigPath || !fs.existsSync(sectionsConfigPath)) return [];
824
827
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
825
828
  ...row,
826
829
  enabledStatus: "enabled",
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
4
+
5
+ /**
6
+ * Local preview only (server side): when PAPYRUS_FEED_FIXTURE names a JSON file written by
7
+ * scripts/export-reference-feed-fixture.py, the demo newsroom shows those References, their previews and the Cyclotron's
8
+ * decisions instead of the invented sample. Without the variable, or without the file, nothing changes.
9
+ */
10
+ export type ReferenceFeedFixture = {
11
+ references: ReferenceRecord[];
12
+ referenceAttachments: ReferenceAttachmentRecord[];
13
+ semanticRelations: SemanticRelationRecord[];
14
+ };
15
+
16
+ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env): ReferenceFeedFixture | null {
17
+ const configured = env.PAPYRUS_FEED_FIXTURE?.trim();
18
+ if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
21
+ if (parsed?.schema !== "papyrus-reference-feed-fixture/1" || !Array.isArray(parsed.references)) return null;
22
+ return {
23
+ references: parsed.references,
24
+ referenceAttachments: Array.isArray(parsed.referenceAttachments) ? parsed.referenceAttachments : [],
25
+ semanticRelations: Array.isArray(parsed.semanticRelations) ? parsed.semanticRelations : [],
26
+ };
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
33
+ const fixture = loadReferenceFeedFixture();
34
+ if (!fixture) return dashboard;
35
+ return {
36
+ ...dashboard,
37
+ references: fixture.references,
38
+ referenceAttachments: fixture.referenceAttachments,
39
+ semanticRelations: fixture.semanticRelations,
40
+ messages: [],
41
+ realDataPreview: true,
42
+ };
43
+ }
@@ -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
+ }
@@ -4,6 +4,8 @@ import { generateClient } from "aws-amplify/data";
4
4
  import { emailReferenceBatches } from "./monitoring-overview";
5
5
  import type {
6
6
  EmailDecisionResult,
7
+ MonitoringListName,
8
+ MonitoringPage,
7
9
  EmailReferenceImportItem,
8
10
  EmailReferenceImportOutcome,
9
11
  MonitoringEmailArticleResolution,
@@ -11,10 +13,10 @@ import type {
11
13
  MonitoringResult,
12
14
  } from "./monitoring-overview";
13
15
 
14
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
16
+ type QueryField = "monitoringOverview" | "monitoringPage" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
15
17
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
16
18
 
17
- 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" }> {
18
20
  const client = generateClient({ authMode: "userPool" });
19
21
  let result: GraphQLResult;
20
22
  try {
@@ -35,6 +37,18 @@ export async function loadMonitoringOverview(scope: "publication" | "all"): Prom
35
37
  `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`, { scope });
36
38
  }
37
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
+
38
52
  /** Loads one receipt's safe plain-text rendering and its already extracted candidate links. */
39
53
  export async function loadMonitoringEmailSubmission(receiptId: string): Promise<MonitoringEmailDetails> {
40
54
  return query<MonitoringEmailDetails>("monitoringEmailSubmission",
@@ -38,6 +38,12 @@ export type MonitoringEmailSummary = {
38
38
  envelopeSender: string | null; headerFrom: string | null; warnings: string[];
39
39
  verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
40
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 }[];
41
47
  };
42
48
 
43
49
  /** Where a held email submission stands. `null` means it never needed review. */
@@ -46,7 +52,7 @@ export type EmailQuarantineState =
46
52
  | "expired" | "decision_failed" | "release_failed";
47
53
  export type EmailQuarantine = {
48
54
  state: EmailQuarantineState;
49
- decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string } | null;
55
+ decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string; viaGroup?: boolean } | null;
50
56
  failure: string | null;
51
57
  };
52
58
  export type EmailDecisionResult =
@@ -144,6 +150,17 @@ export type MonitoringFeed = {
144
150
  nextDueAt: string | null;
145
151
  };
146
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
+
147
164
  export type MonitoringOverview = {
148
165
  state: "ready" | "empty";
149
166
  asOf: string;
@@ -164,6 +181,8 @@ export type MonitoringOverview = {
164
181
  otherMonitors?: string[];
165
182
  emailSubmissions?: MonitoringEmailSummary[];
166
183
  hiddenNotRelevant: number;
184
+ /** Where each list continues; null or missing when it has no more rows. */
185
+ cursors?: MonitoringCursors;
167
186
  };
168
187
 
169
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,133 @@
1
+ /**
2
+ * Stale-while-revalidate copies of what the editor last saw, so a return visit paints cards (and the signed-in name) at once
3
+ * while the real reads run in the background. Display state only: the backend still authorizes every call, a cached copy never
4
+ * enables an action by itself, and everything here is dropped on sign-out. Every storage touch is guarded: the page renders
5
+ * the same, only slower, when storage is blocked or full.
6
+ */
7
+ import type { ReferenceRecord } from "./category-repository";
8
+ import type { RelevanceDecisionView } from "./relevance-decisions";
9
+ import type { ReferencePreview } from "./reference-preview";
10
+
11
+ const FEED_PREFIX = "papyrus.references.feed.v1.";
12
+ const SESSION_PREFIX = "papyrus.newsroom.session.v1.";
13
+ /** A saved session summary is trusted for display only this long; the live check always still runs. */
14
+ export const SESSION_DISPLAY_TTL_MS = 15 * 60 * 1000;
15
+
16
+ export type FeedSnapshot = {
17
+ savedAt: number;
18
+ references: ReferenceRecord[];
19
+ previews: Array<[string, ReferencePreview]>;
20
+ decisions: Array<[string, RelevanceDecisionView]>;
21
+ };
22
+
23
+ export type SessionDisplay = {
24
+ savedAt: number;
25
+ label: string;
26
+ isAdmin: boolean;
27
+ };
28
+
29
+ function storageOf(kind: "local" | "session"): Storage | null {
30
+ try {
31
+ if (typeof window === "undefined") return null;
32
+ return kind === "local" ? window.localStorage : window.sessionStorage;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function read<T>(kind: "local" | "session", key: string): T | null {
39
+ try {
40
+ const raw = storageOf(kind)?.getItem(key);
41
+ return raw ? JSON.parse(raw) as T : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function write(kind: "local" | "session", key: string, value: unknown): boolean {
48
+ try {
49
+ const storage = storageOf(kind);
50
+ if (!storage) return false;
51
+ storage.setItem(key, JSON.stringify(value));
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function remove(kind: "local" | "session", key: string) {
59
+ try { storageOf(kind)?.removeItem(key); } catch { /* nothing to clear */ }
60
+ }
61
+
62
+ /** The fields a card needs, and nothing else: a smaller copy that fits, and less private text at rest. */
63
+ export function slimReference(reference: ReferenceRecord): ReferenceRecord {
64
+ return {
65
+ id: reference.id,
66
+ lineageId: reference.lineageId,
67
+ versionNumber: reference.versionNumber,
68
+ versionState: reference.versionState,
69
+ corpusId: reference.corpusId,
70
+ externalItemId: reference.externalItemId,
71
+ title: reference.title,
72
+ sourceUri: reference.sourceUri,
73
+ mediaType: reference.mediaType,
74
+ sourcePublishedAt: reference.sourcePublishedAt,
75
+ retrievedAt: reference.retrievedAt,
76
+ importedAt: reference.importedAt,
77
+ createdAt: reference.createdAt,
78
+ curationStatus: reference.curationStatus,
79
+ curationStatusUpdatedAt: reference.curationStatusUpdatedAt,
80
+ curationStatusReason: reference.curationStatusReason,
81
+ };
82
+ }
83
+
84
+ export function feedCacheKey(siteId: string): string {
85
+ return `${FEED_PREFIX}${siteId}`;
86
+ }
87
+
88
+ export function loadFeedSnapshot(siteId: string): FeedSnapshot | null {
89
+ const snapshot = read<FeedSnapshot>("local", feedCacheKey(siteId));
90
+ if (!snapshot || !Array.isArray(snapshot.references) || !snapshot.references.length) return null;
91
+ return snapshot;
92
+ }
93
+
94
+ /** Saves the newest copy. On a full store it keeps the newest half, then gives up quietly. */
95
+ export function saveFeedSnapshot(
96
+ siteId: string,
97
+ parts: { references: ReferenceRecord[]; previews: ReadonlyMap<string, ReferencePreview>; decisions: ReadonlyMap<string, RelevanceDecisionView> },
98
+ now = Date.now(),
99
+ ): boolean {
100
+ let references = parts.references.map(slimReference);
101
+ for (let attempt = 0; attempt < 4 && references.length; attempt += 1) {
102
+ const kept = new Set(references.map((reference) => reference.lineageId ?? reference.id));
103
+ const snapshot: FeedSnapshot = {
104
+ savedAt: now,
105
+ references,
106
+ previews: [...parts.previews].filter(([lineage]) => kept.has(lineage)),
107
+ decisions: [...parts.decisions].filter(([lineage]) => kept.has(lineage)).map(([lineage, decision]) => [lineage, { ...decision, provenance: null }]),
108
+ };
109
+ if (write("local", feedCacheKey(siteId), snapshot)) return true;
110
+ references = references.slice(0, Math.ceil(references.length / 2));
111
+ }
112
+ return false;
113
+ }
114
+
115
+ export function clearFeedSnapshot(siteId: string) {
116
+ remove("local", feedCacheKey(siteId));
117
+ }
118
+
119
+ export function loadSessionDisplay(siteId: string, now = Date.now()): SessionDisplay | null {
120
+ const saved = read<SessionDisplay>("session", `${SESSION_PREFIX}${siteId}`);
121
+ if (!saved || typeof saved.label !== "string" || typeof saved.savedAt !== "number") return null;
122
+ return now - saved.savedAt <= SESSION_DISPLAY_TTL_MS ? saved : null;
123
+ }
124
+
125
+ export function saveSessionDisplay(siteId: string, display: Omit<SessionDisplay, "savedAt">, now = Date.now()) {
126
+ write("session", `${SESSION_PREFIX}${siteId}`, { ...display, savedAt: now });
127
+ }
128
+
129
+ /** Sign-out: nothing of the signed-in view survives in this browser. */
130
+ export function clearSavedNewsroomDisplay(siteId: string) {
131
+ remove("session", `${SESSION_PREFIX}${siteId}`);
132
+ clearFeedSnapshot(siteId);
133
+ }