@anthusai/papyrus 2.0.0-next.77 → 2.0.0-next.78
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/components/news-desk-taxonomy-client.ts +24 -1
- package/components/newsroom-references-view.tsx +179 -36
- package/components/reference-cyclotron-suggestion.tsx +9 -5
- package/components/reference-relevance-review.tsx +3 -3
- package/components/topic-steering-workspace.tsx +143 -121
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/use-reference-review-data.ts +44 -0
- package/package.json +4 -1
- package/routes.manifest.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
|
|
3
5
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
4
6
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
7
|
import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
|
|
@@ -1852,9 +1854,29 @@ export async function loadEditorSemanticRelationsData(): Promise<SemanticRelatio
|
|
|
1852
1854
|
pageCount += 1;
|
|
1853
1855
|
} while (nextToken && pageCount < 40);
|
|
1854
1856
|
|
|
1857
|
+
if (nextToken) throw new Error("The activity feed is incomplete. Select a reference to load its prediction directly.");
|
|
1855
1858
|
return rows;
|
|
1856
1859
|
}
|
|
1857
1860
|
|
|
1861
|
+
/** Selected-reference details use indexes, not the bounded global activity feed. */
|
|
1862
|
+
export async function loadEditorReferenceReviewDetails(lineageId: string) {
|
|
1863
|
+
configureAmplifyClient();
|
|
1864
|
+
const testMock = getTestEditorNewsroomMock();
|
|
1865
|
+
return loadReferenceReviewDetails(lineageId, {
|
|
1866
|
+
relations: async (direction) => {
|
|
1867
|
+
if (testMock?.semanticRelations) return testMock.semanticRelations;
|
|
1868
|
+
return loadSemanticRelationsByState({
|
|
1869
|
+
field: direction === "outgoing" ? "listSemanticRelationsBySubjectState" : "listSemanticRelationsByObjectState",
|
|
1870
|
+
keyName: direction === "outgoing" ? "subjectStateKey" : "objectStateKey",
|
|
1871
|
+
keyValue: `reference#${lineageId}#current`,
|
|
1872
|
+
query: direction === "outgoing" ? LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY : LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
|
|
1873
|
+
});
|
|
1874
|
+
},
|
|
1875
|
+
message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
|
|
1876
|
+
payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1858
1880
|
export async function loadNewsroomSemanticRelationPage(options: NewsroomSemanticRelationPageOptions = {}): Promise<NewsroomRecordPage<SemanticRelationRecord>> {
|
|
1859
1881
|
return loadNewsroomFeedPage<SemanticRelationRecord>({
|
|
1860
1882
|
query: NEWSROOM_SEMANTIC_RELATION_FEED_QUERY,
|
|
@@ -3134,7 +3156,7 @@ async function loadSemanticRelationsByState({
|
|
|
3134
3156
|
});
|
|
3135
3157
|
assertNoGraphQLErrors(response.errors);
|
|
3136
3158
|
const connection = response.data?.[field];
|
|
3137
|
-
if (!connection)
|
|
3159
|
+
if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
|
|
3138
3160
|
for (const item of (connection.items ?? []).filter(Boolean) as SemanticRelationRecord[]) {
|
|
3139
3161
|
if (!item.id || seenIds.has(item.id)) continue;
|
|
3140
3162
|
seenIds.add(item.id);
|
|
@@ -3142,6 +3164,7 @@ async function loadSemanticRelationsByState({
|
|
|
3142
3164
|
}
|
|
3143
3165
|
nextToken = connection.nextToken ?? null;
|
|
3144
3166
|
} while (nextToken && pageCount < 200);
|
|
3167
|
+
if (nextToken) throw new Error("Reference details are incomplete. Try again before reviewing its prediction.");
|
|
3145
3168
|
return rows;
|
|
3146
3169
|
}
|
|
3147
3170
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
ThumbsUpIcon,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import { usePathname } from "next/navigation";
|
|
16
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
17
|
import { Badge } from "./ui/badge";
|
|
18
18
|
import { Button } from "./ui/button";
|
|
19
19
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
29
29
|
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
30
30
|
import {
|
|
31
|
+
buildReferencesIndexQuery,
|
|
31
32
|
parseReferenceLineageIdFromNewsroomPathname,
|
|
32
33
|
syncBrowserNewsroomIndexUrl,
|
|
33
34
|
} from "../lib/newsroom-index-filters";
|
|
@@ -45,7 +46,7 @@ import {
|
|
|
45
46
|
type ReferenceCurationAction,
|
|
46
47
|
type ReferenceStatusFilter,
|
|
47
48
|
} from "../lib/newsroom-references";
|
|
48
|
-
import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
49
|
+
import { normalizeReferenceCurationStatus, referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
49
50
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
50
51
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
51
52
|
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
@@ -61,7 +62,14 @@ import { SectionPicker, SectionSuggestionBlock, type SectionChoice } from "./ref
|
|
|
61
62
|
import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-section-suggestions";
|
|
62
63
|
|
|
63
64
|
/** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
|
|
64
|
-
export type ReferenceReviewOptions = {
|
|
65
|
+
export type ReferenceReviewOptions = {
|
|
66
|
+
sectionId?: string | null;
|
|
67
|
+
note?: string;
|
|
68
|
+
reasonCode?: ReferenceRejectionReasonCode | null;
|
|
69
|
+
decisionRelationId?: string;
|
|
70
|
+
shareable?: boolean;
|
|
71
|
+
};
|
|
72
|
+
type PredictionState = "loading" | "ready" | "error";
|
|
65
73
|
import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
66
74
|
import type { PrimingFormInput } from "./reference-cyclotron-priming";
|
|
67
75
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
@@ -102,9 +110,14 @@ type NewsroomReferencesViewProps = {
|
|
|
102
110
|
sectionSuggestions?: Map<string, SectionSuggestion>;
|
|
103
111
|
/** How often the Section suggestions matched later editor decisions. */
|
|
104
112
|
sectionAgreement?: SiteSectionAgreement | null;
|
|
105
|
-
|
|
113
|
+
reviewReasons?: Map<string, ReferenceRejectionReasonCode>;
|
|
114
|
+
reviewSectionIds?: Map<string, string>;
|
|
115
|
+
predictionState?: PredictionState;
|
|
116
|
+
predictionError?: string | null;
|
|
117
|
+
onRetryPredictions?: () => void;
|
|
118
|
+
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => Promise<void> | void;
|
|
106
119
|
initialReferenceLineageId?: string | null;
|
|
107
|
-
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
|
|
120
|
+
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
|
|
108
121
|
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
109
122
|
references: ReferenceRecord[];
|
|
110
123
|
};
|
|
@@ -251,6 +264,9 @@ function ReferenceDetailPanel({
|
|
|
251
264
|
disabled,
|
|
252
265
|
onRelevanceReview,
|
|
253
266
|
onReview,
|
|
267
|
+
predictionState = "ready",
|
|
268
|
+
reviewReason,
|
|
269
|
+
reviewSectionId,
|
|
254
270
|
previewAttachments,
|
|
255
271
|
reference,
|
|
256
272
|
onClose,
|
|
@@ -263,8 +279,11 @@ function ReferenceDetailPanel({
|
|
|
263
279
|
decision?: RelevanceDecisionView;
|
|
264
280
|
demo?: boolean;
|
|
265
281
|
disabled?: boolean;
|
|
266
|
-
onRelevanceReview?: (curation: RelevanceCuration) => void;
|
|
267
|
-
onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
|
|
282
|
+
onRelevanceReview?: (curation: RelevanceCuration) => Promise<void> | void;
|
|
283
|
+
onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
|
|
284
|
+
predictionState?: PredictionState;
|
|
285
|
+
reviewReason?: ReferenceRejectionReasonCode;
|
|
286
|
+
reviewSectionId?: string;
|
|
268
287
|
sectionAgreement?: SiteSectionAgreement | null;
|
|
269
288
|
sectionSuggestion?: SectionSuggestion;
|
|
270
289
|
sections?: SectionChoice[];
|
|
@@ -273,13 +292,77 @@ function ReferenceDetailPanel({
|
|
|
273
292
|
onClose?: () => void;
|
|
274
293
|
}) {
|
|
275
294
|
const lineageId = referenceLineageId(reference);
|
|
276
|
-
const
|
|
277
|
-
|
|
295
|
+
const sourceNote = normalizeReferenceCurationStatus(reference.curationStatus) === "pending" ? "" : reference.curationStatusReason ?? "";
|
|
296
|
+
const sourceReason = reviewReason ?? referenceReasonCode(reference) ?? "";
|
|
297
|
+
const [note, setNote] = useState(sourceNote);
|
|
298
|
+
const [reasonCode, setReasonCode] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
|
|
299
|
+
const [savedNote, setSavedNote] = useState(sourceNote);
|
|
300
|
+
const [savedReason, setSavedReason] = useState<ReferenceRejectionReasonCode | "">(sourceReason);
|
|
301
|
+
const [busy, setBusy] = useState(false);
|
|
302
|
+
const [reviewError, setReviewError] = useState<string | null>(null);
|
|
303
|
+
const [saved, setSaved] = useState(false);
|
|
304
|
+
const saving = useRef(false);
|
|
305
|
+
const noteEdited = useRef(false);
|
|
306
|
+
const reasonEdited = useRef(false);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
setSavedNote(sourceNote);
|
|
309
|
+
if (!noteEdited.current) setNote(sourceNote);
|
|
310
|
+
}, [sourceNote]);
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
setSavedReason(sourceReason);
|
|
313
|
+
if (!reasonEdited.current) setReasonCode(sourceReason);
|
|
314
|
+
}, [sourceReason]);
|
|
315
|
+
const [chosenSectionId, setChosenSectionId] = useState<string | null>(reviewSectionId ?? null);
|
|
316
|
+
const [savedSectionId, setSavedSectionId] = useState<string | null>(reviewSectionId ?? null);
|
|
317
|
+
const sectionEdited = useRef(false);
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
setSavedSectionId(reviewSectionId ?? null);
|
|
320
|
+
if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
|
|
321
|
+
}, [reviewSectionId]);
|
|
278
322
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
279
323
|
const url = referenceUrl(reference);
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
const
|
|
324
|
+
const locked = disabled || busy;
|
|
325
|
+
const acceptDisabled = locked || status === "accepted";
|
|
326
|
+
const rejectDisabled = locked || status === "rejected";
|
|
327
|
+
const archiveDisabled = locked || status === "archived";
|
|
328
|
+
|
|
329
|
+
async function save(action: ReferenceCurationAction, review?: RelevanceCuration) {
|
|
330
|
+
if (disabled || saving.current) return;
|
|
331
|
+
const rejectionReason = review?.reasonCode ?? reasonCode;
|
|
332
|
+
if (action === "reject" && !rejectionReason) {
|
|
333
|
+
setReviewError("Choose a rejection reason before saving. An explanation is optional.");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
saving.current = true;
|
|
337
|
+
setBusy(true);
|
|
338
|
+
setSaved(false);
|
|
339
|
+
setReviewError(null);
|
|
340
|
+
try {
|
|
341
|
+
if (review && onRelevanceReview) await onRelevanceReview({ ...review, ...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}) });
|
|
342
|
+
else await onReview(action, {
|
|
343
|
+
note: note.trim(),
|
|
344
|
+
reasonCode: action === "reject" ? reasonCode || null : null,
|
|
345
|
+
...(action === "accept" && chosenSectionId ? { sectionId: chosenSectionId } : {}),
|
|
346
|
+
...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
|
|
347
|
+
});
|
|
348
|
+
if (review) {
|
|
349
|
+
setNote(review.note ?? "");
|
|
350
|
+
setReasonCode((review.reasonCode as ReferenceRejectionReasonCode) ?? "");
|
|
351
|
+
}
|
|
352
|
+
setSavedNote(review ? review.note ?? "" : note.trim());
|
|
353
|
+
setSavedReason(action === "reject" ? rejectionReason as ReferenceRejectionReasonCode : "");
|
|
354
|
+
setSavedSectionId(action === "accept" ? chosenSectionId : null);
|
|
355
|
+
noteEdited.current = false;
|
|
356
|
+
reasonEdited.current = false;
|
|
357
|
+
sectionEdited.current = false;
|
|
358
|
+
setSaved(true);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
setReviewError(error instanceof Error ? error.message : "Review could not be saved. Try again.");
|
|
361
|
+
} finally {
|
|
362
|
+
saving.current = false;
|
|
363
|
+
setBusy(false);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
283
366
|
|
|
284
367
|
return (
|
|
285
368
|
<div
|
|
@@ -304,8 +387,8 @@ function ReferenceDetailPanel({
|
|
|
304
387
|
<div className="space-y-3" data-reference-relevance-decision={lineageId}>
|
|
305
388
|
<ReferenceSuggestionBlock
|
|
306
389
|
decision={decision}
|
|
307
|
-
disabled={
|
|
308
|
-
onCurate={onRelevanceReview}
|
|
390
|
+
disabled={locked}
|
|
391
|
+
onCurate={onRelevanceReview ? (review) => { void save(review.action, { ...review, note: note.trim() || null, reasonCode: review.action === "reject" ? reasonCode || null : null }); } : undefined}
|
|
309
392
|
referenceLineageId={lineageId}
|
|
310
393
|
settled={status !== "pending"}
|
|
311
394
|
statusLabel={referenceStatusLabel(status)}
|
|
@@ -317,31 +400,54 @@ function ReferenceDetailPanel({
|
|
|
317
400
|
<div className="mt-2">
|
|
318
401
|
<ReferenceRelevanceReview
|
|
319
402
|
decision={decision}
|
|
320
|
-
disabled={
|
|
321
|
-
onCurate={
|
|
403
|
+
disabled={locked}
|
|
404
|
+
onCurate={(review) => save(review.action, review)}
|
|
322
405
|
referenceLineageId={lineageId}
|
|
323
406
|
/>
|
|
324
407
|
</div>
|
|
325
408
|
</details>
|
|
326
409
|
) : null}
|
|
327
410
|
</div>
|
|
411
|
+
) : predictionState === "ready" ? (
|
|
412
|
+
<section aria-label="Publication prediction" className="space-y-1 rounded-md border p-3">
|
|
413
|
+
<h3 className="text-sm font-semibold">No prediction recorded</h3>
|
|
414
|
+
<p className="text-sm text-muted-foreground">No publication relevance prediction was returned for this reference. You can still review it. Pending is its editorial status, not a prediction status.</p>
|
|
415
|
+
</section>
|
|
328
416
|
) : null}
|
|
329
|
-
{status === "pending" &&
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
417
|
+
{sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
|
|
418
|
+
<details className="rounded-md border p-3 text-sm">
|
|
419
|
+
<summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
|
|
420
|
+
<div className="mt-3 space-y-3">
|
|
421
|
+
{sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
|
|
422
|
+
{(status === "pending" || status === "accepted") && sections.length ? <SectionPicker
|
|
334
423
|
agreement={sectionAgreement}
|
|
335
424
|
chosenSectionId={chosenSectionId}
|
|
336
|
-
disabled={
|
|
337
|
-
onChoose={setChosenSectionId}
|
|
425
|
+
disabled={locked}
|
|
426
|
+
onChoose={(sectionId) => { setChosenSectionId(sectionId); sectionEdited.current = true; setSaved(false); }}
|
|
338
427
|
referenceLineageId={lineageId}
|
|
339
428
|
sections={sections}
|
|
340
429
|
suggestedSectionId={sectionSuggestion?.sectionId ?? null}
|
|
341
|
-
|
|
430
|
+
/> : null}
|
|
431
|
+
</div>
|
|
432
|
+
</details>
|
|
342
433
|
) : null}
|
|
343
434
|
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
344
435
|
|
|
436
|
+
<section aria-label="Editor feedback" className="space-y-3">
|
|
437
|
+
<h3 className="text-sm font-semibold">Editor feedback</h3>
|
|
438
|
+
{disabled ? <p className="text-sm text-muted-foreground">Editor access is required to change this reference.</p> : null}
|
|
439
|
+
<label className="block text-sm">
|
|
440
|
+
Review explanation (optional)
|
|
441
|
+
<textarea aria-label="Review explanation (optional)" className="mt-1 block min-h-20 w-full rounded-md border bg-background p-2" disabled={locked} value={note} onChange={(event) => { setNote(event.target.value); noteEdited.current = true; setSaved(false); }} />
|
|
442
|
+
</label>
|
|
443
|
+
<label className="block text-sm">
|
|
444
|
+
Rejection reason
|
|
445
|
+
<select aria-label="Rejection reason" className="mt-1 block w-full rounded-md border bg-background p-2" disabled={locked} value={reasonCode} onChange={(event) => { setReasonCode(event.target.value as ReferenceRejectionReasonCode | ""); reasonEdited.current = true; setSaved(false); }}>
|
|
446
|
+
<option value="">Choose a reason when rejecting</option>
|
|
447
|
+
{REFERENCE_REJECTION_REASON_CODES.map((code) => <option key={code} value={code}>{code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " ")}</option>)}
|
|
448
|
+
</select>
|
|
449
|
+
</label>
|
|
450
|
+
<p className="text-xs text-muted-foreground">Only out of scope and policy exclusion are negative relevance labels. Other rejection reasons do not teach relevance.</p>
|
|
345
451
|
<div
|
|
346
452
|
className="flex flex-wrap items-center gap-2"
|
|
347
453
|
data-news-desk-reference-curation-cluster
|
|
@@ -352,7 +458,7 @@ function ReferenceDetailPanel({
|
|
|
352
458
|
aria-pressed={status === "accepted"}
|
|
353
459
|
data-news-desk-reference-accept
|
|
354
460
|
disabled={acceptDisabled}
|
|
355
|
-
onClick={() =>
|
|
461
|
+
onClick={() => { void save("accept"); }}
|
|
356
462
|
size="sm"
|
|
357
463
|
type="button"
|
|
358
464
|
variant={status === "accepted" ? "default" : "outline"}
|
|
@@ -365,7 +471,7 @@ function ReferenceDetailPanel({
|
|
|
365
471
|
aria-pressed={status === "rejected"}
|
|
366
472
|
data-news-desk-reference-reject
|
|
367
473
|
disabled={rejectDisabled}
|
|
368
|
-
onClick={() =>
|
|
474
|
+
onClick={() => { void save("reject"); }}
|
|
369
475
|
size="sm"
|
|
370
476
|
type="button"
|
|
371
477
|
variant={status === "rejected" ? "destructive" : "outline"}
|
|
@@ -376,7 +482,7 @@ function ReferenceDetailPanel({
|
|
|
376
482
|
<Button
|
|
377
483
|
aria-label="Archive reference"
|
|
378
484
|
disabled={archiveDisabled}
|
|
379
|
-
onClick={() =>
|
|
485
|
+
onClick={() => { void save("archive"); }}
|
|
380
486
|
size="sm"
|
|
381
487
|
type="button"
|
|
382
488
|
variant="outline"
|
|
@@ -384,7 +490,14 @@ function ReferenceDetailPanel({
|
|
|
384
490
|
<ArchiveIcon className="size-4" />
|
|
385
491
|
Archive
|
|
386
492
|
</Button>
|
|
493
|
+
{status === "accepted" || status === "rejected" ? (
|
|
494
|
+
<Button disabled={locked || predictionState !== "ready" || (note.trim() === savedNote.trim() && (status !== "rejected" || reasonCode === savedReason) && (status !== "accepted" || chosenSectionId === savedSectionId))} onClick={() => { void save(status === "accepted" ? "accept" : "reject"); }} size="sm" type="button" variant="outline">Save feedback</Button>
|
|
495
|
+
) : null}
|
|
387
496
|
</div>
|
|
497
|
+
{status !== "pending" && predictionState !== "ready" ? <p className="text-sm text-muted-foreground">Load the review details before revising saved feedback.</p> : null}
|
|
498
|
+
{busy ? <p role="status" className="text-sm">Saving review…</p> : saved ? <p role="status" className="text-sm">Review saved.</p> : null}
|
|
499
|
+
{reviewError ? <p role="alert" className="text-sm text-destructive">{reviewError}</p> : null}
|
|
500
|
+
</section>
|
|
388
501
|
|
|
389
502
|
<Separator />
|
|
390
503
|
|
|
@@ -398,7 +511,7 @@ function ReferenceDetailPanel({
|
|
|
398
511
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Why</dt>
|
|
399
512
|
<dd className="mt-1 text-foreground">
|
|
400
513
|
{reference.curationStatusReason?.trim()
|
|
401
|
-
|| "
|
|
514
|
+
|| "No explanation recorded."}
|
|
402
515
|
</dd>
|
|
403
516
|
</div>
|
|
404
517
|
{url ? (
|
|
@@ -476,6 +589,11 @@ export function NewsroomReferencesView({
|
|
|
476
589
|
demo = false,
|
|
477
590
|
disabled = false,
|
|
478
591
|
onRelevanceReview,
|
|
592
|
+
predictionState = "ready",
|
|
593
|
+
predictionError,
|
|
594
|
+
reviewReasons,
|
|
595
|
+
reviewSectionIds,
|
|
596
|
+
onRetryPredictions,
|
|
479
597
|
sections = [],
|
|
480
598
|
sectionSuggestions,
|
|
481
599
|
sectionAgreement = null,
|
|
@@ -492,7 +610,7 @@ export function NewsroomReferencesView({
|
|
|
492
610
|
() => parseReferenceLineageIdFromNewsroomPathname(pathname),
|
|
493
611
|
[pathname],
|
|
494
612
|
);
|
|
495
|
-
const routeLineageId =
|
|
613
|
+
const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
|
|
496
614
|
|
|
497
615
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
498
616
|
const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
@@ -531,17 +649,21 @@ export function NewsroomReferencesView({
|
|
|
531
649
|
|
|
532
650
|
const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
|
|
533
651
|
if (demo) return;
|
|
534
|
-
|
|
652
|
+
const filters = {
|
|
535
653
|
status: nextStatus === "all" ? "" : nextStatus,
|
|
536
654
|
processing: "",
|
|
537
|
-
order: "published",
|
|
538
|
-
}
|
|
539
|
-
|
|
655
|
+
order: "published" as const,
|
|
656
|
+
};
|
|
657
|
+
if (selectedLineageId && typeof window !== "undefined") {
|
|
658
|
+
window.history.replaceState(null, "", `${buildReferenceDetailPath(selectedLineageId)}${buildReferencesIndexQuery(filters)}`);
|
|
659
|
+
} else {
|
|
660
|
+
syncBrowserNewsroomIndexUrl("references", filters, { replace: true });
|
|
661
|
+
}
|
|
662
|
+
}, [demo, selectedLineageId]);
|
|
540
663
|
|
|
541
664
|
useEffect(() => {
|
|
542
|
-
if (!routeLineageId) return;
|
|
543
665
|
setSelectedLineageId(routeLineageId);
|
|
544
|
-
if (isMobileDetail) setMobileDetailOpen(
|
|
666
|
+
if (isMobileDetail) setMobileDetailOpen(Boolean(routeLineageId));
|
|
545
667
|
}, [isMobileDetail, routeLineageId]);
|
|
546
668
|
|
|
547
669
|
useEffect(() => {
|
|
@@ -571,7 +693,7 @@ export function NewsroomReferencesView({
|
|
|
571
693
|
|
|
572
694
|
const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
|
|
573
695
|
if (!selectedReference) return;
|
|
574
|
-
onReview(selectedReference, action, options);
|
|
696
|
+
return onReview(selectedReference, action, options);
|
|
575
697
|
};
|
|
576
698
|
|
|
577
699
|
const emptyLabel = canonicalReferences.length
|
|
@@ -591,6 +713,17 @@ export function NewsroomReferencesView({
|
|
|
591
713
|
Review source intake before it becomes accepted evidence.
|
|
592
714
|
</p>
|
|
593
715
|
</div>
|
|
716
|
+
{predictionState === "loading" ? <p role="status">Loading predictions…</p> : null}
|
|
717
|
+
{predictionState === "error" ? (
|
|
718
|
+
<div role="alert" className="space-y-2 rounded-md border p-3">
|
|
719
|
+
<p>Predictions unavailable. {predictionError}</p>
|
|
720
|
+
<p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
|
|
721
|
+
{onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
|
|
722
|
+
</div>
|
|
723
|
+
) : null}
|
|
724
|
+
<details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
|
|
725
|
+
<summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
|
|
726
|
+
<div className="mt-3 space-y-4">
|
|
594
727
|
<ReferenceLiveAgreement agreement={shadowAgreement} />
|
|
595
728
|
<ReferenceAddEntry
|
|
596
729
|
importOptions={disabled ? null : priming ?? sessionImport?.panel}
|
|
@@ -606,6 +739,8 @@ export function NewsroomReferencesView({
|
|
|
606
739
|
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
607
740
|
</div>
|
|
608
741
|
) : null}
|
|
742
|
+
</div>
|
|
743
|
+
</details>
|
|
609
744
|
|
|
610
745
|
<Tabs
|
|
611
746
|
className="shrink-0"
|
|
@@ -717,6 +852,10 @@ export function NewsroomReferencesView({
|
|
|
717
852
|
<ScrollArea className="min-h-0 flex-1">
|
|
718
853
|
{selectedReference ? (
|
|
719
854
|
<ReferenceDetailPanel
|
|
855
|
+
key={referenceLineageId(selectedReference)}
|
|
856
|
+
predictionState={predictionState}
|
|
857
|
+
reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
|
|
858
|
+
reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
|
|
720
859
|
countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
|
|
721
860
|
delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
|
|
722
861
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
@@ -753,6 +892,10 @@ export function NewsroomReferencesView({
|
|
|
753
892
|
{selectedReference ? (
|
|
754
893
|
<ScrollArea className="max-h-[75dvh]">
|
|
755
894
|
<ReferenceDetailPanel
|
|
895
|
+
key={referenceLineageId(selectedReference)}
|
|
896
|
+
predictionState={predictionState}
|
|
897
|
+
reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
|
|
898
|
+
reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
|
|
756
899
|
countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
|
|
757
900
|
delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
|
|
758
901
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
@@ -57,11 +57,12 @@ export function ReferenceSuggestionBlock({
|
|
|
57
57
|
const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
|
|
58
58
|
const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
|
|
59
59
|
return (
|
|
60
|
-
<section aria-label="
|
|
61
|
-
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">
|
|
60
|
+
<section aria-label="Publication prediction" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
|
|
61
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Publication prediction</h3>
|
|
62
62
|
<p className="text-sm text-foreground">
|
|
63
63
|
Papyrus suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
|
|
64
64
|
</p>
|
|
65
|
+
{decision.reviewDetail ? <p className="text-xs text-foreground">Why review was requested: {decision.reviewDetail}</p> : null}
|
|
65
66
|
<p className="text-xs text-foreground" data-reference-suggestion-notice>
|
|
66
67
|
A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
|
|
67
68
|
</p>
|
|
@@ -99,14 +100,17 @@ export function ReferenceSuggestionProvenance({ decision }: { decision: Relevanc
|
|
|
99
100
|
<summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
|
|
100
101
|
{summary ? (
|
|
101
102
|
<ul className="mt-2 space-y-1">
|
|
102
|
-
<li>{summary.teachingLabelsSentence}.</li>
|
|
103
|
-
<li>{summary.primingExamplesSentence}.</li>
|
|
103
|
+
{summary.teachingLabelsSentence ? <li>{summary.teachingLabelsSentence}.</li> : null}
|
|
104
|
+
{summary.primingExamplesSentence ? <li>{summary.primingExamplesSentence}.</li> : null}
|
|
105
|
+
{decision.provenance?.system ? <li>Source: {decision.provenance.system === "cyclotron-lab" ? "Shared publication classifier" : decision.provenance.system}.</li> : null}
|
|
106
|
+
{decision.provenance?.output ? <li>Publication output: {decision.provenance.output}.</li> : null}
|
|
107
|
+
{decision.classifierId ? <li>Classifier: {decision.classifierId}.</li> : null}
|
|
104
108
|
<li>Guidelines code {summary.doctrineShortHash ?? "not recorded"}.</li>
|
|
105
109
|
<li>Suggestion {summary.modelVersion ?? "revision not recorded"}.</li>
|
|
106
110
|
<li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
|
|
107
111
|
</ul>
|
|
108
112
|
) : (
|
|
109
|
-
<p className="mt-2">
|
|
113
|
+
<p className="mt-2">Additional provenance was not recorded for this prediction.</p>
|
|
110
114
|
)}
|
|
111
115
|
</details>
|
|
112
116
|
);
|
|
@@ -28,7 +28,7 @@ export function ReferenceRelevanceReview({
|
|
|
28
28
|
}: {
|
|
29
29
|
disabled?: boolean;
|
|
30
30
|
decision: RelevanceDecisionView;
|
|
31
|
-
onCurate: (curation: RelevanceCuration) => void;
|
|
31
|
+
onCurate: (curation: RelevanceCuration) => Promise<void> | void;
|
|
32
32
|
referenceLineageId: string;
|
|
33
33
|
}) {
|
|
34
34
|
const [shareable, setShareable] = useState(false);
|
|
@@ -47,10 +47,10 @@ export function ReferenceRelevanceReview({
|
|
|
47
47
|
disabled={disabled}
|
|
48
48
|
itemId={referenceLineageId}
|
|
49
49
|
mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
|
|
50
|
-
onReview={(review) => {
|
|
50
|
+
onReview={async (review) => {
|
|
51
51
|
try {
|
|
52
52
|
setError(null);
|
|
53
|
-
onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
|
|
53
|
+
await onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
|
|
54
54
|
} catch (failure) {
|
|
55
55
|
setError(failure instanceof Error ? failure.message : String(failure));
|
|
56
56
|
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
loadEditorReferencesData,
|
|
41
41
|
loadEditorProcedureData,
|
|
42
42
|
loadEditorSemanticRelationsData,
|
|
43
|
+
loadEditorReferenceReviewDetails,
|
|
43
44
|
loadEditionForumThreads,
|
|
44
45
|
loadNewsroomAssignmentPage,
|
|
45
46
|
loadNewsroomMessagePage,
|
|
@@ -167,6 +168,9 @@ import {
|
|
|
167
168
|
} from "../lib/reference-policy";
|
|
168
169
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
169
170
|
import { currentRelevanceDecisions } from "../lib/relevance-decisions";
|
|
171
|
+
import { runReferenceReviewRequest } from "../lib/reference-review-request";
|
|
172
|
+
import { useReferenceReviewData } from "../lib/use-reference-review-data";
|
|
173
|
+
import { latestReferenceReviewReasons, latestReferenceReviewSections } from "../lib/reference-review-details";
|
|
170
174
|
import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
171
175
|
import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
|
|
172
176
|
import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
|
|
@@ -902,6 +906,11 @@ function NewsDeskDashboard({
|
|
|
902
906
|
const [referencesRealtimeStatus, setReferencesRealtimeStatus] = useState<RealtimeSubscriptionStatus>("idle");
|
|
903
907
|
const [referencesRealtimeError, setReferencesRealtimeError] = useState<string | null>(null);
|
|
904
908
|
const referencesRef = useRef(references);
|
|
909
|
+
const referenceReviewsPending = useRef(new Map<string, Promise<void>>());
|
|
910
|
+
const [selectedReviewRead, setSelectedReviewRead] = useState<{ lineageId: string; state: "loading" | "ready" | "error"; error: string | null }>({ lineageId: "", state: "ready", error: null });
|
|
911
|
+
const [selectedReviewAttempt, setSelectedReviewAttempt] = useState(0);
|
|
912
|
+
const reviewReasons = useMemo(() => latestReferenceReviewReasons(messages), [messages]);
|
|
913
|
+
const reviewSectionIds = useMemo(() => latestReferenceReviewSections(messages), [messages]);
|
|
905
914
|
|
|
906
915
|
useEffect(() => {
|
|
907
916
|
referencesRef.current = references;
|
|
@@ -1231,17 +1240,6 @@ function NewsDeskDashboard({
|
|
|
1231
1240
|
if (dashboard.isDemo) return;
|
|
1232
1241
|
let active = true;
|
|
1233
1242
|
|
|
1234
|
-
if (activeTab === "messages" && !loadedSections.messages) {
|
|
1235
|
-
setLoadedSections((current) => ({ ...current, messages: true }));
|
|
1236
|
-
void loadEditorMessagesData()
|
|
1237
|
-
.then((nextMessages) => {
|
|
1238
|
-
if (active) setMessages(nextMessages);
|
|
1239
|
-
})
|
|
1240
|
-
.catch((error) => {
|
|
1241
|
-
if (active) setActionState({ id: "messages-load", message: error instanceof Error ? error.message : "messages load failed", tone: "error" });
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1243
|
if (activeTab === "assignments" && !loadedSections.assignments) {
|
|
1246
1244
|
setLoadedSections((current) => ({ ...current, assignments: true }));
|
|
1247
1245
|
void loadEditorAssignmentsData()
|
|
@@ -1269,23 +1267,6 @@ function NewsDeskDashboard({
|
|
|
1269
1267
|
});
|
|
1270
1268
|
}
|
|
1271
1269
|
|
|
1272
|
-
if ((activeTab === "assignments" || activeTab === "references" || activeTab === "messages") && !loadedSections.semanticRelations) {
|
|
1273
|
-
setLoadedSections((current) => ({ ...current, semanticRelations: true }));
|
|
1274
|
-
void loadEditorSemanticRelationsData()
|
|
1275
|
-
.then((relations) => {
|
|
1276
|
-
if (active) setSemanticRelations(relations);
|
|
1277
|
-
})
|
|
1278
|
-
.catch(() => {
|
|
1279
|
-
if (active) setSemanticRelations([]);
|
|
1280
|
-
});
|
|
1281
|
-
if (!loadedSections.messages) {
|
|
1282
|
-
setLoadedSections((current) => ({ ...current, messages: true }));
|
|
1283
|
-
void loadEditorMessagesData().then((nextMessages) => {
|
|
1284
|
-
if (active) setMessages(nextMessages);
|
|
1285
|
-
}).catch(() => undefined);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
1270
|
if ((activeTab === "topics" || activeTab === "desks" || activeTab === "concepts" || activeTab === "administration") && !loadedSections.fullDashboard) {
|
|
1290
1271
|
setLoadedSections((current) => ({ ...current, fullDashboard: true }));
|
|
1291
1272
|
void loadEditorFullNewsDeskDashboard({ isAdmin: Boolean(dashboard.canManageUsers) })
|
|
@@ -1340,6 +1321,37 @@ function NewsDeskDashboard({
|
|
|
1340
1321
|
refreshNewsroomSections,
|
|
1341
1322
|
]);
|
|
1342
1323
|
|
|
1324
|
+
const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1325
|
+
const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
|
|
1326
|
+
const referenceReviewData = useReferenceReviewData({
|
|
1327
|
+
enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
|
|
1328
|
+
loadRelations: loadEditorSemanticRelationsData,
|
|
1329
|
+
loadMessages: loadEditorMessagesData,
|
|
1330
|
+
onRelations: setSemanticRelations,
|
|
1331
|
+
onMessages: setMessages,
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
useEffect(() => {
|
|
1335
|
+
if (!canLoadPrivateCorpus || activeTab !== "references" || !selectedReviewLineageId) return;
|
|
1336
|
+
let active = true;
|
|
1337
|
+
setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "loading", error: null });
|
|
1338
|
+
void loadEditorReferenceReviewDetails(selectedReviewLineageId).then(({ relations, messages: history }) => {
|
|
1339
|
+
if (!active) return;
|
|
1340
|
+
setSemanticRelations((current) => [...current.filter((relation) => relation.subjectLineageId !== selectedReviewLineageId || relation.subjectKind !== "reference"), ...relations]);
|
|
1341
|
+
setMessages((current) => [...current.filter((message) => !history.some((entry) => entry.id === message.id)), ...history]);
|
|
1342
|
+
setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "ready", error: null });
|
|
1343
|
+
}).catch((error) => {
|
|
1344
|
+
if (active) setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "error", error: error instanceof Error ? error.message : "Reference review details could not be loaded." });
|
|
1345
|
+
});
|
|
1346
|
+
return () => { active = false; };
|
|
1347
|
+
}, [activeTab, canLoadPrivateCorpus, selectedReviewLineageId, selectedReviewAttempt]);
|
|
1348
|
+
const selectedReviewIsCurrent = selectedReviewLineageId && !dashboard.isDemo;
|
|
1349
|
+
const predictionState = selectedReviewIsCurrent
|
|
1350
|
+
? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
|
|
1351
|
+
: referenceReviewData.state;
|
|
1352
|
+
const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
|
|
1353
|
+
const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
|
|
1354
|
+
|
|
1343
1355
|
const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
|
|
1344
1356
|
const { nextRecords, nextRecord, previousRecord } = upsertReferenceRecords(referencesRef.current, nextReference);
|
|
1345
1357
|
referencesRef.current = nextRecords;
|
|
@@ -1594,96 +1606,98 @@ function NewsDeskDashboard({
|
|
|
1594
1606
|
reasonCode?: ReferenceRejectionReasonCode | null,
|
|
1595
1607
|
relevance?: { decisionRelationId: string; shareable: boolean },
|
|
1596
1608
|
sectionId?: string | null,
|
|
1597
|
-
) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
note: note?.trim() || undefined,
|
|
1666
|
-
reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
|
|
1667
|
-
decisionRelationId: relevance?.decisionRelationId,
|
|
1668
|
-
shareable: relevance ? relevance.shareable : undefined,
|
|
1669
|
-
sectionId: action === "accept" ? sectionId ?? undefined : undefined,
|
|
1670
|
-
},
|
|
1671
|
-
{ authMode: USER_POOL_AUTH_MODE },
|
|
1672
|
-
);
|
|
1673
|
-
const review = assertReferenceReviewMutationSucceeded(response, reference.id);
|
|
1674
|
-
const status = review.status ?? nextStatus;
|
|
1675
|
-
const now = new Date().toISOString();
|
|
1676
|
-
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1609
|
+
): Promise<void> {
|
|
1610
|
+
return runReferenceReviewRequest(referenceReviewsPending.current, reference.lineageId ?? reference.id, async () => {
|
|
1611
|
+
if (!canEdit) throw new Error("Your session does not have permission to review references.");
|
|
1612
|
+
const nextStatus = referenceCurationStatusForAction(action);
|
|
1613
|
+
setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
|
|
1614
|
+
if (dashboard.isDemo) {
|
|
1615
|
+
const now = new Date().toISOString();
|
|
1616
|
+
const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
|
|
1617
|
+
const relationId = `semantic-relation-demo-${messageId}`;
|
|
1618
|
+
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1619
|
+
actorLabel: authState.label,
|
|
1620
|
+
nextStatus,
|
|
1621
|
+
note: note ?? null,
|
|
1622
|
+
now,
|
|
1623
|
+
}));
|
|
1624
|
+
setMessages((current) => [{
|
|
1625
|
+
id: messageId,
|
|
1626
|
+
messageKind: "reference_curation",
|
|
1627
|
+
messageDomain: "commentary",
|
|
1628
|
+
status: "active",
|
|
1629
|
+
body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
|
|
1630
|
+
summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
|
|
1631
|
+
source: "newsroom",
|
|
1632
|
+
authorLabel: authState.label,
|
|
1633
|
+
createdAt: now,
|
|
1634
|
+
updatedAt: now,
|
|
1635
|
+
metadata: {
|
|
1636
|
+
referenceLineageId: reference.lineageId ?? reference.id,
|
|
1637
|
+
action,
|
|
1638
|
+
curationStatus: nextStatus,
|
|
1639
|
+
reasonCode: action === "reject" ? reasonCode ?? null : null,
|
|
1640
|
+
...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
|
|
1641
|
+
...(action === "accept" && sectionId ? { sectionId } : {}),
|
|
1642
|
+
},
|
|
1643
|
+
}, ...current]);
|
|
1644
|
+
setSemanticRelations((current) => [{
|
|
1645
|
+
id: relationId,
|
|
1646
|
+
relationState: "current",
|
|
1647
|
+
predicate: "comment",
|
|
1648
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1649
|
+
relationTypeKey: "comment",
|
|
1650
|
+
relationDomain: "commentary",
|
|
1651
|
+
subjectKind: "message",
|
|
1652
|
+
subjectId: messageId,
|
|
1653
|
+
subjectLineageId: messageId,
|
|
1654
|
+
subjectVersionNumber: 1,
|
|
1655
|
+
objectKind: "reference",
|
|
1656
|
+
objectId: reference.id,
|
|
1657
|
+
objectLineageId: reference.lineageId ?? reference.id,
|
|
1658
|
+
objectVersionNumber: reference.versionNumber ?? null,
|
|
1659
|
+
subjectStateKey: `message#${messageId}#current`,
|
|
1660
|
+
objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
|
|
1661
|
+
objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
|
|
1662
|
+
predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
|
|
1663
|
+
subjectVersionKey: `message#${messageId}`,
|
|
1664
|
+
objectVersionKey: `reference#${reference.id}`,
|
|
1665
|
+
rank: 1,
|
|
1666
|
+
reviewRecommended: false,
|
|
1667
|
+
importedAt: now,
|
|
1668
|
+
}, ...current]);
|
|
1669
|
+
setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
try {
|
|
1673
|
+
const response = await dataClient.mutations.reviewReferenceCuration(
|
|
1674
|
+
{
|
|
1675
|
+
referenceId: reference.id,
|
|
1676
|
+
action,
|
|
1677
1677
|
actorLabel: authState.label,
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1678
|
+
note: note?.trim() || undefined,
|
|
1679
|
+
reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
|
|
1680
|
+
decisionRelationId: relevance?.decisionRelationId,
|
|
1681
|
+
shareable: relevance ? relevance.shareable : undefined,
|
|
1682
|
+
sectionId: action === "accept" ? sectionId ?? undefined : undefined,
|
|
1683
|
+
},
|
|
1684
|
+
{ authMode: USER_POOL_AUTH_MODE },
|
|
1685
|
+
);
|
|
1686
|
+
const review = assertReferenceReviewMutationSucceeded(response, reference.id);
|
|
1687
|
+
const status = review.status ?? nextStatus;
|
|
1688
|
+
const now = new Date().toISOString();
|
|
1689
|
+
applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
|
|
1690
|
+
actorLabel: authState.label,
|
|
1691
|
+
nextStatus: status,
|
|
1692
|
+
note: note?.trim() || null,
|
|
1693
|
+
now,
|
|
1694
|
+
}));
|
|
1695
|
+
setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
|
|
1696
|
+
retryReferenceReviewData();
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
|
|
1699
|
+
throw error;
|
|
1700
|
+
}
|
|
1687
1701
|
});
|
|
1688
1702
|
}
|
|
1689
1703
|
|
|
@@ -3127,7 +3141,7 @@ function NewsDeskDashboard({
|
|
|
3127
3141
|
<NewsroomReferencesView
|
|
3128
3142
|
demo={Boolean(dashboard.isDemo)}
|
|
3129
3143
|
disabled={controlsDisabled}
|
|
3130
|
-
initialReferenceLineageId={
|
|
3144
|
+
initialReferenceLineageId={selectedReviewLineageId}
|
|
3131
3145
|
cyclotronStatus={cyclotronStatus}
|
|
3132
3146
|
cyclotronPriming={{
|
|
3133
3147
|
labels: primingLabels,
|
|
@@ -3139,8 +3153,8 @@ function NewsDeskDashboard({
|
|
|
3139
3153
|
},
|
|
3140
3154
|
}}
|
|
3141
3155
|
decisions={relevanceDecisions}
|
|
3142
|
-
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3143
|
-
delegatedCurations={delegatedCurations}
|
|
3156
|
+
humanCuratedReferenceLineageIds={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : humanCuratedReferenceLineageIds}
|
|
3157
|
+
delegatedCurations={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : delegatedCurations}
|
|
3144
3158
|
shadowAgreement={shadowAgreement}
|
|
3145
3159
|
onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
|
|
3146
3160
|
const request = buildReviewRateRequest(choice, authState.label);
|
|
@@ -3153,11 +3167,19 @@ function NewsDeskDashboard({
|
|
|
3153
3167
|
curation.note ?? undefined,
|
|
3154
3168
|
normalizeReferenceRejectionReasonCode(curation.reasonCode),
|
|
3155
3169
|
{ decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
|
|
3170
|
+
curation.sectionId,
|
|
3156
3171
|
)}
|
|
3157
3172
|
sections={sectionChoices}
|
|
3158
3173
|
sectionSuggestions={sectionSuggestions}
|
|
3159
3174
|
sectionAgreement={siteSectionAgreement}
|
|
3160
|
-
|
|
3175
|
+
predictionState={predictionState}
|
|
3176
|
+
predictionError={predictionError}
|
|
3177
|
+
onRetryPredictions={retryReferenceReviewData}
|
|
3178
|
+
reviewReasons={reviewReasons}
|
|
3179
|
+
reviewSectionIds={reviewSectionIds}
|
|
3180
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
|
|
3181
|
+
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined,
|
|
3182
|
+
options?.sectionId)}
|
|
3161
3183
|
referenceAttachments={referenceAttachments}
|
|
3162
3184
|
references={references}
|
|
3163
3185
|
/>
|
|
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export type ProvenanceSummary = {
|
|
190
|
-
teachingLabelsSentence: string;
|
|
191
|
-
primingExamplesSentence: string;
|
|
190
|
+
teachingLabelsSentence: string | null;
|
|
191
|
+
primingExamplesSentence: string | null;
|
|
192
192
|
doctrineShortHash: string | null;
|
|
193
193
|
modelVersion: string | null;
|
|
194
194
|
decidedAt: string | null;
|
|
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
|
|
|
202
202
|
if (!provenance) return null;
|
|
203
203
|
const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
204
204
|
return {
|
|
205
|
-
teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
-
primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
205
|
+
teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
+
primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
207
207
|
doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
|
|
208
208
|
modelVersion: decision.version === null ? null : `revision ${decision.version}`,
|
|
209
209
|
decidedAt: decision.decidedAt,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
|
|
3
|
+
|
|
4
|
+
function metadataRecord(value: unknown): Record<string, unknown> {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
|
|
7
|
+
}
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The selected Reference's index is authoritative even when the global feed is truncated. */
|
|
12
|
+
export async function loadReferenceReviewDetails(lineageId: string, read: {
|
|
13
|
+
relations: (direction: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
|
|
14
|
+
message: (id: string) => Promise<MessageRecord | null>;
|
|
15
|
+
payloads: (id: string) => Promise<HydratedModelPayload[]>;
|
|
16
|
+
}): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
|
|
17
|
+
const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
|
|
18
|
+
const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
|
|
19
|
+
const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
|
|
20
|
+
&& relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
|
|
21
|
+
&& metadataRecord(relation.metadata).messageKind === "reference_curation")
|
|
22
|
+
.sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
|
|
23
|
+
if (!latest) return { relations, messages: [] };
|
|
24
|
+
const message = await read.message(latest.subjectId);
|
|
25
|
+
if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
|
|
26
|
+
const payload = (await read.payloads(message.id))
|
|
27
|
+
.filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
|
|
28
|
+
.sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
|
|
29
|
+
if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
|
|
30
|
+
if (payload.error) throw new Error(payload.error);
|
|
31
|
+
if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
|
|
32
|
+
throw new Error("The latest reference review metadata is invalid.");
|
|
33
|
+
}
|
|
34
|
+
const hydrated = { ...message, metadata: payload.json };
|
|
35
|
+
const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
|
|
36
|
+
if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
|
|
37
|
+
return { relations, messages: [hydrated] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
|
|
41
|
+
const latest = new Map<string, MessageRecord>();
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
44
|
+
const lineage = metadataRecord(message.metadata).referenceLineageId;
|
|
45
|
+
if (typeof lineage !== "string" || !lineage) continue;
|
|
46
|
+
if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
|
|
47
|
+
}
|
|
48
|
+
return latest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A later acceptance or archive clears any earlier rejection reason. */
|
|
52
|
+
export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
|
|
53
|
+
const reasons = new Map<string, ReferenceRejectionReasonCode>();
|
|
54
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
55
|
+
const metadata = metadataRecord(message.metadata);
|
|
56
|
+
const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
|
|
57
|
+
if (reason) reasons.set(lineage, reason);
|
|
58
|
+
}
|
|
59
|
+
return reasons;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
|
|
63
|
+
export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
|
|
64
|
+
const sections = new Map<string, string>();
|
|
65
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
66
|
+
const metadata = metadataRecord(message.metadata);
|
|
67
|
+
const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
|
|
68
|
+
if (sectionId) sections.set(lineage, sectionId);
|
|
69
|
+
}
|
|
70
|
+
return sections;
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Hold the lock before invoking a save, including before React can rerender. */
|
|
2
|
+
export function runReferenceReviewRequest(
|
|
3
|
+
pending: Map<string, Promise<void>>,
|
|
4
|
+
lineageId: string,
|
|
5
|
+
save: () => Promise<void>,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
const existing = pending.get(lineageId);
|
|
8
|
+
if (existing) return existing;
|
|
9
|
+
const request = Promise.resolve().then(save).finally(() => {
|
|
10
|
+
if (pending.get(lineageId) === request) pending.delete(lineageId);
|
|
11
|
+
});
|
|
12
|
+
pending.set(lineageId, request);
|
|
13
|
+
return request;
|
|
14
|
+
}
|
|
@@ -7,8 +7,12 @@ export type RelevanceProvenance = {
|
|
|
7
7
|
labelSet: string[];
|
|
8
8
|
doctrineFingerprint: string | null;
|
|
9
9
|
teachingFingerprint: string | null;
|
|
10
|
-
teachingLabels: number;
|
|
11
|
-
primingExamples: number;
|
|
10
|
+
teachingLabels: number | null;
|
|
11
|
+
primingExamples: number | null;
|
|
12
|
+
/** Recorded by the shared monitoring router, absent on local sweep provenance. */
|
|
13
|
+
system?: string;
|
|
14
|
+
publicationId?: string;
|
|
15
|
+
output?: string;
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
/** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
|
|
@@ -28,6 +32,7 @@ export type RelevanceDecisionView = {
|
|
|
28
32
|
/** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
|
|
29
33
|
reviewControl: "thumbs" | "labels";
|
|
30
34
|
cyclotronId: string | null;
|
|
35
|
+
classifierId?: string;
|
|
31
36
|
/** When the cyclotron made the suggestion, if the decision recorded it. */
|
|
32
37
|
decidedAt: string | null;
|
|
33
38
|
/** Null for decisions made before provenance was recorded. */
|
|
@@ -44,6 +49,7 @@ export type RelevanceReview = {
|
|
|
44
49
|
|
|
45
50
|
/** The existing curation action an editor's review becomes. */
|
|
46
51
|
export type RelevanceCuration = {
|
|
52
|
+
sectionId?: string | null;
|
|
47
53
|
action: "accept" | "reject";
|
|
48
54
|
reasonCode: string | null;
|
|
49
55
|
note: string | null;
|
|
@@ -65,7 +71,7 @@ function parseMetadata(value: unknown): Record<string, unknown> {
|
|
|
65
71
|
function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
66
72
|
const record = parseMetadata(value);
|
|
67
73
|
if (!Object.keys(record).length) return null;
|
|
68
|
-
const count = (entry: unknown) =>
|
|
74
|
+
const count = (entry: unknown) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 ? entry : null;
|
|
69
75
|
const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
|
|
70
76
|
return {
|
|
71
77
|
labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
|
|
@@ -73,27 +79,52 @@ function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
|
73
79
|
teachingFingerprint: text(record.teachingFingerprint),
|
|
74
80
|
teachingLabels: count(record.teachingLabels),
|
|
75
81
|
primingExamples: count(record.primingExamples),
|
|
82
|
+
...(text(record.system) ? { system: text(record.system)! } : {}),
|
|
83
|
+
...(text(record.publicationId) ? { publicationId: text(record.publicationId)! } : {}),
|
|
84
|
+
...(text(record.output) ? { output: text(record.output)! } : {}),
|
|
76
85
|
};
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function recordedVersion(value: unknown): number | null {
|
|
89
|
+
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
|
|
90
|
+
const version = Number(value);
|
|
91
|
+
return Number.isInteger(version) && version >= 0 ? version : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decisionTime(relation: SemanticRelationRecord): number {
|
|
95
|
+
const metadata = parseMetadata(relation.metadata);
|
|
96
|
+
for (const value of [metadata.decidedAt, relation.importedAt, relation.createdAt]) {
|
|
97
|
+
if (typeof value !== "string") continue;
|
|
98
|
+
const timestamp = Date.parse(value);
|
|
99
|
+
if (Number.isFinite(timestamp)) return timestamp;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
105
|
* Current decisions keyed by reference lineage, from relations the Newsroom
|
|
81
106
|
* already loads: no extra request per row.
|
|
82
107
|
*/
|
|
83
108
|
export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
|
|
84
109
|
const decisions = new Map<string, RelevanceDecisionView>();
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
// Realtime events can briefly expose both sides of a superseding write. The
|
|
111
|
+
// result must not depend on list pagination or subscription arrival order.
|
|
112
|
+
const ordered = [...relations].sort((left, right) => decisionTime(left) - decisionTime(right) || left.id.localeCompare(right.id));
|
|
113
|
+
for (const relation of ordered) {
|
|
114
|
+
if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current" || relation.subjectKind !== "reference") continue;
|
|
87
115
|
const metadata = parseMetadata(relation.metadata);
|
|
116
|
+
if (!relation.subjectLineageId || typeof metadata.label !== "string" || !metadata.label.trim()
|
|
117
|
+
|| typeof metadata.decisionId !== "string" || !metadata.decisionId.trim()) continue;
|
|
88
118
|
const probabilities = parseMetadata(metadata.probabilities);
|
|
89
119
|
const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
|
|
90
|
-
const version =
|
|
120
|
+
const version = recordedVersion(relation.modelVersion ?? metadata.version);
|
|
91
121
|
decisions.set(relation.subjectLineageId, {
|
|
92
122
|
decisionRelationId: relation.id,
|
|
93
|
-
decisionId:
|
|
94
|
-
label:
|
|
95
|
-
confidence: typeof relation.confidence === "number"
|
|
96
|
-
|
|
123
|
+
decisionId: metadata.decisionId,
|
|
124
|
+
label: metadata.label,
|
|
125
|
+
confidence: typeof relation.confidence === "number" && Number.isFinite(relation.confidence)
|
|
126
|
+
&& relation.confidence >= 0 && relation.confidence <= 1 ? relation.confidence : null,
|
|
127
|
+
version,
|
|
97
128
|
reviewRecommended: Boolean(relation.reviewRecommended),
|
|
98
129
|
reviewReason: reason,
|
|
99
130
|
reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
|
|
@@ -104,6 +135,7 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
104
135
|
question: typeof metadata.question === "string" ? metadata.question : null,
|
|
105
136
|
reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
|
|
106
137
|
cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
|
|
138
|
+
...(relation.classifierId ? { classifierId: relation.classifierId } : {}),
|
|
107
139
|
decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
|
|
108
140
|
provenance: parseProvenance(metadata.provenance),
|
|
109
141
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** Load persisted predictions and attributed review history without cancelling on a loading rerender. */
|
|
6
|
+
export function useReferenceReviewData<Relation, Message>({
|
|
7
|
+
enabled,
|
|
8
|
+
loadRelations,
|
|
9
|
+
loadMessages,
|
|
10
|
+
onRelations,
|
|
11
|
+
onMessages,
|
|
12
|
+
}: {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
loadRelations: () => Promise<Relation[]>;
|
|
15
|
+
loadMessages: () => Promise<Message[]>;
|
|
16
|
+
onRelations: (relations: Relation[]) => void;
|
|
17
|
+
onMessages: (messages: Message[]) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [state, setState] = useState<"loading" | "ready" | "error">(enabled ? "loading" : "ready");
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [attempt, setAttempt] = useState(0);
|
|
22
|
+
const retry = useCallback(() => setAttempt((current) => current + 1), []);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
let active = true;
|
|
26
|
+
setState("loading");
|
|
27
|
+
setError(null);
|
|
28
|
+
void Promise.allSettled([loadRelations(), loadMessages()]).then(([relations, history]) => {
|
|
29
|
+
if (!active) return;
|
|
30
|
+
if (relations.status === "fulfilled") onRelations(relations.value);
|
|
31
|
+
if (history.status === "fulfilled") onMessages(history.value);
|
|
32
|
+
const failure = relations.status === "rejected" ? relations.reason : history.status === "rejected" ? history.reason : null;
|
|
33
|
+
if (failure) {
|
|
34
|
+
const subject = relations.status === "rejected" ? "Predictions" : "Review history";
|
|
35
|
+
setError(`${subject} could not be loaded. ${failure instanceof Error ? failure.message : "Try again."}`);
|
|
36
|
+
setState("error");
|
|
37
|
+
} else {
|
|
38
|
+
setState("ready");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return () => { active = false; };
|
|
42
|
+
}, [enabled, loadRelations, loadMessages, onRelations, onMessages, attempt]);
|
|
43
|
+
return { state, error, retry };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -298,6 +298,8 @@
|
|
|
298
298
|
"./lib/reader-subject": "./lib/reader-subject.ts",
|
|
299
299
|
"./lib/reference-display": "./lib/reference-display.ts",
|
|
300
300
|
"./lib/reference-policy": "./lib/reference-policy.ts",
|
|
301
|
+
"./lib/reference-review-details": "./lib/reference-review-details.ts",
|
|
302
|
+
"./lib/reference-review-request": "./lib/reference-review-request.ts",
|
|
301
303
|
"./lib/reference-source-preview": "./lib/reference-source-preview.ts",
|
|
302
304
|
"./lib/relevance-decisions": "./lib/relevance-decisions.ts",
|
|
303
305
|
"./lib/relevance-status": "./lib/relevance-status.ts",
|
|
@@ -329,6 +331,7 @@
|
|
|
329
331
|
"./lib/test-editor-override": "./lib/test-editor-override.ts",
|
|
330
332
|
"./lib/text-columns": "./lib/text-columns.ts",
|
|
331
333
|
"./lib/themed-image": "./lib/themed-image.ts",
|
|
334
|
+
"./lib/use-reference-review-data": "./lib/use-reference-review-data.ts",
|
|
332
335
|
"./lib/utils": "./lib/utils.ts",
|
|
333
336
|
"./lib/video-loading": "./lib/video-loading.ts",
|
|
334
337
|
"./lib/video-mode": "./lib/video-mode.ts",
|
|
@@ -444,7 +447,7 @@
|
|
|
444
447
|
"optional": true
|
|
445
448
|
}
|
|
446
449
|
},
|
|
447
|
-
"version": "2.0.0-next.
|
|
450
|
+
"version": "2.0.0-next.78",
|
|
448
451
|
"dependencies": {
|
|
449
452
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
450
453
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED