@anthusai/papyrus 5.44.0 → 5.46.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/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +8 -0
- 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/bin/papyrus-app.mjs +12 -1
- 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-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +49 -2
- package/components/newsroom-references-view.tsx +296 -23
- package/components/notification-cards.tsx +364 -0
- package/components/notification-center.tsx +319 -331
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +502 -0
- package/components/reference-review-card.tsx +32 -80
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +177 -0
- package/components/topic-steering-workspace.tsx +43 -9
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -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/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +1 -1
- package/lib/notification-feed.ts +250 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +17 -1
- package/routes.manifest.json +1 -1
|
@@ -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,52 @@
|
|
|
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
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The pages that need the References straight away: the feed itself and the home page, whose Notification Center shows References cards. */
|
|
50
|
+
export function wantsReferencesHeadStart(pathname: string): boolean {
|
|
51
|
+
return /\/newsroom\/references(\/|$)/.test(pathname) || /\/newsroom\/?$/.test(pathname);
|
|
52
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
note = null,
|
|
46
|
+
onChoose,
|
|
47
|
+
prediction,
|
|
48
|
+
showPrediction = true,
|
|
49
|
+
size = "compact",
|
|
50
|
+
surface = "footer",
|
|
51
|
+
}: {
|
|
52
|
+
/** The side the buttons show as chosen (it may be a pending choice that is still saving). */
|
|
53
|
+
answer: ReviewChoice | null;
|
|
54
|
+
/** The saved human answer, which the Match / Miss verdict compares against. */
|
|
55
|
+
decided?: ReviewChoice | null;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
disabledReason?: string | null;
|
|
58
|
+
feedback?: RatingFeedback;
|
|
59
|
+
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
60
|
+
note?: string | null;
|
|
61
|
+
onChoose: (choice: ReviewChoice) => void;
|
|
62
|
+
prediction: CardPrediction;
|
|
63
|
+
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
64
|
+
showPrediction?: boolean;
|
|
65
|
+
size?: "compact" | "large";
|
|
66
|
+
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
67
|
+
surface?: "footer" | "card";
|
|
68
|
+
}) {
|
|
69
|
+
const ready = prediction.state === "ready" ? prediction : null;
|
|
70
|
+
const verdict = ready && decided ? (ready.choice === decided ? "match" : "miss") : null;
|
|
71
|
+
const probability = ready ? predictionAcceptProbability(ready) : 0.5;
|
|
72
|
+
const half = confidenceFillPercent(probability) / 2; // percent of the full bar width, on one side of the centre
|
|
73
|
+
const leansAccept = probability >= 0.5;
|
|
74
|
+
const confidenceText = ready && ready.confidence !== null ? `${Math.round(ready.confidence * 100)}%` : null;
|
|
75
|
+
const cell = size === "large" ? "min-h-14 text-base" : "min-h-12 text-sm";
|
|
76
|
+
const label = size === "large" ? "text-sm" : "text-xs";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
"grid gap-x-2 gap-y-2.5",
|
|
82
|
+
showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
|
|
83
|
+
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
84
|
+
)}
|
|
85
|
+
data-rating-grid
|
|
86
|
+
>
|
|
87
|
+
{showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
|
|
88
|
+
<span className={cn("self-center font-semibold", label)}>Cyclotron</span>
|
|
89
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
90
|
+
const predicted = ready?.choice === choice;
|
|
91
|
+
const Icon = predicted ? (verdict === "match" ? CircleCheckIcon : verdict === "miss" ? CircleXIcon : choice === "accept" ? CheckIcon : XIcon) : null;
|
|
92
|
+
return (
|
|
93
|
+
<span
|
|
94
|
+
className={cn(
|
|
95
|
+
"flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
|
|
96
|
+
cell,
|
|
97
|
+
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
98
|
+
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
99
|
+
predicted && verdict && cn("font-semibold", TONE[verdict]),
|
|
100
|
+
)}
|
|
101
|
+
data-cyclotron-choice={choice}
|
|
102
|
+
data-predicted={predicted || undefined}
|
|
103
|
+
data-verdict={predicted && verdict ? verdict : undefined}
|
|
104
|
+
key={choice}
|
|
105
|
+
>
|
|
106
|
+
<span className="inline-flex items-center gap-1.5">
|
|
107
|
+
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
108
|
+
{WORD[choice]}
|
|
109
|
+
{predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
|
|
110
|
+
</span>
|
|
111
|
+
{predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
|
|
112
|
+
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
<div className="col-span-2 col-start-2 flex flex-col gap-1" data-confidence>
|
|
117
|
+
<div
|
|
118
|
+
aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
|
|
119
|
+
className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
120
|
+
data-confidence-bar
|
|
121
|
+
data-confidence-direction={ready ? (leansAccept ? "accept" : "reject") : "none"}
|
|
122
|
+
role="img"
|
|
123
|
+
>
|
|
124
|
+
{ready ? (
|
|
125
|
+
<span
|
|
126
|
+
className={cn("absolute inset-y-0 rounded-full", FILL[verdict ?? "neutral"])}
|
|
127
|
+
data-confidence-fill
|
|
128
|
+
data-fill-percent={Math.round(half * 2)}
|
|
129
|
+
style={{ left: leansAccept ? `${50 - half}%` : "50%", width: `${half}%` }}
|
|
130
|
+
/>
|
|
131
|
+
) : null}
|
|
132
|
+
<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)]")} />
|
|
133
|
+
</div>
|
|
134
|
+
{/* The line is always there, so a prediction arriving later never changes the card's height. */}
|
|
135
|
+
<small className="min-h-4 text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
136
|
+
{prediction.state === "ready" ? "\u00a0" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
137
|
+
</small>
|
|
138
|
+
</div>
|
|
139
|
+
</div> : null}
|
|
140
|
+
|
|
141
|
+
<div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
|
|
142
|
+
{showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
|
|
143
|
+
{(["accept", "reject"] as const).map((choice) => {
|
|
144
|
+
const on = answer === choice;
|
|
145
|
+
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
aria-disabled={disabled ? true : undefined}
|
|
149
|
+
aria-pressed={on}
|
|
150
|
+
className={cn(
|
|
151
|
+
"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",
|
|
152
|
+
cell,
|
|
153
|
+
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
154
|
+
disabled && !on && "opacity-60",
|
|
155
|
+
)}
|
|
156
|
+
data-review-choice={choice}
|
|
157
|
+
key={choice}
|
|
158
|
+
onClick={() => onChoose(choice)}
|
|
159
|
+
type="button"
|
|
160
|
+
>
|
|
161
|
+
<Icon aria-hidden="true" className={cn("size-4", !on && "opacity-60")} />
|
|
162
|
+
{WORD[choice]}
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className={cn("flex min-h-5 flex-wrap items-center gap-x-3 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
|
|
169
|
+
{note ? <span data-feed-reason>{note}</span> : null}
|
|
170
|
+
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
171
|
+
{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}
|
|
172
|
+
</div>
|
|
173
|
+
{disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
|
|
174
|
+
{feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -94,6 +94,10 @@ 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 { correctReferenceCapture } from "../lib/content-actions-client";
|
|
98
|
+
import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
|
|
99
|
+
import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
100
|
+
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
97
101
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
98
102
|
import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
|
|
99
103
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
@@ -696,7 +700,7 @@ export function NewsDeskWorkspace({
|
|
|
696
700
|
}
|
|
697
701
|
|
|
698
702
|
if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
|
|
699
|
-
return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
703
|
+
return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
700
704
|
}
|
|
701
705
|
|
|
702
706
|
if (dashboard) {
|
|
@@ -716,7 +720,7 @@ export function NewsDeskWorkspace({
|
|
|
716
720
|
);
|
|
717
721
|
}
|
|
718
722
|
|
|
719
|
-
return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
723
|
+
return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
720
724
|
}
|
|
721
725
|
|
|
722
726
|
function NewsDeskDashboard({
|
|
@@ -777,9 +781,16 @@ function NewsDeskDashboard({
|
|
|
777
781
|
const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
|
|
778
782
|
const [messages, setMessages] = useState(dashboard.messages);
|
|
779
783
|
const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
|
|
784
|
+
const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
// Display only: the last view this browser saved, shown until the live read arrives.
|
|
787
|
+
if (dashboard.isDemo) return;
|
|
788
|
+
const snapshot = loadFeedSnapshot(SITE_BRAND.id);
|
|
789
|
+
if (snapshot) setSavedFeedCopy(snapshot);
|
|
790
|
+
}, [dashboard.isDemo]);
|
|
780
791
|
const cyclotronPreviewFixtures = useMemo(
|
|
781
|
-
() => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
|
|
782
|
-
[references],
|
|
792
|
+
() => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
|
|
793
|
+
[references, semanticRelations],
|
|
783
794
|
);
|
|
784
795
|
const relevanceDecisions = useMemo(
|
|
785
796
|
() => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
|
|
@@ -1215,7 +1226,7 @@ function NewsDeskDashboard({
|
|
|
1215
1226
|
useEffect(() => {
|
|
1216
1227
|
if (!canLoadPrivateCorpus || hasHydratedReferences) return;
|
|
1217
1228
|
let active = true;
|
|
1218
|
-
void
|
|
1229
|
+
void loadReferencesWithHeadStart()
|
|
1219
1230
|
.then(({ references: nextReferences, referenceAttachments }) => {
|
|
1220
1231
|
if (!active) return;
|
|
1221
1232
|
setReferences(nextReferences);
|
|
@@ -1322,12 +1333,12 @@ function NewsDeskDashboard({
|
|
|
1322
1333
|
refreshNewsroomSections,
|
|
1323
1334
|
]);
|
|
1324
1335
|
|
|
1325
|
-
const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1336
|
+
const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1326
1337
|
const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
|
|
1327
1338
|
const referenceReviewData = useReferenceReviewData({
|
|
1328
1339
|
enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
|
|
1329
|
-
loadRelations:
|
|
1330
|
-
loadMessages:
|
|
1340
|
+
loadRelations: loadRelationsWithHeadStart,
|
|
1341
|
+
loadMessages: loadMessagesWithHeadStart,
|
|
1331
1342
|
onRelations: setSemanticRelations,
|
|
1332
1343
|
onMessages: setMessages,
|
|
1333
1344
|
});
|
|
@@ -1351,6 +1362,18 @@ function NewsDeskDashboard({
|
|
|
1351
1362
|
? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
|
|
1352
1363
|
: referenceReviewData.state;
|
|
1353
1364
|
const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
|
|
1365
|
+
useEffect(() => {
|
|
1366
|
+
// Keep a display copy for the next visit once everything the feed shows has been read.
|
|
1367
|
+
if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
|
|
1368
|
+
const timer = window.setTimeout(() => {
|
|
1369
|
+
saveFeedSnapshot(SITE_BRAND.id, {
|
|
1370
|
+
references: selectCanonicalReferenceRecords(references),
|
|
1371
|
+
previews: referencePreviewsByLineage(referenceAttachments),
|
|
1372
|
+
decisions: relevanceDecisions,
|
|
1373
|
+
});
|
|
1374
|
+
}, 800);
|
|
1375
|
+
return () => window.clearTimeout(timer);
|
|
1376
|
+
}, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
|
|
1354
1377
|
const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
|
|
1355
1378
|
|
|
1356
1379
|
const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
|
|
@@ -3035,7 +3058,7 @@ function NewsDeskDashboard({
|
|
|
3035
3058
|
headerActions={(
|
|
3036
3059
|
<>
|
|
3037
3060
|
{dashboard.isDemo ? (
|
|
3038
|
-
<Badge variant="outline">Demo</Badge>
|
|
3061
|
+
dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
|
|
3039
3062
|
) : (
|
|
3040
3063
|
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
3041
3064
|
)}
|
|
@@ -3081,9 +3104,17 @@ function NewsDeskDashboard({
|
|
|
3081
3104
|
assignments={assignments}
|
|
3082
3105
|
decisions={relevanceDecisions}
|
|
3083
3106
|
demo={Boolean(dashboard.isDemo)}
|
|
3107
|
+
disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
|
|
3084
3108
|
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3109
|
+
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3085
3110
|
newsroomSections={newsroomSections}
|
|
3111
|
+
notificationFixture={dashboard.notificationFixture ?? null}
|
|
3112
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, options?.reasonCode,
|
|
3113
|
+
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
|
|
3114
|
+
predictionState={referenceReviewData.state}
|
|
3115
|
+
referenceAttachments={referenceAttachments}
|
|
3086
3116
|
references={references}
|
|
3117
|
+
savedCopy={savedFeedCopy}
|
|
3087
3118
|
/>
|
|
3088
3119
|
) : null}
|
|
3089
3120
|
{activeTab === "search" ? (
|
|
@@ -3182,6 +3213,9 @@ function NewsDeskDashboard({
|
|
|
3182
3213
|
options?.sectionId)}
|
|
3183
3214
|
referenceAttachments={referenceAttachments}
|
|
3184
3215
|
references={references}
|
|
3216
|
+
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3217
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3218
|
+
savedCopy={savedFeedCopy}
|
|
3185
3219
|
/>
|
|
3186
3220
|
</NewsroomCurationBootstrap>
|
|
3187
3221
|
) : null}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
+
import type { RatingFeedback } from "./review-rating-grid";
|
|
7
|
+
|
|
8
|
+
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
|
+
export const NOTE_AUTOSAVE_MS = 1200;
|
|
10
|
+
|
|
11
|
+
export type ItemRating = {
|
|
12
|
+
/** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
|
|
13
|
+
note?: string;
|
|
14
|
+
reasonCode?: ReferenceRejectionReasonCode | null;
|
|
15
|
+
/** A vote that is waiting for a reason (Reject needs one) or is still saving. */
|
|
16
|
+
pending: ReviewChoice | null;
|
|
17
|
+
/** True while a Reject is waiting for its reason. */
|
|
18
|
+
askReason: boolean;
|
|
19
|
+
feedback: RatingFeedback;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
|
|
23
|
+
|
|
24
|
+
export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
|
|
25
|
+
|
|
26
|
+
export type RatingController = {
|
|
27
|
+
get: (id: string) => ItemRating;
|
|
28
|
+
choose: (id: string, choice: ReviewChoice) => void;
|
|
29
|
+
setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
|
|
30
|
+
setNote: (id: string, text: string) => void;
|
|
31
|
+
/** Save a typed reason now (on blur). */
|
|
32
|
+
commitNote: (id: string) => void;
|
|
33
|
+
cancelReason: (id: string) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
|
|
38
|
+
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
|
+
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
|
+
* reason, then vote -> the vote carries the reason
|
|
41
|
+
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
|
+
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
|
+
*/
|
|
44
|
+
export function useRatingStore({ save, saved, seed }: {
|
|
45
|
+
save: RatingSave;
|
|
46
|
+
saved: (id: string) => ReviewChoice | null;
|
|
47
|
+
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
|
+
}): RatingController {
|
|
49
|
+
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
50
|
+
const itemsRef = useRef(items);
|
|
51
|
+
const latest = useRef({ save, saved, seed });
|
|
52
|
+
latest.current = { save, saved, seed };
|
|
53
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
54
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
55
|
+
const dirty = useRef<Set<string>>(new Set());
|
|
56
|
+
|
|
57
|
+
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
58
|
+
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
59
|
+
itemsRef.current = next;
|
|
60
|
+
setItems(next);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
|
|
64
|
+
const get = useCallback((id: string): ItemRating => {
|
|
65
|
+
const item = items[id] ?? EMPTY;
|
|
66
|
+
const seeded = latest.current.seed(id);
|
|
67
|
+
return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
68
|
+
}, [items]);
|
|
69
|
+
|
|
70
|
+
const values = (id: string) => {
|
|
71
|
+
const item = itemsRef.current[id] ?? EMPTY;
|
|
72
|
+
const seeded = latest.current.seed(id);
|
|
73
|
+
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
77
|
+
const { note, reasonCode } = values(id);
|
|
78
|
+
if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
79
|
+
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
80
|
+
inFlight.current.add(id);
|
|
81
|
+
dirty.current.delete(id);
|
|
82
|
+
const started = itemsRef.current[id] ?? EMPTY;
|
|
83
|
+
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
84
|
+
try {
|
|
85
|
+
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
86
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
87
|
+
const now = itemsRef.current[id] ?? EMPTY;
|
|
88
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
// The typed reason stays in the store; only the vote is undone.
|
|
91
|
+
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
92
|
+
} finally {
|
|
93
|
+
inFlight.current.delete(id);
|
|
94
|
+
}
|
|
95
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
96
|
+
}, [patch]);
|
|
97
|
+
|
|
98
|
+
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
99
|
+
|
|
100
|
+
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
101
|
+
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
102
|
+
void commit(id, choice);
|
|
103
|
+
}, [commit]);
|
|
104
|
+
|
|
105
|
+
const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
|
|
106
|
+
patch(id, { reasonCode: code });
|
|
107
|
+
// Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
|
|
108
|
+
if (effective(id) === "reject") void commit(id, "reject");
|
|
109
|
+
}, [commit, patch]);
|
|
110
|
+
|
|
111
|
+
const setNote = useCallback((id: string, text: string) => {
|
|
112
|
+
patch(id, { note: text, feedback: { phase: "idle" } });
|
|
113
|
+
dirty.current.add(`note:${id}`);
|
|
114
|
+
clearTimeout(timers.current[id]);
|
|
115
|
+
timers.current[id] = setTimeout(() => {
|
|
116
|
+
const choice = effective(id);
|
|
117
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
118
|
+
}, NOTE_AUTOSAVE_MS);
|
|
119
|
+
}, [commit, patch]);
|
|
120
|
+
|
|
121
|
+
const commitNote = useCallback((id: string) => {
|
|
122
|
+
clearTimeout(timers.current[id]);
|
|
123
|
+
const choice = effective(id);
|
|
124
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
125
|
+
}, [commit]);
|
|
126
|
+
|
|
127
|
+
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
|
|
130
|
+
|
|
131
|
+
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
132
|
+
}
|
|
@@ -656,6 +656,10 @@ 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;
|
|
661
|
+
/** Local preview only: review requests and their drafts from scripts/export-notification-center-fixture.py. */
|
|
662
|
+
notificationFixture?: { requests: import("./review-requests").ReviewRequest[]; drafts: import("./review-drafts").DraftRow[] } | null;
|
|
659
663
|
isPublicSkeleton?: boolean;
|
|
660
664
|
summaryStatus?: "loading" | "missing" | "ready";
|
|
661
665
|
summary?: NewsroomSummaryRecord | null;
|
|
@@ -820,7 +824,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
|
|
|
820
824
|
}
|
|
821
825
|
|
|
822
826
|
function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
|
|
823
|
-
|
|
827
|
+
// A data-driven site names a seed file it does not ship: its Sections live in the backend, so there is nothing to seed.
|
|
828
|
+
if (!sectionsConfigPath || !fs.existsSync(sectionsConfigPath)) return [];
|
|
824
829
|
return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
|
|
825
830
|
...row,
|
|
826
831
|
enabledStatus: "enabled",
|
|
@@ -30,6 +30,18 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
|
|
|
30
30
|
export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
|
|
31
31
|
export type UnpublishItemResult = { ok: true; changed: boolean };
|
|
32
32
|
|
|
33
|
+
export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
|
|
34
|
+
export type CorrectReferenceCaptureInput = {
|
|
35
|
+
referenceId: string;
|
|
36
|
+
/** The full corrected or pasted text. */
|
|
37
|
+
text?: string;
|
|
38
|
+
grobid?: Record<string, unknown>;
|
|
39
|
+
/** Required with text; alone it is just a label on the capture. */
|
|
40
|
+
assessment?: CaptureAssessment;
|
|
41
|
+
note?: string;
|
|
42
|
+
};
|
|
43
|
+
export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
|
|
44
|
+
|
|
33
45
|
export class ContentActionFailure extends Error {
|
|
34
46
|
readonly errors: ContentActionError[];
|
|
35
47
|
|
|
@@ -48,6 +60,7 @@ export type ContentActionsTransport = {
|
|
|
48
60
|
saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
49
61
|
publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
50
62
|
unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
63
|
+
correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
51
64
|
};
|
|
52
65
|
};
|
|
53
66
|
|
|
@@ -80,6 +93,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
|
|
|
80
93
|
async publishItem(id: string): Promise<PublishItemResult> {
|
|
81
94
|
return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
82
95
|
},
|
|
96
|
+
async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
|
|
97
|
+
return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
|
|
98
|
+
},
|
|
83
99
|
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
84
100
|
return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
85
101
|
},
|
|
@@ -99,3 +115,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
|
|
|
99
115
|
export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
|
|
100
116
|
export const publishItem = (id: string) => contentActionsClient().publishItem(id);
|
|
101
117
|
export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
|
|
118
|
+
|
|
119
|
+
export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);
|