@anthusai/papyrus 5.47.0 → 5.49.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/components/needs-me-bodies.tsx +300 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +578 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/newsroom-ops-overview.tsx +44 -37
- package/components/newsroom-ops-shell.tsx +4 -4
- package/components/newsroom-organizer.css +30 -11
- package/components/newsroom-references-view.tsx +5 -113
- package/components/notification-cards.tsx +14 -9
- package/components/notification-center.tsx +197 -304
- package/components/reference-capture-panel.tsx +2 -2
- package/components/reference-feed.tsx +1 -1
- package/components/reference-review-card.tsx +5 -1
- package/components/topic-steering-workspace.tsx +2 -1
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +57 -6
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/notification-feed.ts +0 -38
- package/lib/reference-preview.ts +2 -2
- package/lib/review-client.ts +33 -4
- package/lib/review-drafts.ts +49 -8
- package/lib/review-fixture.ts +20 -0
- package/lib/review-requests.ts +25 -0
- package/package.json +10 -1
- package/routes.manifest.json +1 -1
|
@@ -113,9 +113,9 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
|
|
116
|
-
export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
|
|
116
|
+
export function ReferenceCapturePanel({ capture, startEditing = false }: { capture: CaptureView; startEditing?: boolean }) {
|
|
117
117
|
const [expanded, setExpanded] = useState(false);
|
|
118
|
-
const [editing, setEditing] = useState(
|
|
118
|
+
const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
|
|
119
119
|
const [savedNote, setSavedNote] = useState(false);
|
|
120
120
|
const line = captureLine(capture.status);
|
|
121
121
|
const Icon = ICON[line.state];
|
|
@@ -35,7 +35,7 @@ export const FEED_SURFACE_STYLE = {} as CSSProperties;
|
|
|
35
35
|
/** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
|
|
36
36
|
export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
|
|
37
37
|
|
|
38
|
-
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
38
|
+
export const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
39
39
|
out_of_scope: "Out of scope",
|
|
40
40
|
policy_exclusion: "Policy exclusion",
|
|
41
41
|
duplicate: "Duplicate",
|
|
@@ -270,7 +270,10 @@ export function ReviewQueueBar({
|
|
|
270
270
|
onPrevious,
|
|
271
271
|
position,
|
|
272
272
|
total,
|
|
273
|
+
anchored = false,
|
|
273
274
|
}: {
|
|
275
|
+
/** On a wide screen, pin the bar to the bottom-right corner of the viewport (the detail column's width), whatever scrolls. A sheet's own bar is not anchored. */
|
|
276
|
+
anchored?: boolean;
|
|
274
277
|
caughtUp?: boolean;
|
|
275
278
|
onNext: () => void;
|
|
276
279
|
onPrevious: () => void;
|
|
@@ -282,7 +285,8 @@ export function ReviewQueueBar({
|
|
|
282
285
|
return (
|
|
283
286
|
<nav
|
|
284
287
|
aria-label="Review queue"
|
|
285
|
-
className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2
|
|
288
|
+
className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "md:fixed md:bottom-0 md:right-0 md:w-[calc(24rem+1rem)] md:pr-7 lg:w-[calc(28rem+1rem)]")}
|
|
289
|
+
data-review-queue-anchored={anchored || undefined}
|
|
286
290
|
data-review-queue-bar
|
|
287
291
|
>
|
|
288
292
|
<Button
|
|
@@ -3109,7 +3109,8 @@ function NewsDeskDashboard({
|
|
|
3109
3109
|
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3110
3110
|
newsroomSections={newsroomSections}
|
|
3111
3111
|
notificationFixture={dashboard.notificationFixture ?? null}
|
|
3112
|
-
|
|
3112
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3113
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
|
|
3113
3114
|
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
|
|
3114
3115
|
predictionState={referenceReviewData.state}
|
|
3115
3116
|
referenceAttachments={referenceAttachments}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** True while the media query matches. False on the server and before the first effect, so the first paint is the wide layout. */
|
|
6
|
+
export function useMediaQuery(query: string): boolean {
|
|
7
|
+
const [matches, setMatches] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const mediaQuery = window.matchMedia(query);
|
|
10
|
+
const update = () => setMatches(mediaQuery.matches);
|
|
11
|
+
update();
|
|
12
|
+
mediaQuery.addEventListener("change", update);
|
|
13
|
+
return () => mediaQuery.removeEventListener("change", update);
|
|
14
|
+
}, [query]);
|
|
15
|
+
return matches;
|
|
16
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
5
|
+
import { referenceLineageId } from "../lib/newsroom-references";
|
|
6
|
+
import { loadReferenceAttachmentsForLineageId, loadStoragePathUrl } from "./news-desk-taxonomy-client";
|
|
7
|
+
|
|
8
|
+
function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
|
|
9
|
+
const trimmed = value?.trim();
|
|
10
|
+
if (!trimmed) return null;
|
|
11
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The attachments of one Reference (seeded, else read), with stored files turned into links. Shared by the References detail pane and the Needs me detail pane. */
|
|
16
|
+
export function useReferencePreviewAttachments(
|
|
17
|
+
reference: ReferenceRecord | null,
|
|
18
|
+
referenceAttachments: ReferenceAttachmentRecord[],
|
|
19
|
+
) {
|
|
20
|
+
const lineageId = reference ? referenceLineageId(reference) : "";
|
|
21
|
+
const seededAttachments = useMemo(
|
|
22
|
+
() => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
|
|
23
|
+
[lineageId, referenceAttachments],
|
|
24
|
+
);
|
|
25
|
+
const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
|
|
26
|
+
const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setAttachments(seededAttachments);
|
|
30
|
+
}, [seededAttachments]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!lineageId) return;
|
|
34
|
+
if (seededAttachments.length > 0) return;
|
|
35
|
+
let active = true;
|
|
36
|
+
void loadReferenceAttachmentsForLineageId(lineageId)
|
|
37
|
+
.then((loaded) => {
|
|
38
|
+
if (!active || !loaded.length) return;
|
|
39
|
+
setAttachments(loaded);
|
|
40
|
+
})
|
|
41
|
+
.catch(() => undefined);
|
|
42
|
+
return () => {
|
|
43
|
+
active = false;
|
|
44
|
+
};
|
|
45
|
+
}, [lineageId, seededAttachments.length]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!attachments.length) {
|
|
49
|
+
setAttachmentLinksById({});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let active = true;
|
|
53
|
+
const staticLinks = new Map<string, string>();
|
|
54
|
+
const storageLookups: Array<{ id: string; storagePath: string }> = [];
|
|
55
|
+
|
|
56
|
+
for (const attachment of attachments) {
|
|
57
|
+
if (attachment.storagePath) {
|
|
58
|
+
storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
|
|
62
|
+
if (sourceHref) staticLinks.set(attachment.id, sourceHref);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!storageLookups.length) {
|
|
66
|
+
setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
|
|
67
|
+
return () => {
|
|
68
|
+
active = false;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
|
|
73
|
+
void Promise.all(storageLookups.map(async (lookup) => {
|
|
74
|
+
const result = await loadStoragePathUrl(lookup.storagePath);
|
|
75
|
+
return { id: lookup.id, url: result.url };
|
|
76
|
+
}))
|
|
77
|
+
.then((resolved) => {
|
|
78
|
+
if (!active) return;
|
|
79
|
+
const nextLinks = Object.fromEntries(staticLinks.entries());
|
|
80
|
+
for (const entry of resolved) {
|
|
81
|
+
if (entry.url) nextLinks[entry.id] = entry.url;
|
|
82
|
+
}
|
|
83
|
+
setAttachmentLinksById(nextLinks);
|
|
84
|
+
})
|
|
85
|
+
.catch(() => undefined);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
active = false;
|
|
89
|
+
};
|
|
90
|
+
}, [attachments]);
|
|
91
|
+
|
|
92
|
+
const previewAttachments = useMemo(
|
|
93
|
+
() => attachments.map((attachment) => ({
|
|
94
|
+
...attachment,
|
|
95
|
+
sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
|
|
96
|
+
})),
|
|
97
|
+
[attachmentLinksById, attachments],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return previewAttachments;
|
|
101
|
+
}
|
|
@@ -41,18 +41,29 @@ export type RatingController = {
|
|
|
41
41
|
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
42
|
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
|
-
export function useRatingStore({ save, saved, seed }: {
|
|
44
|
+
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
46
46
|
saved: (id: string) => ReviewChoice | null;
|
|
47
47
|
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
|
+
/** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
|
|
49
|
+
needsReason?: (id: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
|
|
52
|
+
* queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
|
|
53
|
+
*/
|
|
54
|
+
sticky?: (id: string) => boolean;
|
|
55
|
+
/** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
|
|
56
|
+
saveNote?: (id: string, note: string) => Promise<void>;
|
|
48
57
|
}): RatingController {
|
|
49
58
|
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
50
59
|
const itemsRef = useRef(items);
|
|
51
|
-
const latest = useRef({ save, saved, seed });
|
|
52
|
-
latest.current = { save, saved, seed };
|
|
60
|
+
const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
|
|
61
|
+
latest.current = { save, saved, seed, needsReason, sticky, saveNote };
|
|
53
62
|
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
54
63
|
const inFlight = useRef<Set<string>>(new Set());
|
|
55
64
|
const dirty = useRef<Set<string>>(new Set());
|
|
65
|
+
const decidedHere = useRef<Set<string>>(new Set());
|
|
66
|
+
const noteInFlight = useRef<Set<string>>(new Set());
|
|
56
67
|
|
|
57
68
|
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
58
69
|
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
@@ -73,9 +84,34 @@ export function useRatingStore({ save, saved, seed }: {
|
|
|
73
84
|
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
74
85
|
};
|
|
75
86
|
|
|
87
|
+
/** True once a sticky item has its decision: from then on only its reason can change. */
|
|
88
|
+
const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
|
|
89
|
+
|
|
90
|
+
/** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
|
|
91
|
+
const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
|
|
92
|
+
if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
|
|
93
|
+
noteInFlight.current.add(id);
|
|
94
|
+
dirty.current.delete(`note:${id}`);
|
|
95
|
+
const started = itemsRef.current[id]?.note;
|
|
96
|
+
const { note } = values(id);
|
|
97
|
+
patch(id, { feedback: { phase: "saving" } });
|
|
98
|
+
try {
|
|
99
|
+
await latest.current.saveNote!(id, note.trim());
|
|
100
|
+
const now = itemsRef.current[id];
|
|
101
|
+
patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
|
|
104
|
+
patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
|
|
105
|
+
} finally {
|
|
106
|
+
noteInFlight.current.delete(id);
|
|
107
|
+
}
|
|
108
|
+
if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
|
|
109
|
+
}, [patch]);
|
|
110
|
+
|
|
76
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
|
+
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
77
113
|
const { note, reasonCode } = values(id);
|
|
78
|
-
if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
114
|
+
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
79
115
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
80
116
|
inFlight.current.add(id);
|
|
81
117
|
dirty.current.delete(id);
|
|
@@ -83,6 +119,7 @@ export function useRatingStore({ save, saved, seed }: {
|
|
|
83
119
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
84
120
|
try {
|
|
85
121
|
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
122
|
+
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
86
123
|
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
87
124
|
const now = itemsRef.current[id] ?? EMPTY;
|
|
88
125
|
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
@@ -93,11 +130,12 @@ export function useRatingStore({ save, saved, seed }: {
|
|
|
93
130
|
inFlight.current.delete(id);
|
|
94
131
|
}
|
|
95
132
|
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
96
|
-
}, [patch]);
|
|
133
|
+
}, [patch, saveNoteOnly]);
|
|
97
134
|
|
|
98
135
|
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
99
136
|
|
|
100
137
|
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
138
|
+
if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
|
|
101
139
|
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
102
140
|
void commit(id, choice);
|
|
103
141
|
}, [commit]);
|
|
@@ -126,7 +164,20 @@ export function useRatingStore({ save, saved, seed }: {
|
|
|
126
164
|
|
|
127
165
|
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
128
166
|
|
|
129
|
-
|
|
167
|
+
// Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
|
|
168
|
+
const leaving = useRef<() => void>(() => undefined);
|
|
169
|
+
leaving.current = () => {
|
|
170
|
+
for (const key of [...dirty.current]) {
|
|
171
|
+
if (!key.startsWith("note:")) continue;
|
|
172
|
+
const id = key.slice(5);
|
|
173
|
+
const choice = effective(id);
|
|
174
|
+
if (choice && dirty.current.delete(key)) void commit(id, choice);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
useEffect(() => () => {
|
|
178
|
+
for (const timer of Object.values(timers.current)) clearTimeout(timer);
|
|
179
|
+
leaving.current();
|
|
180
|
+
}, []);
|
|
130
181
|
|
|
131
182
|
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
132
183
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapters that turn what already exists into `NeedsMeItem`s, the one model the "What needs me" feed ranks and renders.
|
|
3
|
+
* Each adapter only reads; deciding is routed back to the existing action for that source (see components/notification-center.tsx).
|
|
4
|
+
*
|
|
5
|
+
* reference a pending Reference reads the Reference, the Cyclotron's decision on it, its preview
|
|
6
|
+
* incomplete_capture a pending Reference whose captured text is reads the same, plus the Reference's capture status (the
|
|
7
|
+
* missing or cut off `curation` card, else the extracted-text attachments)
|
|
8
|
+
* draft one AI draft in an open draft review reads the review request and the Proposal (headline, dek, sources)
|
|
9
|
+
* stage_review an open hard gate or soft review that is reads the review request (reason, ask, deadline, subject) and the
|
|
10
|
+
* not about drafts subject story's title and excerpt
|
|
11
|
+
* flag an open flag reads the review request
|
|
12
|
+
* held_email an email submission held for review reads the monitoring email summary (subject, sender, verdicts)
|
|
13
|
+
* concept_proposal a proposed taxonomy concept or reader STUB: typed, returns nothing until those sources reach the page
|
|
14
|
+
* proposal
|
|
15
|
+
*/
|
|
16
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
17
|
+
import type { MonitoringEmailSummary } from "./monitoring-overview";
|
|
18
|
+
import { buildNotificationItems, draftPicture, requestSource, type DraftItem, type RequestItem } from "./notification-feed";
|
|
19
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
20
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
21
|
+
import { dekAddsInformation, referenceDek, referenceSourceHost, type ReferencePreview } from "./reference-preview";
|
|
22
|
+
import { referenceArrivalTime, referenceDisplayTime } from "./reference-feed-ranking";
|
|
23
|
+
import type { CaptureStatus } from "./reference-capture";
|
|
24
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
25
|
+
import type { DraftView } from "./review-drafts";
|
|
26
|
+
import { nextText, type ReviewRequest } from "./review-requests";
|
|
27
|
+
import { predictionOf, uncertaintyOf, type NeedsMeCounts, type NeedsMeDecisionState, type NeedsMeItem } from "./needs-me-ranking";
|
|
28
|
+
|
|
29
|
+
export type NeedsMePayload =
|
|
30
|
+
| { kind: "reference"; reference: ReferenceRecord; lineageId: string; decision: RelevanceDecisionView | undefined; preview: ReferencePreview | undefined; capture: CaptureStatus | undefined; displayTime: number | null }
|
|
31
|
+
| { kind: "draft"; item: DraftItem; loading: boolean }
|
|
32
|
+
| { kind: "request"; request: ReviewRequest; storyTitle: string | null; excerpt: string | null; storyDeleted: boolean }
|
|
33
|
+
| { kind: "email"; summary: MonitoringEmailSummary }
|
|
34
|
+
| { kind: "concept"; row: ConceptProposalRow };
|
|
35
|
+
|
|
36
|
+
export type NeedsMeEntry = NeedsMeItem<NeedsMePayload>;
|
|
37
|
+
|
|
38
|
+
const ACCEPT_WORD = { accept: "Accept", reject: "Reject" } as const;
|
|
39
|
+
|
|
40
|
+
function timeOf(value: string | null | undefined): number | null {
|
|
41
|
+
const parsed = value ? Date.parse(value) : NaN;
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function percent(confidence: number | null): string {
|
|
46
|
+
return confidence === null ? "" : ` ${Math.round(confidence * 100)}%`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- references and captures ----------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a capture was attempted and came up short, and nobody has said otherwise: text that is thin (too short, or showing signs of
|
|
53
|
+
* being cut off or paywalled), or a curation card that records a failed capture. A Reference nobody has tried to capture yet (the
|
|
54
|
+
* address only, as monitoring discovers it) is not "incomplete": there is nothing to check.
|
|
55
|
+
*/
|
|
56
|
+
export function captureIsIncomplete(capture: CaptureStatus | undefined): boolean {
|
|
57
|
+
if (!capture || capture.assessment) return false;
|
|
58
|
+
return capture.state === "thin" || (capture.state === "none" && capture.source === "curation");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function referenceDefaultOutcome(decision: RelevanceDecisionView | undefined): string {
|
|
62
|
+
const prediction = predictionOf(decision);
|
|
63
|
+
return prediction
|
|
64
|
+
? `If you leave it, it stays pending with the Cyclotron's suggestion: ${ACCEPT_WORD[prediction.label]}${percent(prediction.confidence)}.`
|
|
65
|
+
: "If you leave it, it stays pending. Nothing uses it until someone rates it.";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ReferenceSources = {
|
|
69
|
+
references: ReferenceRecord[];
|
|
70
|
+
decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined;
|
|
71
|
+
previews: ReadonlyMap<string, ReferencePreview>;
|
|
72
|
+
captures: ReadonlyMap<string, CaptureStatus> | undefined;
|
|
73
|
+
/** Lineage ids rated in this visit: they keep their place showing the answer. */
|
|
74
|
+
visitRated: ReadonlySet<string>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Pending References (and the ones rated in this visit), as `reference` items, or `incomplete_capture` when the capture is cut off. */
|
|
78
|
+
export function referenceItems(input: ReferenceSources): NeedsMeEntry[] {
|
|
79
|
+
const items: NeedsMeEntry[] = [];
|
|
80
|
+
for (const reference of input.references) {
|
|
81
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
82
|
+
const lineageId = referenceLineageId(reference);
|
|
83
|
+
if (status === "archived") continue;
|
|
84
|
+
const decided = status === "accepted" || status === "rejected";
|
|
85
|
+
if (decided && !input.visitRated.has(lineageId)) continue;
|
|
86
|
+
const decision = input.decisions?.get(lineageId);
|
|
87
|
+
const preview = input.previews.get(lineageId);
|
|
88
|
+
const capture = input.captures?.get(lineageId);
|
|
89
|
+
const prediction = predictionOf(decision);
|
|
90
|
+
const title = referenceDisplayTitle(reference);
|
|
91
|
+
const dek = referenceDek(reference, preview);
|
|
92
|
+
const host = referenceSourceHost(reference);
|
|
93
|
+
const incomplete = !decided && captureIsIncomplete(capture);
|
|
94
|
+
items.push({
|
|
95
|
+
type: incomplete ? "incomplete_capture" : "reference",
|
|
96
|
+
id: lineageId,
|
|
97
|
+
title,
|
|
98
|
+
dek: incomplete ? "The captured text looks cut off or missing, so the Cyclotron cannot judge it well." : dekAddsInformation(title, dek, preview?.imageUrl ?? null) ? dek : null,
|
|
99
|
+
image: preview?.imageUrl ?? null,
|
|
100
|
+
source: preview?.siteName ?? host ?? "Unknown source",
|
|
101
|
+
host,
|
|
102
|
+
createdAt: referenceArrivalTime(reference) ?? referenceDisplayTime(reference),
|
|
103
|
+
...(prediction ? { prediction } : {}),
|
|
104
|
+
importance: 1,
|
|
105
|
+
uncertainty: uncertaintyOf(prediction?.pAccept),
|
|
106
|
+
decisionState: status === "accepted" ? "accepted" : status === "rejected" ? "rejected" : "open",
|
|
107
|
+
defaultOutcome: incomplete ? "If you leave it, the Reference stays pending with the text it has." : referenceDefaultOutcome(decision),
|
|
108
|
+
hardGate: false,
|
|
109
|
+
askedForReview: Boolean(decision?.reviewRecommended),
|
|
110
|
+
payload: { kind: "reference", reference, lineageId, decision, preview, capture, displayTime: referenceDisplayTime(reference) },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return items;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- drafts, stories, flags -------------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function choiceState(choice: "accept" | "reject" | null | undefined): NeedsMeDecisionState {
|
|
119
|
+
return choice === "accept" ? "accepted" : choice === "reject" ? "rejected" : "open";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, ReferencePreview>): NeedsMeEntry {
|
|
123
|
+
const { view, request } = item;
|
|
124
|
+
const picture = draftPicture(view, bySource);
|
|
125
|
+
const link = request.links.find((entry) => entry.id === item.draftId);
|
|
126
|
+
return {
|
|
127
|
+
type: "draft",
|
|
128
|
+
id: item.key,
|
|
129
|
+
title: view?.headline ?? link?.label ?? "Draft",
|
|
130
|
+
dek: view ? view.dek : "Loading the draft…",
|
|
131
|
+
image: picture.imageUrl,
|
|
132
|
+
source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "AI draft"),
|
|
133
|
+
host: picture.host,
|
|
134
|
+
createdAt: timeOf(request.raisedAt),
|
|
135
|
+
importance: 1,
|
|
136
|
+
uncertainty: uncertaintyOf(undefined),
|
|
137
|
+
decisionState: choiceState(item.decision?.choice),
|
|
138
|
+
defaultOutcome: nextText(request),
|
|
139
|
+
hardGate: request.mode === "hard_gate",
|
|
140
|
+
askedForReview: true,
|
|
141
|
+
payload: { kind: "draft", item, loading: !view },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function requestDecisionState(request: ReviewRequest): NeedsMeDecisionState {
|
|
146
|
+
if (request.state === "open") return "open";
|
|
147
|
+
const answer = request.answer;
|
|
148
|
+
if (!answer) return "accepted";
|
|
149
|
+
if (answer.decision === "acknowledge" || (answer.decision === "approve" && request.mode === "flag")) return "acknowledged";
|
|
150
|
+
if (answer.decision === "reject" || (answer.decision === "feedback" && answer.steer === "not_this_kind")) return "rejected";
|
|
151
|
+
return "accepted";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function requestItem(
|
|
155
|
+
item: RequestItem,
|
|
156
|
+
context: { titles: Readonly<Record<string, string>>; excerpts: Readonly<Record<string, string>>; deleted: ReadonlySet<string> },
|
|
157
|
+
): NeedsMeEntry {
|
|
158
|
+
const { request } = item;
|
|
159
|
+
const storyId = request.subject.type === "story" ? request.subject.id : null;
|
|
160
|
+
const flag = request.mode === "flag";
|
|
161
|
+
return {
|
|
162
|
+
type: flag ? "flag" : "stage_review",
|
|
163
|
+
id: request.id,
|
|
164
|
+
title: request.reason,
|
|
165
|
+
dek: request.ask,
|
|
166
|
+
image: null,
|
|
167
|
+
source: requestSource(request),
|
|
168
|
+
host: null,
|
|
169
|
+
createdAt: timeOf(request.raisedAt),
|
|
170
|
+
importance: 1,
|
|
171
|
+
uncertainty: uncertaintyOf(undefined),
|
|
172
|
+
decisionState: requestDecisionState(request),
|
|
173
|
+
defaultOutcome: nextText(request),
|
|
174
|
+
hardGate: request.mode === "hard_gate",
|
|
175
|
+
askedForReview: true,
|
|
176
|
+
payload: {
|
|
177
|
+
kind: "request",
|
|
178
|
+
request,
|
|
179
|
+
storyTitle: storyId ? context.titles[storyId] ?? null : null,
|
|
180
|
+
excerpt: storyId ? context.excerpts[storyId] ?? null : null,
|
|
181
|
+
storyDeleted: storyId ? context.deleted.has(storyId) : false,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- held email --------------------------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
function senderDomain(summary: MonitoringEmailSummary): string | null {
|
|
189
|
+
const from = summary.headerFrom || summary.envelopeSender || "";
|
|
190
|
+
const address = from.match(/@([A-Za-z0-9.-]+\.[A-Za-z]{2,})/);
|
|
191
|
+
return address ? address[1].toLowerCase() : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The decision recorded on a receipt, else the one made in this visit. */
|
|
195
|
+
export function emailDecisionState(summary: MonitoringEmailSummary, decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeDecisionState {
|
|
196
|
+
const made = decided.get(summary.receiptId) ?? summary.quarantine?.decision?.action;
|
|
197
|
+
return choiceState(made);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Held email submissions: untrusted, so only the subject and sender facts are used; the body is read in the detail pane as inert text. */
|
|
201
|
+
export function heldEmailItems(summaries: readonly MonitoringEmailSummary[], decided: ReadonlyMap<string, "accept" | "reject">): NeedsMeEntry[] {
|
|
202
|
+
const items: NeedsMeEntry[] = [];
|
|
203
|
+
for (const summary of summaries) {
|
|
204
|
+
const held = summary.quarantine?.state === "quarantined";
|
|
205
|
+
const decision = emailDecisionState(summary, decided);
|
|
206
|
+
if (!held && !decided.has(summary.receiptId)) continue;
|
|
207
|
+
const domain = senderDomain(summary);
|
|
208
|
+
items.push({
|
|
209
|
+
type: "held_email",
|
|
210
|
+
id: `email#${summary.receiptId}`,
|
|
211
|
+
title: summary.subject?.trim() || "(no subject)",
|
|
212
|
+
dek: summary.warnings.length ? `Held for review: ${summary.warnings.join(", ")}.` : `Held for review. ${summary.linkCount} candidate ${summary.linkCount === 1 ? "link" : "links"}.`,
|
|
213
|
+
image: null,
|
|
214
|
+
source: domain ?? "Email submission",
|
|
215
|
+
host: domain,
|
|
216
|
+
createdAt: timeOf(summary.observedAt),
|
|
217
|
+
importance: 1,
|
|
218
|
+
uncertainty: uncertaintyOf(undefined),
|
|
219
|
+
decisionState: decision,
|
|
220
|
+
defaultOutcome: "If you leave it, it stays held privately. Nothing is routed from it unless you accept it.",
|
|
221
|
+
hardGate: false,
|
|
222
|
+
askedForReview: true,
|
|
223
|
+
payload: { kind: "email", summary },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return items;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- stubs ---------------------------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** A proposed taxonomy concept or a reader's proposal. Not on the page yet: the source is typed so the adapter has a place to land. */
|
|
232
|
+
export type ConceptProposalRow = { id: string; title: string; summary: string | null; proposedBy: "cyclotron" | "reader"; createdAt: string | null };
|
|
233
|
+
|
|
234
|
+
export function conceptProposalItems(_rows: readonly ConceptProposalRow[]): NeedsMeEntry[] {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- all of it -----------------------------------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export type NeedsMeSources = ReferenceSources & {
|
|
241
|
+
requests: ReviewRequest[];
|
|
242
|
+
drafts: Readonly<Record<string, DraftView | undefined>>;
|
|
243
|
+
missingDrafts: ReadonlySet<string>;
|
|
244
|
+
draftsState: "loading" | "ready" | "failed";
|
|
245
|
+
/** Draft keys and request ids decided in this visit. */
|
|
246
|
+
stay: ReadonlySet<string>;
|
|
247
|
+
bySource: ReadonlyMap<string, ReferencePreview>;
|
|
248
|
+
storyTitles: Readonly<Record<string, string>>;
|
|
249
|
+
storyExcerpts: Readonly<Record<string, string>>;
|
|
250
|
+
deletedStories: ReadonlySet<string>;
|
|
251
|
+
emails: readonly MonitoringEmailSummary[];
|
|
252
|
+
emailsDecided: ReadonlyMap<string, "accept" | "reject">;
|
|
253
|
+
concepts?: readonly ConceptProposalRow[];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Every source, as one list of items (unordered: `rankNeedsMe` orders it). */
|
|
257
|
+
export function buildNeedsMeItems(sources: NeedsMeSources): NeedsMeEntry[] {
|
|
258
|
+
const fromRequests = buildNotificationItems({
|
|
259
|
+
requests: sources.requests, drafts: sources.drafts, missingDrafts: sources.missingDrafts, draftsState: sources.draftsState, stay: sources.stay,
|
|
260
|
+
});
|
|
261
|
+
const context = { titles: sources.storyTitles, excerpts: sources.storyExcerpts, deleted: sources.deletedStories };
|
|
262
|
+
return [
|
|
263
|
+
...referenceItems(sources),
|
|
264
|
+
...fromRequests.drafts.map((item) => draftItem(item, sources.bySource)),
|
|
265
|
+
...fromRequests.reviews.map((item) => requestItem(item, context)),
|
|
266
|
+
...fromRequests.flags.map((item) => requestItem(item, context)),
|
|
267
|
+
...heldEmailItems(sources.emails, sources.emailsDecided),
|
|
268
|
+
...conceptProposalItems(sources.concepts ?? []),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function plural(count: number, one: string, many = `${one}s`): string {
|
|
273
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
|
|
277
|
+
export function waitingTextFor(counts: NeedsMeCounts): string {
|
|
278
|
+
const parts = [
|
|
279
|
+
counts.drafts ? plural(counts.drafts, "draft") : null,
|
|
280
|
+
counts.references ? plural(counts.references, "reference") : null,
|
|
281
|
+
counts.stories ? plural(counts.stories, "story review") : null,
|
|
282
|
+
counts.email ? plural(counts.email, "held email", "held emails") : null,
|
|
283
|
+
counts.captures ? plural(counts.captures, "capture to check", "captures to check") : null,
|
|
284
|
+
counts.other ? plural(counts.other, "other item") : null,
|
|
285
|
+
].filter((part): part is string => part !== null);
|
|
286
|
+
if (!parts.length) return "All caught up";
|
|
287
|
+
const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
288
|
+
return `${list} waiting for you`;
|
|
289
|
+
}
|