@anthusai/papyrus 5.43.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/category-action/handler.ts +2 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +183 -24
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +90 -76
- 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/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- 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 +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +14 -1
- package/routes.manifest.json +1 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
14
|
import { cn } from "../lib/utils";
|
|
15
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
15
16
|
import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
16
17
|
|
|
17
18
|
export type ReviewChoice = "accept" | "reject";
|
|
@@ -44,6 +45,84 @@ function percent(confidence: number | null): string | null {
|
|
|
44
45
|
return confidence === null ? null : `${Math.round(confidence * 100)}%`;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The Cyclotron's read-only row: its Accept or Reject highlighted with its confidence, and a check or cross once a person has answered.
|
|
50
|
+
* Shared by the review card and by every list that shows a suggestion before a decision.
|
|
51
|
+
*/
|
|
52
|
+
export function CyclotronRow({ decided = null, prediction }: { decided?: ReviewChoice | null; prediction: CardPrediction }) {
|
|
53
|
+
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-2" data-review-row="cyclotron">
|
|
56
|
+
<div className="flex items-center gap-3">
|
|
57
|
+
<span className="w-20 shrink-0 text-sm font-semibold">Cyclotron</span>
|
|
58
|
+
{prediction.state === "ready" ? (
|
|
59
|
+
<div className="flex flex-1 gap-2">
|
|
60
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
61
|
+
const predicted = prediction.state === "ready" && prediction.choice === choice;
|
|
62
|
+
const verdict = predicted && matched !== null ? (matched ? "match" : "miss") : null;
|
|
63
|
+
return (
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
67
|
+
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
68
|
+
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
69
|
+
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
70
|
+
)}
|
|
71
|
+
data-cyclotron-choice={choice}
|
|
72
|
+
data-predicted={predicted || undefined}
|
|
73
|
+
data-verdict={verdict ?? undefined}
|
|
74
|
+
key={choice}
|
|
75
|
+
>
|
|
76
|
+
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
77
|
+
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
78
|
+
{CHOICE_LABEL[choice]}
|
|
79
|
+
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
80
|
+
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
87
|
+
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The two answers, large enough to tap: Accept and Reject, the chosen one filled and checked (never colour alone). */
|
|
96
|
+
export function ChoiceButtons({ chosen, disabled = false, onChoose }: { chosen: ReviewChoice | null; disabled?: boolean; onChoose: (choice: ReviewChoice) => void }) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex flex-1 gap-2">
|
|
99
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
100
|
+
const on = chosen === choice;
|
|
101
|
+
return (
|
|
102
|
+
<Button
|
|
103
|
+
aria-disabled={disabled ? true : undefined}
|
|
104
|
+
aria-pressed={on}
|
|
105
|
+
className="min-h-14 flex-1 text-base"
|
|
106
|
+
data-review-choice={choice}
|
|
107
|
+
key={choice}
|
|
108
|
+
onClick={() => onChoose(choice)}
|
|
109
|
+
type="button"
|
|
110
|
+
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
111
|
+
>
|
|
112
|
+
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
113
|
+
{CHOICE_LABEL[choice]}
|
|
114
|
+
</Button>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A reference's relevance decision in the card's own vocabulary. */
|
|
122
|
+
export function predictionFromDecision(decision: { label: string; positiveLabel: string | null; confidence: number | null }): CardPrediction {
|
|
123
|
+
return { state: "ready", choice: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", confidence: decision.confidence };
|
|
124
|
+
}
|
|
125
|
+
|
|
47
126
|
/**
|
|
48
127
|
* One reusable card for answering a single question: Accept or Reject. The Cyclotron's row is
|
|
49
128
|
* read-only; the human row is the real control and its answer is final. Saves on tap.
|
|
@@ -156,12 +235,6 @@ export function ReviewCard({
|
|
|
156
235
|
return () => window.removeEventListener("keydown", onKey);
|
|
157
236
|
});
|
|
158
237
|
|
|
159
|
-
const predictedMarker = (choice: ReviewChoice) => {
|
|
160
|
-
if (prediction.state !== "ready" || prediction.choice !== choice) return null;
|
|
161
|
-
return prediction.choice;
|
|
162
|
-
};
|
|
163
|
-
const matched = decided && prediction.state === "ready" ? prediction.choice === decided : null;
|
|
164
|
-
|
|
165
238
|
return (
|
|
166
239
|
<section aria-label="Review" className="space-y-4" data-review-card={itemId}>
|
|
167
240
|
<header className="space-y-1">
|
|
@@ -175,76 +248,17 @@ export function ReviewCard({
|
|
|
175
248
|
{summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
|
|
176
249
|
</header>
|
|
177
250
|
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"inline-flex min-h-10 flex-1 items-center justify-center gap-1.5 rounded-md border px-3 text-sm",
|
|
190
|
-
predicted ? "border-foreground bg-muted font-semibold" : "border-dashed text-muted-foreground",
|
|
191
|
-
verdict === "match" && "border-green-700 bg-green-100 text-green-950",
|
|
192
|
-
verdict === "miss" && "border-red-700 bg-red-100 text-red-950",
|
|
193
|
-
)}
|
|
194
|
-
data-cyclotron-choice={choice}
|
|
195
|
-
data-predicted={predicted || undefined}
|
|
196
|
-
data-verdict={verdict ?? undefined}
|
|
197
|
-
key={choice}
|
|
198
|
-
>
|
|
199
|
-
{verdict === "match" ? <CircleCheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
200
|
-
{verdict === "miss" ? <CircleXIcon aria-hidden="true" className="size-4" /> : null}
|
|
201
|
-
{CHOICE_LABEL[choice]}
|
|
202
|
-
{predicted && percent(prediction.confidence) ? <small className="text-xs font-normal">{percent(prediction.confidence)}</small> : null}
|
|
203
|
-
{verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
204
|
-
</span>
|
|
205
|
-
);
|
|
206
|
-
})}
|
|
207
|
-
</div>
|
|
208
|
-
) : (
|
|
209
|
-
<p className="text-sm text-muted-foreground" data-cyclotron-state={prediction.state}>
|
|
210
|
-
{prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
211
|
-
</p>
|
|
212
|
-
)}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
<div className="space-y-2" data-review-row="you">
|
|
217
|
-
<div className="flex items-center gap-3">
|
|
218
|
-
<span className="w-20 shrink-0 text-sm font-semibold">You</span>
|
|
219
|
-
<div className="flex flex-1 gap-2">
|
|
220
|
-
{(["accept", "reject"] as const).map((choice) => {
|
|
221
|
-
const on = chosen === choice;
|
|
222
|
-
return (
|
|
223
|
-
<Button
|
|
224
|
-
aria-disabled={disabledReason ? true : undefined}
|
|
225
|
-
aria-pressed={on}
|
|
226
|
-
className="min-h-14 flex-1 text-base"
|
|
227
|
-
data-review-choice={choice}
|
|
228
|
-
key={choice}
|
|
229
|
-
onClick={() => choose(choice)}
|
|
230
|
-
type="button"
|
|
231
|
-
variant={on ? (choice === "accept" ? "default" : "destructive") : "outline"}
|
|
232
|
-
>
|
|
233
|
-
{on ? <CheckIcon aria-hidden="true" className="size-5" /> : null}
|
|
234
|
-
{CHOICE_LABEL[choice]}
|
|
235
|
-
</Button>
|
|
236
|
-
);
|
|
237
|
-
})}
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
<div className="min-h-5 pl-[5.75rem] text-sm" role="status">
|
|
241
|
-
{saving ? "Saving…" : saved ? (
|
|
242
|
-
<span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
|
|
243
|
-
) : null}
|
|
244
|
-
</div>
|
|
245
|
-
{disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
|
|
246
|
-
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
|
|
247
|
-
</div>
|
|
251
|
+
<RatingGrid
|
|
252
|
+
answer={chosen}
|
|
253
|
+
decided={decided}
|
|
254
|
+
disabled={Boolean(disabledReason)}
|
|
255
|
+
disabledReason={disabledReason}
|
|
256
|
+
feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
|
|
257
|
+
onChoose={choose}
|
|
258
|
+
prediction={prediction}
|
|
259
|
+
size="large"
|
|
260
|
+
surface="card"
|
|
261
|
+
/>
|
|
248
262
|
|
|
249
263
|
<label className="block text-sm font-medium">
|
|
250
264
|
Why? (optional)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { LoaderIcon } from "lucide-react";
|
|
5
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
6
|
+
import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
7
|
+
import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
8
|
+
import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
|
|
9
|
+
import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the References page shows while the session and the first reads are still resolving: the real page header at once,
|
|
13
|
+
* then this browser's last saved view (read only, answering switches on when the session is confirmed), or card skeletons
|
|
14
|
+
* when there is none. Never a full-screen "checking" screen.
|
|
15
|
+
*/
|
|
16
|
+
export function ReferencesPageLoading() {
|
|
17
|
+
const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
|
|
18
|
+
const [checked, setChecked] = useState(false);
|
|
19
|
+
const [tab, setTab] = useState<ReferenceFeedTabKey>("feed");
|
|
20
|
+
const [note, setNote] = useState<string | null>(null);
|
|
21
|
+
const [now] = useState(() => Date.now());
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setSnapshot(loadFeedSnapshot(SITE_BRAND.id));
|
|
24
|
+
setChecked(true);
|
|
25
|
+
}, []);
|
|
26
|
+
const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
|
|
27
|
+
const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
|
|
28
|
+
const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
|
|
29
|
+
const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
|
|
30
|
+
const noVisitRated = useMemo(() => new Set<string>(), []);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="space-y-4" data-references-page-loading>
|
|
34
|
+
<div className="space-y-1">
|
|
35
|
+
<h2 className="text-2xl font-bold tracking-tight">References</h2>
|
|
36
|
+
<p className="text-sm text-muted-foreground">A feed ranked by what you are most likely to accept.</p>
|
|
37
|
+
</div>
|
|
38
|
+
{note ? <p className="text-sm text-muted-foreground" role="status">{note}</p> : null}
|
|
39
|
+
{checked && references.length ? (
|
|
40
|
+
<ReferenceFeed
|
|
41
|
+
disabledReason="Confirming your sign-in. You can answer in a moment."
|
|
42
|
+
now={now}
|
|
43
|
+
onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
|
|
44
|
+
onRate={() => undefined}
|
|
45
|
+
onTabChange={setTab}
|
|
46
|
+
onVisitRated={() => undefined}
|
|
47
|
+
partition={partition}
|
|
48
|
+
previews={previews}
|
|
49
|
+
stale
|
|
50
|
+
tab={tab}
|
|
51
|
+
visitRated={noVisitRated}
|
|
52
|
+
/>
|
|
53
|
+
) : (
|
|
54
|
+
<>
|
|
55
|
+
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><LoaderIcon aria-hidden="true" className="size-3.5 animate-spin" />Loading your references</p>
|
|
56
|
+
<ReferenceFeedSkeleton />
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts the References reads as soon as the editor session is confirmed, in parallel with the summary dashboard, instead of after it.
|
|
3
|
+
* The page that needs them takes the in-flight request once; anything later, or a retry, makes a fresh read. This is a head start
|
|
4
|
+
* on the same reads, not a cache: the backend authorizes each call exactly as before.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
loadEditorMessagesData,
|
|
8
|
+
loadEditorReferencesData,
|
|
9
|
+
loadEditorSemanticRelationsData,
|
|
10
|
+
} from "./news-desk-taxonomy-client";
|
|
11
|
+
|
|
12
|
+
const HEAD_START_MS = 60_000;
|
|
13
|
+
type Started = { startedAt: number; promise: Promise<unknown> };
|
|
14
|
+
const started = new Map<string, Started>();
|
|
15
|
+
|
|
16
|
+
const LOADERS = {
|
|
17
|
+
references: loadEditorReferencesData,
|
|
18
|
+
relations: loadEditorSemanticRelationsData,
|
|
19
|
+
messages: loadEditorMessagesData,
|
|
20
|
+
} as const;
|
|
21
|
+
type Key = keyof typeof LOADERS;
|
|
22
|
+
|
|
23
|
+
/** Begin all three reads. Safe to call twice; a failure is reported to whoever takes the read, never as an unhandled rejection. */
|
|
24
|
+
export function prefetchReferencesFeed(now = Date.now()) {
|
|
25
|
+
for (const key of Object.keys(LOADERS) as Key[]) {
|
|
26
|
+
if (started.has(key)) continue;
|
|
27
|
+
const promise = LOADERS[key]();
|
|
28
|
+
promise.catch(() => undefined);
|
|
29
|
+
started.set(key, { startedAt: now, promise });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function take<Key2 extends Key>(key: Key2, now = Date.now()): ReturnType<(typeof LOADERS)[Key2]> {
|
|
34
|
+
const head = started.get(key);
|
|
35
|
+
started.delete(key);
|
|
36
|
+
if (head && now - head.startedAt <= HEAD_START_MS) return head.promise as ReturnType<(typeof LOADERS)[Key2]>;
|
|
37
|
+
return LOADERS[key]() as ReturnType<(typeof LOADERS)[Key2]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const loadReferencesWithHeadStart = () => take("references");
|
|
41
|
+
export const loadRelationsWithHeadStart = () => take("relations");
|
|
42
|
+
export const loadMessagesWithHeadStart = () => take("messages");
|
|
43
|
+
|
|
44
|
+
/** Forget any unclaimed head start (sign-out). */
|
|
45
|
+
export function discardReferencesHeadStart() {
|
|
46
|
+
started.clear();
|
|
47
|
+
}
|
|
@@ -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}
|
|
@@ -6,10 +6,26 @@ import { addCloudEmail } from "./cloud-email.js";
|
|
|
6
6
|
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
7
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
8
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
9
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
9
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
10
10
|
function environmentVariables(variables) {
|
|
11
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
|
|
15
|
+
* resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
|
|
16
|
+
* site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
|
|
17
|
+
*/
|
|
18
|
+
function chatEnvironment(config) {
|
|
19
|
+
const chatStackName = resolveChatStackName(config);
|
|
20
|
+
if (!chatStackName)
|
|
21
|
+
return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
|
|
22
|
+
return {
|
|
23
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
|
|
24
|
+
...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
|
|
25
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
|
|
26
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
13
29
|
function readerCustomHeaders(rules) {
|
|
14
30
|
if (!rules)
|
|
15
31
|
return undefined;
|
|
@@ -199,7 +215,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
199
215
|
environmentVariables: environmentVariables({
|
|
200
216
|
...config.cms.environment,
|
|
201
217
|
...emailEnvironment,
|
|
202
|
-
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
218
|
+
...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
|
|
203
219
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
204
220
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
205
221
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -7,12 +7,28 @@ import { addCloudEmail } from "./cloud-email";
|
|
|
7
7
|
import { parseCloudEmail } from "./cloud-email-config";
|
|
8
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
9
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
10
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
10
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config";
|
|
11
11
|
|
|
12
12
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
13
13
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
|
|
18
|
+
* resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
|
|
19
|
+
* site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
|
|
20
|
+
*/
|
|
21
|
+
function chatEnvironment(config: AmplifyAppShellSiteConfig): Record<string, string> {
|
|
22
|
+
const chatStackName = resolveChatStackName(config);
|
|
23
|
+
if (!chatStackName) return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
|
|
24
|
+
return {
|
|
25
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
|
|
26
|
+
...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
|
|
27
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
|
|
28
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
|
|
17
33
|
if (!rules) return undefined;
|
|
18
34
|
const lines = ["customHeaders:"];
|
|
@@ -221,7 +237,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
221
237
|
environmentVariables: environmentVariables({
|
|
222
238
|
...config.cms.environment,
|
|
223
239
|
...emailEnvironment,
|
|
224
|
-
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
240
|
+
...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
|
|
225
241
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
226
242
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
227
243
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|