@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.
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/components/monitoring-overview-view.tsx +52 -6
- package/components/monitoring-panel.tsx +61 -3
- package/components/news-desk-client-provider.tsx +16 -1
- package/components/news-desk-page.tsx +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +181 -20
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +12 -15
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +47 -0
- package/components/review-rating-grid.tsx +164 -0
- package/components/topic-steering-workspace.tsx +32 -8
- package/lib/category-repository.ts +4 -1
- package/lib/local-preview-feed-fixture.ts +43 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +16 -2
- package/lib/monitoring-overview.ts +20 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -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
|
|
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:
|
|
1330
|
-
loadMessages:
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -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
|
+
}
|