@anthusai/papyrus 5.45.0 → 5.47.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/bin/papyrus-app.mjs +12 -1
- package/components/news-desk-client-provider.tsx +2 -2
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-organizer.css +5 -2
- package/components/newsroom-references-view.tsx +120 -8
- 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 +94 -60
- package/components/reference-review-card.tsx +21 -66
- package/components/references-prefetch.ts +5 -0
- package/components/review-rating-grid.tsx +31 -15
- package/components/topic-steering-workspace.tsx +12 -2
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +2 -0
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +20 -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 +37 -0
- package/lib/reference-feed-ranking.ts +44 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notification Center as a feed of cards ("What needs me?"): which cards there are, how they are counted, filtered and
|
|
3
|
+
* worded. Pure functions, so the page and its tests share them. A review request that covers several AI drafts is not shown as a
|
|
4
|
+
* batch: each draft is its own card, with a small caption saying which run it came from. The request still closes itself when
|
|
5
|
+
* its last draft is decided (components/notification-center.tsx).
|
|
6
|
+
*/
|
|
7
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
8
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
9
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
10
|
+
import type { ReferencePreview } from "./reference-preview";
|
|
11
|
+
import { draftDecisionOf, type DraftView } from "./review-drafts";
|
|
12
|
+
import { draftIdsOf, isDraftReview, proposalHref, sectionReviews, type DraftDecision, type ReviewRequest } from "./review-requests";
|
|
13
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
14
|
+
|
|
15
|
+
export type NotificationFilter = "all" | "drafts" | "references" | "other";
|
|
16
|
+
export const NOTIFICATION_FILTERS: readonly NotificationFilter[] = ["all", "drafts", "references", "other"];
|
|
17
|
+
export const FILTER_LABELS: Record<NotificationFilter, string> = { all: "All", drafts: "Drafts", references: "References", other: "Other" };
|
|
18
|
+
|
|
19
|
+
export type DraftItem = {
|
|
20
|
+
kind: "draft";
|
|
21
|
+
/** `<request id>#<draft id>`. */
|
|
22
|
+
key: string;
|
|
23
|
+
request: ReviewRequest;
|
|
24
|
+
draftId: string;
|
|
25
|
+
/** Undefined while the Proposal is still loading. */
|
|
26
|
+
view: DraftView | undefined;
|
|
27
|
+
decision: DraftDecision | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A request that is decided as a whole: a hard gate, a soft review of something that is not a draft, or a flag. */
|
|
31
|
+
export type RequestItem = { kind: "request"; key: string; request: ReviewRequest };
|
|
32
|
+
|
|
33
|
+
export type NotificationItems = {
|
|
34
|
+
drafts: DraftItem[];
|
|
35
|
+
/** Hard gates first, then the other soft reviews, soonest deadline first. */
|
|
36
|
+
reviews: RequestItem[];
|
|
37
|
+
/** For your information: flags. */
|
|
38
|
+
flags: RequestItem[];
|
|
39
|
+
counts: NotificationCounts;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type NotificationCounts = { drafts: number; references: number; other: number };
|
|
43
|
+
|
|
44
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
45
|
+
|
|
46
|
+
/** "Oct 27", in UTC so the words do not change with the reader's time zone. */
|
|
47
|
+
export function shortDate(iso: string | null | undefined): string {
|
|
48
|
+
if (!iso) return "";
|
|
49
|
+
const date = new Date(iso);
|
|
50
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
51
|
+
return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sentenceCase(slug: string): string {
|
|
55
|
+
const spaced = slug.replace(/[-_.]+/g, " ").trim();
|
|
56
|
+
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where a request came from, in words: the run its batch names ("Monitor stories run"), else the step that raised it. */
|
|
60
|
+
export function requestSource(request: Pick<ReviewRequest, "subject" | "raisedBy">): string {
|
|
61
|
+
if (request.subject.type === "batch") {
|
|
62
|
+
const named = request.subject.id.replace(/-\d{8}t\d{6}z.*$/i, "").replace(/-[0-9a-f]{6,}$/i, "");
|
|
63
|
+
if (/[a-z]{3,}/i.test(named)) return sentenceCase(named);
|
|
64
|
+
}
|
|
65
|
+
return sentenceCase(request.raisedBy) || "An automated step";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The small caption on a card that came out of a request: "from Monitor stories run · Oct 27". */
|
|
69
|
+
export function requestCaption(request: Pick<ReviewRequest, "subject" | "raisedBy" | "raisedAt">): string {
|
|
70
|
+
const when = shortDate(request.raisedAt);
|
|
71
|
+
return `from ${requestSource(request)}${when ? ` · ${when}` : ""}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NO_FALLBACK = { by: "", at: "" } as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The cards for review requests. A draft review becomes one card per draft: undecided drafts of open requests, plus drafts decided in
|
|
78
|
+
* this visit (`stay`, keyed `<request id>#<draft id>`) so a card does not vanish under the editor's thumb. Drafts that are not Proposals
|
|
79
|
+
* on this site cannot be opened here; when none can, the request itself is one card that takes Accept or Reject.
|
|
80
|
+
*/
|
|
81
|
+
export function buildNotificationItems(input: {
|
|
82
|
+
requests: ReviewRequest[];
|
|
83
|
+
drafts: Readonly<Record<string, DraftView | undefined>>;
|
|
84
|
+
missingDrafts: ReadonlySet<string>;
|
|
85
|
+
draftsState: "loading" | "ready" | "failed";
|
|
86
|
+
/** Draft keys and request ids decided in this visit. */
|
|
87
|
+
stay: ReadonlySet<string>;
|
|
88
|
+
referenceCount: number;
|
|
89
|
+
}): NotificationItems {
|
|
90
|
+
const sections = sectionReviews(input.requests);
|
|
91
|
+
const closedHere = input.requests.filter((request) => request.state !== "open" && input.stay.has(request.id));
|
|
92
|
+
const draftItems: DraftItem[] = [];
|
|
93
|
+
const requestLevel: ReviewRequest[] = [];
|
|
94
|
+
const draftRequests = [...sections.draftBatches, ...input.requests.filter((request) => request.state !== "open" && isDraftReview(request) && draftIdsOf(request).some((id) => input.stay.has(`${request.id}#${id}`)))];
|
|
95
|
+
for (const request of draftRequests) {
|
|
96
|
+
const available = draftIdsOf(request).filter((id) => !input.missingDrafts.has(id));
|
|
97
|
+
if (request.state === "open" && input.draftsState === "ready" && available.length === 0) {
|
|
98
|
+
requestLevel.push(request);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const draftId of available) {
|
|
102
|
+
const key = `${request.id}#${draftId}`;
|
|
103
|
+
const view = input.drafts[draftId];
|
|
104
|
+
const decision = draftDecisionOf(request.links.find((link) => link.id === draftId), view, NO_FALLBACK);
|
|
105
|
+
const waiting = request.state === "open" && !decision;
|
|
106
|
+
if (waiting || input.stay.has(key)) draftItems.push({ kind: "draft", key, request, draftId, view, decision });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const asItem = (request: ReviewRequest): RequestItem => ({ kind: "request", key: request.id, request });
|
|
110
|
+
const standing = (request: ReviewRequest) => request.state === "open" || input.stay.has(request.id);
|
|
111
|
+
const reviews = [
|
|
112
|
+
...sections.hardGates,
|
|
113
|
+
...sections.softReviews,
|
|
114
|
+
...requestLevel,
|
|
115
|
+
...closedHere.filter((request) => request.mode !== "flag" && !isDraftReview(request)),
|
|
116
|
+
].filter(standing).map(asItem);
|
|
117
|
+
const flags = [...sections.flags, ...closedHere.filter((request) => request.mode === "flag")].filter(standing).map(asItem);
|
|
118
|
+
return {
|
|
119
|
+
drafts: draftItems,
|
|
120
|
+
reviews: [...new Map(reviews.map((item) => [item.key, item])).values()],
|
|
121
|
+
flags: [...new Map(flags.map((item) => [item.key, item])).values()],
|
|
122
|
+
counts: {
|
|
123
|
+
drafts: draftItems.filter((item) => !item.decision).length,
|
|
124
|
+
references: input.referenceCount,
|
|
125
|
+
other: [...reviews, ...flags].filter((item) => item.request.state === "open").length,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
131
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
|
|
135
|
+
export function waitingText(counts: NotificationCounts): string {
|
|
136
|
+
const parts = [
|
|
137
|
+
counts.drafts ? plural(counts.drafts, "draft") : null,
|
|
138
|
+
counts.references ? plural(counts.references, "reference") : null,
|
|
139
|
+
counts.other ? plural(counts.other, "other item") : null,
|
|
140
|
+
].filter((part): part is string => part !== null);
|
|
141
|
+
if (!parts.length) return "All caught up";
|
|
142
|
+
const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
143
|
+
return `${list} waiting for you`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function filterCount(filter: NotificationFilter, counts: NotificationCounts): number {
|
|
147
|
+
return filter === "all" ? counts.drafts + counts.references + counts.other : counts[filter];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Which sections a filter shows. */
|
|
151
|
+
export function showsSection(filter: NotificationFilter, section: "drafts" | "references" | "other"): boolean {
|
|
152
|
+
return filter === "all" || filter === section;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- sources, pictures ----------------------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
function normalizedAddress(address: string): string {
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(address);
|
|
160
|
+
return `${url.hostname.replace(/^www\./i, "").toLowerCase()}${url.pathname.replace(/\/+$/, "")}${url.search}`;
|
|
161
|
+
} catch {
|
|
162
|
+
return address.trim().toLowerCase();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The previews of References by the address they were saved from, so a draft's cited source finds its picture. */
|
|
167
|
+
export function previewsBySourceAddress(references: ReferenceRecord[], previews: ReadonlyMap<string, ReferencePreview>): Map<string, ReferencePreview> {
|
|
168
|
+
const found = new Map<string, ReferencePreview>();
|
|
169
|
+
for (const reference of references) {
|
|
170
|
+
const preview = previews.get(referenceLineageId(reference));
|
|
171
|
+
if (preview && reference.sourceUri) found.set(normalizedAddress(reference.sourceUri), preview);
|
|
172
|
+
}
|
|
173
|
+
return found;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The first cited source that has a picture, with the site name its preview gives. */
|
|
177
|
+
export function draftPicture(view: DraftView | undefined, bySource: ReadonlyMap<string, ReferencePreview>): { imageUrl: string | null; siteName: string | null; host: string | null } {
|
|
178
|
+
const sources = view?.sources ?? [];
|
|
179
|
+
const host = sources.find((source) => source.url)?.url ? sourceHost(sources.find((source) => source.url)!.url!) : null;
|
|
180
|
+
for (const source of sources) {
|
|
181
|
+
const preview = source.url ? bySource.get(normalizedAddress(source.url)) : undefined;
|
|
182
|
+
if (preview?.imageUrl) return { imageUrl: preview.imageUrl, siteName: preview.siteName, host: source.url ? sourceHost(source.url) : host };
|
|
183
|
+
}
|
|
184
|
+
return { imageUrl: null, siteName: null, host };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function sourceHost(address: string): string | null {
|
|
188
|
+
try {
|
|
189
|
+
return new URL(address).hostname.replace(/^www\./i, "") || null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- what you decided ------------------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export type RecentDecision = {
|
|
198
|
+
key: string;
|
|
199
|
+
kind: "draft" | "reference" | "request";
|
|
200
|
+
word: "Accepted" | "Rejected" | "Acknowledged" | "Answered";
|
|
201
|
+
choice: "accept" | "reject" | "acknowledge" | "answer";
|
|
202
|
+
title: string;
|
|
203
|
+
at: string;
|
|
204
|
+
href: string | null;
|
|
205
|
+
/** The editor's why, when there is one. */
|
|
206
|
+
note: string | null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function requestDecision(request: ReviewRequest): Pick<RecentDecision, "word" | "choice"> | null {
|
|
210
|
+
const answer = request.answer;
|
|
211
|
+
if (request.state !== "answered" || !answer) return null;
|
|
212
|
+
if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return { word: "Acknowledged", choice: "acknowledge" };
|
|
213
|
+
if (answer.decision === "approve") return { word: "Accepted", choice: "accept" };
|
|
214
|
+
if (answer.decision === "reject") return { word: "Rejected", choice: "reject" };
|
|
215
|
+
return { word: "Answered", choice: "answer" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** What the editor decided most recently, newest first: drafts, other requests and References. "Last few", so a small default. */
|
|
219
|
+
export function recentDecisions(requests: ReviewRequest[], references: ReferenceRecord[], demo = false, limit = 5): RecentDecision[] {
|
|
220
|
+
const found = new Map<string, RecentDecision>();
|
|
221
|
+
for (const request of requests) {
|
|
222
|
+
for (const link of request.links) {
|
|
223
|
+
if (!link.decision || !link.id) continue;
|
|
224
|
+
found.set(`${request.id}#${link.id}`, {
|
|
225
|
+
key: `${request.id}#${link.id}`, kind: "draft", word: link.decision.choice === "accept" ? "Accepted" : "Rejected", choice: link.decision.choice,
|
|
226
|
+
title: link.decision.title ?? link.label ?? link.id, at: link.decision.at, href: proposalHref(link.id, demo), note: link.decision.why ?? null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
for (const entry of request.answer?.drafts ?? []) {
|
|
230
|
+
found.set(`${request.id}#${entry.id}`, {
|
|
231
|
+
key: `${request.id}#${entry.id}`, kind: "draft", word: entry.choice === "accept" ? "Accepted" : "Rejected", choice: entry.choice,
|
|
232
|
+
title: entry.title ?? entry.id, at: entry.at, href: proposalHref(entry.id, demo), note: entry.why ?? null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const whole = requestDecision(request);
|
|
236
|
+
if (whole && !request.answer?.drafts?.length) {
|
|
237
|
+
found.set(request.id, { key: request.id, kind: "request", ...whole, title: request.reason, at: request.answer!.at, href: null, note: request.answer!.feedback });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const reference of references) {
|
|
241
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
242
|
+
if ((status !== "accepted" && status !== "rejected") || !reference.curationStatusUpdatedAt) continue;
|
|
243
|
+
const lineageId = referenceLineageId(reference);
|
|
244
|
+
found.set(`reference#${lineageId}`, {
|
|
245
|
+
key: `reference#${lineageId}`, kind: "reference", word: status === "accepted" ? "Accepted" : "Rejected", choice: status === "accepted" ? "accept" : "reject",
|
|
246
|
+
title: referenceDisplayTitle(reference), at: reference.curationStatusUpdatedAt, href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo), note: null,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return [...found.values()].filter((entry) => entry.at).sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
|
|
250
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
import type { ReferenceRecord } from "./category-repository";
|
|
8
8
|
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
9
9
|
import type { ReferencePreview } from "./reference-preview";
|
|
10
|
+
import type { DraftView } from "./review-drafts";
|
|
11
|
+
import type { ReviewRequest } from "./review-requests";
|
|
10
12
|
|
|
11
13
|
const FEED_PREFIX = "papyrus.references.feed.v1.";
|
|
12
14
|
const SESSION_PREFIX = "papyrus.newsroom.session.v1.";
|
|
15
|
+
const NOTIFICATION_PREFIX = "papyrus.notifications.v1.";
|
|
13
16
|
/** A saved session summary is trusted for display only this long; the live check always still runs. */
|
|
14
17
|
export const SESSION_DISPLAY_TTL_MS = 15 * 60 * 1000;
|
|
15
18
|
|
|
@@ -126,8 +129,42 @@ export function saveSessionDisplay(siteId: string, display: Omit<SessionDisplay,
|
|
|
126
129
|
write("session", `${SESSION_PREFIX}${siteId}`, { ...display, savedAt: now });
|
|
127
130
|
}
|
|
128
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
|
+
|
|
129
165
|
/** Sign-out: nothing of the signed-in view survives in this browser. */
|
|
130
166
|
export function clearSavedNewsroomDisplay(siteId: string) {
|
|
131
167
|
remove("session", `${SESSION_PREFIX}${siteId}`);
|
|
132
168
|
clearFeedSnapshot(siteId);
|
|
169
|
+
clearNotificationSnapshot(siteId);
|
|
133
170
|
}
|
|
@@ -189,3 +189,47 @@ export function groupFeedItems(items: FeedItem[]): Array<{ key: FeedGroupKey; it
|
|
|
189
189
|
}
|
|
190
190
|
return (["top", "more", "least"] as const).filter((key) => groups[key].length).map((key) => ({ key, items: groups[key] }));
|
|
191
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The order of the feed, taken once per visit when it loads and then left alone: selecting, rating, a background refresh and a cache
|
|
195
|
+
* revalidation never re-rank it. Items that arrive later are counted as new and wait behind a "N new items: show" pill.
|
|
196
|
+
*/
|
|
197
|
+
export type FeedOrderSnapshot = {
|
|
198
|
+
/** Every Reference lineage the snapshot knew about (pending, rated and earlier): anything else is new. */
|
|
199
|
+
known: ReadonlySet<string>;
|
|
200
|
+
feed: string[];
|
|
201
|
+
earlier: string[];
|
|
202
|
+
groups: Array<{ key: FeedGroupKey; ids: string[] }>;
|
|
203
|
+
leadId: string | undefined;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export function takeFeedOrderSnapshot(partition: ReferenceFeedPartition): FeedOrderSnapshot {
|
|
207
|
+
const groups = groupFeedItems(partition.feed);
|
|
208
|
+
return {
|
|
209
|
+
known: new Set([...partition.feed, ...partition.earlier, ...partition.rated].map((item) => item.lineageId)),
|
|
210
|
+
feed: partition.feed.map((item) => item.lineageId),
|
|
211
|
+
earlier: partition.earlier.map((item) => item.lineageId),
|
|
212
|
+
groups: groups.map((group) => ({ key: group.key, ids: group.items.map((item) => item.lineageId) })),
|
|
213
|
+
leadId: groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The live partition with its feed order, sections and lead taken from the snapshot; records and scores stay current. */
|
|
218
|
+
export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live: ReferenceFeedPartition): {
|
|
219
|
+
partition: ReferenceFeedPartition;
|
|
220
|
+
groups: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
|
|
221
|
+
leadId: string | undefined;
|
|
222
|
+
newCount: number;
|
|
223
|
+
} {
|
|
224
|
+
const byId = new Map<string, FeedItem>();
|
|
225
|
+
for (const item of [...live.rated, ...live.earlier, ...live.feed]) byId.set(item.lineageId, item);
|
|
226
|
+
const pick = (ids: string[]) => ids.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
|
|
227
|
+
const groups = snapshot.groups.map((group) => ({ key: group.key, items: pick(group.ids) })).filter((group) => group.items.length);
|
|
228
|
+
const newCount = [...live.feed, ...live.earlier].filter((item) => !snapshot.known.has(item.lineageId)).length;
|
|
229
|
+
return {
|
|
230
|
+
partition: { ...live, feed: pick(snapshot.feed), earlier: pick(snapshot.earlier) },
|
|
231
|
+
groups,
|
|
232
|
+
leadId: snapshot.leadId && byId.has(snapshot.leadId) ? snapshot.leadId : undefined,
|
|
233
|
+
newCount,
|
|
234
|
+
};
|
|
235
|
+
}
|