@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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the newsroom actually captured of a Reference, so an editor can tell whether there is anything to rate.
|
|
3
|
+
*
|
|
4
|
+
* Curation before review (docs/reference-curation-pipeline.md) stores ONE `ReferenceAttachment` per Reference with role `curation` and sort
|
|
5
|
+
* key `003-curation`: a card with a status (`ready` / `degraded` / `capture_failed` / `running`), typed reasons, completeness signals, the
|
|
6
|
+
* summary and GROBID counts; the bodies are `ModelAttachment` rows (`curation-text`, `curation-grobid`, `curation-edit-NNNN-*`). That card is
|
|
7
|
+
* read first. Older captured text lives in `ReferenceAttachment` rows (the fallback): role `extracted_text` (the canonical text, possibly filtered to the article) and
|
|
8
|
+
* `extracted_text_raw` (what extraction returned), each with a `storagePath` to the stored text and a metadata object that carries
|
|
9
|
+
* `textLength` (characters), `title`, and any structured extraction (`grobid`, `structured`) written by `papyrus references
|
|
10
|
+
* process-fetch-url-text` / `process-attach-extracted-text`. The summary is a `metadata` payload on the Reference. A Reference that
|
|
11
|
+
* monitoring discovered has only its `source` attachment (the address): nothing captured.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReferenceAttachmentRecord } from "./category-repository";
|
|
14
|
+
|
|
15
|
+
export type CaptureState = "none" | "thin" | "full" | "running";
|
|
16
|
+
|
|
17
|
+
export type CurationStatus = "ready" | "degraded" | "capture_failed" | "running";
|
|
18
|
+
|
|
19
|
+
export type CurationCard = {
|
|
20
|
+
status: CurationStatus;
|
|
21
|
+
capture: { status?: string; reason?: string; transient?: boolean; sourceKind?: string; wordCount?: number; textSortKey?: string };
|
|
22
|
+
signals: Record<string, unknown>;
|
|
23
|
+
editedSignals: Record<string, unknown> | null;
|
|
24
|
+
summary: { status?: string; text?: string; stale?: boolean; reason?: string } | null;
|
|
25
|
+
grobid: { status?: string; reason?: string; title?: string; authorCount?: number; hasAbstract?: boolean; sectionCount?: number; referenceCount?: number; fieldsSortKey?: string } | null;
|
|
26
|
+
current: { textSortKey?: string; grobidSortKey?: string } | null;
|
|
27
|
+
labels: Array<{ kind?: string; assessment?: string; labeledAt?: string }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CaptureStatus = {
|
|
31
|
+
state: CaptureState;
|
|
32
|
+
/** Words in the stored text; an estimate (`estimated`) until the text itself is loaded. */
|
|
33
|
+
words: number | null;
|
|
34
|
+
estimated: boolean;
|
|
35
|
+
/** Where the status came from: the curation card, the older extracted-text attachment, or nothing. */
|
|
36
|
+
source?: "curation" | "extracted" | "none";
|
|
37
|
+
/** The curation card's own status, when there is a card. */
|
|
38
|
+
curation?: CurationStatus | null;
|
|
39
|
+
/** Why nothing was captured (the card's typed reason). */
|
|
40
|
+
reason?: string | null;
|
|
41
|
+
/** Plain-words signs the text may be cut off or paywalled (from the card's completeness signals). */
|
|
42
|
+
flags?: string[];
|
|
43
|
+
/** The editor's own verdict on the capture, when they gave one. */
|
|
44
|
+
assessment?: string | null;
|
|
45
|
+
summaryStale?: boolean;
|
|
46
|
+
/** Why a thin capture is thin: too few words, or signs in the text that it is cut off or paywalled. */
|
|
47
|
+
thinBy?: "length" | "signals";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
|
|
51
|
+
|
|
52
|
+
export const CURATION_ROLE = "curation";
|
|
53
|
+
export const CURATION_SORT_KEY = "003-curation";
|
|
54
|
+
|
|
55
|
+
/** Without enough captured texts to learn from, text shorter than this many words reads as a teaser. */
|
|
56
|
+
export const DEFAULT_TEASER_WORDS = 250;
|
|
57
|
+
/** With at least this many captured texts, the teaser line is learned: a quarter of the corpus' median length. */
|
|
58
|
+
export const MINIMUM_CAPTURES_TO_LEARN = 10;
|
|
59
|
+
const AVERAGE_CHARACTERS_PER_WORD = 6;
|
|
60
|
+
|
|
61
|
+
export const EXTRACTED_TEXT_ROLES = ["extracted_text", "extracted_text_raw"] as const;
|
|
62
|
+
|
|
63
|
+
function metadataOf(attachment: ReferenceAttachmentRecord): Record<string, unknown> {
|
|
64
|
+
const raw = attachment.metadata;
|
|
65
|
+
if (typeof raw === "string") {
|
|
66
|
+
try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; }
|
|
67
|
+
}
|
|
68
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function countWords(text: string): number {
|
|
72
|
+
const matches = text.trim().match(/\S+/g);
|
|
73
|
+
return matches ? matches.length : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The characters an extracted-text attachment holds, from its own record (no download). */
|
|
77
|
+
function recordedCharacters(attachment: ReferenceAttachmentRecord): number | null {
|
|
78
|
+
const length = metadataOf(attachment).textLength;
|
|
79
|
+
if (typeof length === "number" && length >= 0) return length;
|
|
80
|
+
return typeof attachment.byteSize === "number" && attachment.byteSize >= 0 ? attachment.byteSize : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function objectOf(value: unknown): Record<string, unknown> {
|
|
84
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The curation card of a Reference, from the attachments the newsroom already loads. Null when curation has not run. */
|
|
88
|
+
export function curationCardOf(attachments: readonly ReferenceAttachmentRecord[]): CurationCard | null {
|
|
89
|
+
const row = attachments.find((attachment) => attachment.role === CURATION_ROLE && (attachment.sortKey === CURATION_SORT_KEY || !attachment.sortKey));
|
|
90
|
+
if (!row) return null;
|
|
91
|
+
const card = metadataOf(row);
|
|
92
|
+
const status = card.status;
|
|
93
|
+
if (status !== "ready" && status !== "degraded" && status !== "capture_failed" && status !== "running") return null;
|
|
94
|
+
const grobid = card.grobid ? objectOf(card.grobid) : null;
|
|
95
|
+
const summary = card.summary ? objectOf(card.summary) : null;
|
|
96
|
+
const current = card.current ? objectOf(card.current) : null;
|
|
97
|
+
return {
|
|
98
|
+
status,
|
|
99
|
+
capture: objectOf(card.capture) as CurationCard["capture"],
|
|
100
|
+
signals: objectOf(card.signals),
|
|
101
|
+
editedSignals: card.editedSignals ? objectOf(card.editedSignals) : null,
|
|
102
|
+
summary: summary as CurationCard["summary"],
|
|
103
|
+
grobid: grobid as CurationCard["grobid"],
|
|
104
|
+
current: current as CurationCard["current"],
|
|
105
|
+
labels: Array.isArray(card.labels) ? card.labels.map((label) => objectOf(label) as CurationCard["labels"][number]) : [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const REASON_WORDS: Record<string, string> = {
|
|
110
|
+
blocked: "The site refused our request. It may be paywalled or protect itself from bots.",
|
|
111
|
+
http_4xx: "The page is gone or refused the request.",
|
|
112
|
+
http_5xx: "The site had an error when we asked.",
|
|
113
|
+
timeout: "The site took too long to answer.",
|
|
114
|
+
connection: "We could not connect to the site.",
|
|
115
|
+
dns: "The site's address did not resolve.",
|
|
116
|
+
tls: "The site's certificate could not be verified.",
|
|
117
|
+
too_large: "The document is larger than we read.",
|
|
118
|
+
unreadable: "The page has nothing we could read.",
|
|
119
|
+
no_text: "The page held no readable text.",
|
|
120
|
+
unsupported_media: "The address is not a page or a PDF.",
|
|
121
|
+
no_source_uri: "The record has no web address.",
|
|
122
|
+
not_public: "The address is not a public web page.",
|
|
123
|
+
invalid_url: "The address is not valid.",
|
|
124
|
+
loop: "The address redirects in a circle.",
|
|
125
|
+
unsafe_destination: "The destination did not pass our safety check.",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function captureReasonWords(reason: string | null | undefined): string | null {
|
|
129
|
+
return reason ? REASON_WORDS[reason] ?? `Capture failed (${reason}).` : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Plain-words signs, from the card's completeness signals, that the text may be cut off or paywalled. Facts, never a verdict. */
|
|
133
|
+
export function completenessFlags(signals: Record<string, unknown>): string[] {
|
|
134
|
+
const flags: string[] = [];
|
|
135
|
+
const markers = Array.isArray(signals.paywallMarkers) ? signals.paywallMarkers.map((marker) => objectOf(marker)) : [];
|
|
136
|
+
if (markers.some((marker) => marker.inTail === true)) flags.push("The text ends with a subscribe or sign-in phrase.");
|
|
137
|
+
else if (markers.length) flags.push("The text mentions subscribing or signing in.");
|
|
138
|
+
if (signals.endingPattern === "ellipsis" || signals.endingPattern === "truncation_marker") flags.push("The text stops with an ellipsis or a cut-off marker.");
|
|
139
|
+
else if (signals.endingPattern === "no_terminal_punctuation") flags.push("The text stops mid-sentence.");
|
|
140
|
+
if (signals.bodyNotLongerThanDescription === true) flags.push("The text is no longer than the page's own description.");
|
|
141
|
+
if (signals.structuredAccessibleForFree === false) flags.push("The page declares the article is not free to read.");
|
|
142
|
+
const ratio = signals.wordCountRatioToDeclared;
|
|
143
|
+
if (typeof ratio === "number" && ratio < 0.6 && typeof signals.declaredWordCount === "number") flags.push(`The page declares ${signals.declaredWordCount} words; we hold about ${Math.round(ratio * 100)}% of that.`);
|
|
144
|
+
if (signals.responseTruncated === true) flags.push("The page was larger than we read and was cut.");
|
|
145
|
+
const empty = signals.pdfEmptyPageCount;
|
|
146
|
+
if (typeof empty === "number" && empty > 0 && typeof signals.pdfPageCount === "number") flags.push(`${empty} of ${signals.pdfPageCount} PDF pages had no text (scanned).`);
|
|
147
|
+
return flags;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function extractedTextAttachments(attachments: readonly ReferenceAttachmentRecord[]): ReferenceAttachmentRecord[] {
|
|
151
|
+
return attachments.filter((attachment) => (EXTRACTED_TEXT_ROLES as readonly string[]).includes(attachment.role) && Boolean(attachment.storagePath || attachment.sourceUri));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The attachment to read: the filtered canonical text first, then the raw extraction; the newest of each. */
|
|
155
|
+
export function primaryTextAttachment(attachments: readonly ReferenceAttachmentRecord[]): ReferenceAttachmentRecord | null {
|
|
156
|
+
const newest = (rows: ReferenceAttachmentRecord[]) => [...rows].sort((left, right) => String(right.importedAt ?? "").localeCompare(String(left.importedAt ?? "")) || right.id.localeCompare(left.id))[0] ?? null;
|
|
157
|
+
const rows = extractedTextAttachments(attachments);
|
|
158
|
+
return newest(rows.filter((row) => row.role === "extracted_text")) ?? newest(rows.filter((row) => row.role === "extracted_text_raw"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Words in the card's current text: the editor's corrected count when there is one, else the capture's. */
|
|
162
|
+
function curationWords(card: CurationCard): number | null {
|
|
163
|
+
const edited = card.editedSignals?.wordCount;
|
|
164
|
+
if (typeof edited === "number") return edited;
|
|
165
|
+
const captured = card.capture.wordCount ?? card.signals.wordCount;
|
|
166
|
+
return typeof captured === "number" ? captured : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Estimated word count of one Reference's stored text, from its record alone. */
|
|
170
|
+
export function estimatedWords(attachments: readonly ReferenceAttachmentRecord[]): number | null {
|
|
171
|
+
const card = curationCardOf(attachments);
|
|
172
|
+
const cardWords = card ? curationWords(card) : null;
|
|
173
|
+
if (cardWords !== null && cardWords > 0) return cardWords;
|
|
174
|
+
const primary = primaryTextAttachment(attachments);
|
|
175
|
+
const characters = primary ? recordedCharacters(primary) : null;
|
|
176
|
+
return characters === null ? null : Math.round(characters / AVERAGE_CHARACTERS_PER_WORD);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Where "thin" starts for this corpus: learned from its captured texts, else the default. */
|
|
180
|
+
export function teaserWords(capturedWordCounts: readonly number[]): number {
|
|
181
|
+
if (capturedWordCounts.length < MINIMUM_CAPTURES_TO_LEARN) return DEFAULT_TEASER_WORDS;
|
|
182
|
+
const sorted = [...capturedWordCounts].sort((left, right) => left - right);
|
|
183
|
+
const middle = Math.floor(sorted.length / 2);
|
|
184
|
+
const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
185
|
+
return Math.max(50, Math.round(median * 0.25));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function captureStatus(attachments: readonly ReferenceAttachmentRecord[], teaserLine = DEFAULT_TEASER_WORDS, loadedWords: number | null = null): CaptureStatus {
|
|
189
|
+
const card = curationCardOf(attachments);
|
|
190
|
+
const assessment = card?.labels.filter((label) => label.kind === "capture_completeness").at(-1)?.assessment ?? null;
|
|
191
|
+
const summaryStale = Boolean(card?.summary?.stale);
|
|
192
|
+
const cardWords = card ? curationWords(card) : null;
|
|
193
|
+
const cardHasText = Boolean(card && (cardWords ?? 0) > 0 && (card.capture.status === "completed" || card.current?.textSortKey));
|
|
194
|
+
if (card && cardHasText) {
|
|
195
|
+
const words = loadedWords ?? cardWords;
|
|
196
|
+
const flags = completenessFlags(card.editedSignals ?? card.signals);
|
|
197
|
+
const thin = words !== null && words < teaserLine;
|
|
198
|
+
const edited = Boolean(card.current?.textSortKey?.startsWith("curation-edit"));
|
|
199
|
+
// A complete text an editor vouched for is not second-guessed by the signals.
|
|
200
|
+
const doubtful = !edited && assessment !== "complete" && flags.length > 0 && !thin ? flags.some((flag) => /ends with|stops|no longer|not free|cut|declares/.test(flag)) : false;
|
|
201
|
+
return { state: thin || doubtful ? "thin" : "full", words, estimated: false, source: "curation", curation: card.status, reason: null, flags, assessment, summaryStale, ...(thin ? { thinBy: "length" as const } : doubtful ? { thinBy: "signals" as const } : {}) };
|
|
202
|
+
}
|
|
203
|
+
if (primaryTextAttachment(attachments)) {
|
|
204
|
+
const words = loadedWords ?? estimatedWords(attachments);
|
|
205
|
+
if (words === null) return { state: "thin", words: null, estimated: true, source: "extracted", curation: card?.status ?? null, reason: null, flags: [], assessment, summaryStale };
|
|
206
|
+
return { state: words === 0 ? "none" : words < teaserLine ? "thin" : "full", words, estimated: loadedWords === null, source: "extracted", curation: card?.status ?? null, reason: null, flags: [], assessment, summaryStale, ...(words > 0 && words < teaserLine ? { thinBy: "length" as const } : {}) };
|
|
207
|
+
}
|
|
208
|
+
if (card && card.status === "running") return { state: "running", words: null, estimated: false, source: "curation", curation: "running", reason: null, flags: [], assessment, summaryStale };
|
|
209
|
+
return { state: "none", words: null, estimated: false, source: "none", curation: card?.status ?? null, reason: card?.capture.reason ?? null, flags: [], assessment, summaryStale };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Capture status for every Reference lineage that has attachments, using one corpus-wide teaser line. */
|
|
213
|
+
export function captureStatusesByLineage(attachments: readonly ReferenceAttachmentRecord[], lineageIds: Iterable<string>): { statuses: Map<string, CaptureStatus>; teaserLine: number } {
|
|
214
|
+
const byLineage = new Map<string, ReferenceAttachmentRecord[]>();
|
|
215
|
+
for (const attachment of attachments) {
|
|
216
|
+
const list = byLineage.get(attachment.referenceLineageId) ?? [];
|
|
217
|
+
list.push(attachment);
|
|
218
|
+
byLineage.set(attachment.referenceLineageId, list);
|
|
219
|
+
}
|
|
220
|
+
const counts = [...byLineage.values()].map(estimatedWords).filter((words): words is number => words !== null && words > 0);
|
|
221
|
+
const teaserLine = teaserWords(counts);
|
|
222
|
+
const statuses = new Map<string, CaptureStatus>();
|
|
223
|
+
for (const lineageId of lineageIds) statuses.set(lineageId, captureStatus(byLineage.get(lineageId) ?? [], teaserLine));
|
|
224
|
+
return { statuses, teaserLine };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type CaptureLine = { state: CaptureState; label: string; detail: string };
|
|
228
|
+
|
|
229
|
+
/** The plain-words status line. */
|
|
230
|
+
export function captureLine(status: CaptureStatus): CaptureLine {
|
|
231
|
+
const count = status.words === null ? null : `${status.estimated ? "about " : ""}${status.words.toLocaleString("en-US")} words`;
|
|
232
|
+
if (status.state === "running") return { state: "running", label: "Being captured now", detail: "Curation is reading this item. Check back in a moment." };
|
|
233
|
+
if (status.state === "none") {
|
|
234
|
+
const why = captureReasonWords(status.reason);
|
|
235
|
+
return { state: "none", label: "No text captured", detail: why ?? (status.curation === "capture_failed" ? "Capture failed." : "Only the link is on record. There is nothing here to judge the content by.") };
|
|
236
|
+
}
|
|
237
|
+
if (status.state === "thin") {
|
|
238
|
+
const byLength = status.thinBy !== "signals";
|
|
239
|
+
return {
|
|
240
|
+
state: "thin",
|
|
241
|
+
label: status.words === null ? "Only a little text captured — may be a teaser or paywalled" : byLength ? `Only ${status.words.toLocaleString("en-US")} words — may be a teaser or paywalled` : "Captured, but it may be cut off or paywalled",
|
|
242
|
+
detail: status.flags?.length ? status.flags.join(" ") : "Check the source before you rate it.",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { state: "full", label: "Full text captured", detail: count ?? "" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Structured extraction (GROBID for papers, or the structured summary) as plain label and value rows. */
|
|
249
|
+
export function structuredRows(attachments: readonly ReferenceAttachmentRecord[]): Array<{ label: string; value: string }> {
|
|
250
|
+
const rows: Array<{ label: string; value: string }> = [];
|
|
251
|
+
const raw = attachments.find((attachment) => attachment.role === "extracted_text_raw") ?? primaryTextAttachment(attachments);
|
|
252
|
+
if (!raw) return rows;
|
|
253
|
+
const metadata = metadataOf(raw);
|
|
254
|
+
const add = (label: string, value: unknown) => {
|
|
255
|
+
if (value === null || value === undefined || value === "") return;
|
|
256
|
+
const text = Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry : (entry as { name?: string })?.name ?? "")).filter(Boolean).join(", ")
|
|
257
|
+
: typeof value === "object" ? "" : String(value);
|
|
258
|
+
if (text.trim()) rows.push({ label, value: text.length > 400 ? `${text.slice(0, 397)}…` : text });
|
|
259
|
+
};
|
|
260
|
+
for (const source of [metadata.grobid, metadata.structured]) {
|
|
261
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) continue;
|
|
262
|
+
const record = source as Record<string, unknown>;
|
|
263
|
+
add("Title", record.title);
|
|
264
|
+
add("Authors", record.authors);
|
|
265
|
+
add("Abstract", record.abstract);
|
|
266
|
+
add("DOI", record.doi);
|
|
267
|
+
add("Published", record.published ?? record.date ?? record.year);
|
|
268
|
+
add("Venue", record.venue ?? record.journal);
|
|
269
|
+
if (Array.isArray(record.citations)) add("Citations", `${record.citations.length} cited works`);
|
|
270
|
+
if (Array.isArray(record.references)) add("References", `${record.references.length} cited works`);
|
|
271
|
+
add("Keywords", record.keywords);
|
|
272
|
+
}
|
|
273
|
+
return rows;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Rows for the structured extraction from the card's GROBID fields body (`curation-grobid`), else from its counts. */
|
|
277
|
+
export function grobidRows(fields: unknown, card: CurationCard | null): Array<{ label: string; value: string }> {
|
|
278
|
+
const rows: Array<{ label: string; value: string }> = [];
|
|
279
|
+
const record = objectOf(fields);
|
|
280
|
+
const add = (label: string, value: unknown) => { if (typeof value === "string" && value.trim()) rows.push({ label, value: value.length > 500 ? `${value.slice(0, 497)}…` : value }); };
|
|
281
|
+
add("Title", record.title ?? card?.grobid?.title);
|
|
282
|
+
if (Array.isArray(record.authors) && record.authors.length) {
|
|
283
|
+
add("Authors", record.authors.map((author) => (typeof author === "string" ? author : String(objectOf(author).name ?? ""))).filter(Boolean).join(", "));
|
|
284
|
+
} else if (typeof card?.grobid?.authorCount === "number") add("Authors", `${card.grobid.authorCount} authors`);
|
|
285
|
+
add("Abstract", record.abstract);
|
|
286
|
+
add("DOI", record.doi);
|
|
287
|
+
if (Array.isArray(record.keywords) && record.keywords.length) add("Keywords", record.keywords.join(", "));
|
|
288
|
+
const sections = Array.isArray(record.sections) ? record.sections.length : card?.grobid?.sectionCount;
|
|
289
|
+
if (typeof sections === "number") add("Sections", `${sections} sections`);
|
|
290
|
+
const references = Array.isArray(record.references) ? record.references.length : card?.grobid?.referenceCount;
|
|
291
|
+
if (typeof references === "number") add("References", `${references} cited works`);
|
|
292
|
+
return rows;
|
|
293
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
import type { DraftView } from "./review-drafts";
|
|
11
|
+
import type { ReviewRequest } from "./review-requests";
|
|
12
|
+
|
|
13
|
+
const FEED_PREFIX = "papyrus.references.feed.v1.";
|
|
14
|
+
const SESSION_PREFIX = "papyrus.newsroom.session.v1.";
|
|
15
|
+
const NOTIFICATION_PREFIX = "papyrus.notifications.v1.";
|
|
16
|
+
/** A saved session summary is trusted for display only this long; the live check always still runs. */
|
|
17
|
+
export const SESSION_DISPLAY_TTL_MS = 15 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export type FeedSnapshot = {
|
|
20
|
+
savedAt: number;
|
|
21
|
+
references: ReferenceRecord[];
|
|
22
|
+
previews: Array<[string, ReferencePreview]>;
|
|
23
|
+
decisions: Array<[string, RelevanceDecisionView]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SessionDisplay = {
|
|
27
|
+
savedAt: number;
|
|
28
|
+
label: string;
|
|
29
|
+
isAdmin: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function storageOf(kind: "local" | "session"): Storage | null {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof window === "undefined") return null;
|
|
35
|
+
return kind === "local" ? window.localStorage : window.sessionStorage;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function read<T>(kind: "local" | "session", key: string): T | null {
|
|
42
|
+
try {
|
|
43
|
+
const raw = storageOf(kind)?.getItem(key);
|
|
44
|
+
return raw ? JSON.parse(raw) as T : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function write(kind: "local" | "session", key: string, value: unknown): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const storage = storageOf(kind);
|
|
53
|
+
if (!storage) return false;
|
|
54
|
+
storage.setItem(key, JSON.stringify(value));
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function remove(kind: "local" | "session", key: string) {
|
|
62
|
+
try { storageOf(kind)?.removeItem(key); } catch { /* nothing to clear */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The fields a card needs, and nothing else: a smaller copy that fits, and less private text at rest. */
|
|
66
|
+
export function slimReference(reference: ReferenceRecord): ReferenceRecord {
|
|
67
|
+
return {
|
|
68
|
+
id: reference.id,
|
|
69
|
+
lineageId: reference.lineageId,
|
|
70
|
+
versionNumber: reference.versionNumber,
|
|
71
|
+
versionState: reference.versionState,
|
|
72
|
+
corpusId: reference.corpusId,
|
|
73
|
+
externalItemId: reference.externalItemId,
|
|
74
|
+
title: reference.title,
|
|
75
|
+
sourceUri: reference.sourceUri,
|
|
76
|
+
mediaType: reference.mediaType,
|
|
77
|
+
sourcePublishedAt: reference.sourcePublishedAt,
|
|
78
|
+
retrievedAt: reference.retrievedAt,
|
|
79
|
+
importedAt: reference.importedAt,
|
|
80
|
+
createdAt: reference.createdAt,
|
|
81
|
+
curationStatus: reference.curationStatus,
|
|
82
|
+
curationStatusUpdatedAt: reference.curationStatusUpdatedAt,
|
|
83
|
+
curationStatusReason: reference.curationStatusReason,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function feedCacheKey(siteId: string): string {
|
|
88
|
+
return `${FEED_PREFIX}${siteId}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadFeedSnapshot(siteId: string): FeedSnapshot | null {
|
|
92
|
+
const snapshot = read<FeedSnapshot>("local", feedCacheKey(siteId));
|
|
93
|
+
if (!snapshot || !Array.isArray(snapshot.references) || !snapshot.references.length) return null;
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Saves the newest copy. On a full store it keeps the newest half, then gives up quietly. */
|
|
98
|
+
export function saveFeedSnapshot(
|
|
99
|
+
siteId: string,
|
|
100
|
+
parts: { references: ReferenceRecord[]; previews: ReadonlyMap<string, ReferencePreview>; decisions: ReadonlyMap<string, RelevanceDecisionView> },
|
|
101
|
+
now = Date.now(),
|
|
102
|
+
): boolean {
|
|
103
|
+
let references = parts.references.map(slimReference);
|
|
104
|
+
for (let attempt = 0; attempt < 4 && references.length; attempt += 1) {
|
|
105
|
+
const kept = new Set(references.map((reference) => reference.lineageId ?? reference.id));
|
|
106
|
+
const snapshot: FeedSnapshot = {
|
|
107
|
+
savedAt: now,
|
|
108
|
+
references,
|
|
109
|
+
previews: [...parts.previews].filter(([lineage]) => kept.has(lineage)),
|
|
110
|
+
decisions: [...parts.decisions].filter(([lineage]) => kept.has(lineage)).map(([lineage, decision]) => [lineage, { ...decision, provenance: null }]),
|
|
111
|
+
};
|
|
112
|
+
if (write("local", feedCacheKey(siteId), snapshot)) return true;
|
|
113
|
+
references = references.slice(0, Math.ceil(references.length / 2));
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearFeedSnapshot(siteId: string) {
|
|
119
|
+
remove("local", feedCacheKey(siteId));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function loadSessionDisplay(siteId: string, now = Date.now()): SessionDisplay | null {
|
|
123
|
+
const saved = read<SessionDisplay>("session", `${SESSION_PREFIX}${siteId}`);
|
|
124
|
+
if (!saved || typeof saved.label !== "string" || typeof saved.savedAt !== "number") return null;
|
|
125
|
+
return now - saved.savedAt <= SESSION_DISPLAY_TTL_MS ? saved : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function saveSessionDisplay(siteId: string, display: Omit<SessionDisplay, "savedAt">, now = Date.now()) {
|
|
129
|
+
write("session", `${SESSION_PREFIX}${siteId}`, { ...display, savedAt: now });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What the Notification Center last showed: review requests (without their history) and the drafts they cover. Display only. */
|
|
133
|
+
export type NotificationSnapshot = {
|
|
134
|
+
savedAt: number;
|
|
135
|
+
requests: ReviewRequest[];
|
|
136
|
+
drafts: Record<string, DraftView>;
|
|
137
|
+
missing: string[];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function notificationCacheKey(siteId: string): string {
|
|
141
|
+
return `${NOTIFICATION_PREFIX}${siteId}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function loadNotificationSnapshot(siteId: string): NotificationSnapshot | null {
|
|
145
|
+
const snapshot = read<NotificationSnapshot>("local", notificationCacheKey(siteId));
|
|
146
|
+
if (!snapshot || !Array.isArray(snapshot.requests) || typeof snapshot.drafts !== "object" || !snapshot.drafts) return null;
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Saves the newest copy; who did what stays out of storage. Quiet when storage is blocked or full. */
|
|
151
|
+
export function saveNotificationSnapshot(siteId: string, parts: { requests: ReviewRequest[]; drafts: Readonly<Record<string, DraftView>>; missing: Iterable<string> }, now = Date.now()): boolean {
|
|
152
|
+
const snapshot: NotificationSnapshot = {
|
|
153
|
+
savedAt: now,
|
|
154
|
+
requests: parts.requests.map((request) => ({ ...request, history: [] })),
|
|
155
|
+
drafts: { ...parts.drafts },
|
|
156
|
+
missing: [...parts.missing],
|
|
157
|
+
};
|
|
158
|
+
return write("local", notificationCacheKey(siteId), snapshot);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function clearNotificationSnapshot(siteId: string) {
|
|
162
|
+
remove("local", notificationCacheKey(siteId));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Sign-out: nothing of the signed-in view survives in this browser. */
|
|
166
|
+
export function clearSavedNewsroomDisplay(siteId: string) {
|
|
167
|
+
remove("session", `${SESSION_PREFIX}${siteId}`);
|
|
168
|
+
clearFeedSnapshot(siteId);
|
|
169
|
+
clearNotificationSnapshot(siteId);
|
|
170
|
+
}
|