@anthusai/papyrus 5.46.0 → 5.48.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 +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/newsroom-ops-overview.tsx +10 -9
- package/components/newsroom-organizer.css +11 -0
- package/components/newsroom-references-view.tsx +3 -111
- package/components/notification-cards.tsx +14 -9
- package/components/notification-center.tsx +145 -304
- package/components/reference-capture-panel.tsx +2 -2
- package/components/reference-feed.tsx +3 -2
- package/components/review-rating-grid.tsx +9 -6
- 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 +6 -4
- 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 +17 -4
- package/lib/review-drafts.ts +16 -0
- package/lib/review-fixture.ts +20 -0
- package/package.json +10 -1
- package/routes.manifest.json +1 -1
|
@@ -47,6 +47,7 @@ export function RatingGrid({
|
|
|
47
47
|
prediction,
|
|
48
48
|
showPrediction = true,
|
|
49
49
|
size = "compact",
|
|
50
|
+
statusOverlay = false,
|
|
50
51
|
surface = "footer",
|
|
51
52
|
}: {
|
|
52
53
|
/** The side the buttons show as chosen (it may be a pending choice that is still saving). */
|
|
@@ -63,6 +64,8 @@ export function RatingGrid({
|
|
|
63
64
|
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
64
65
|
showPrediction?: boolean;
|
|
65
66
|
size?: "compact" | "large";
|
|
67
|
+
/** Lay the Saved / note line over the bottom padding of the nearest positioned ancestor, so it adds no height under the buttons. */
|
|
68
|
+
statusOverlay?: boolean;
|
|
66
69
|
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
67
70
|
surface?: "footer" | "card";
|
|
68
71
|
}) {
|
|
@@ -78,7 +81,7 @@ export function RatingGrid({
|
|
|
78
81
|
return (
|
|
79
82
|
<div
|
|
80
83
|
className={cn(
|
|
81
|
-
"grid gap-x-2 gap-y-2
|
|
84
|
+
"grid gap-x-2 gap-y-2",
|
|
82
85
|
showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
|
|
83
86
|
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
84
87
|
)}
|
|
@@ -113,7 +116,7 @@ export function RatingGrid({
|
|
|
113
116
|
</span>
|
|
114
117
|
);
|
|
115
118
|
})}
|
|
116
|
-
<div className="col-span-2 col-start-2 flex flex-col
|
|
119
|
+
<div className="col-span-2 col-start-2 flex flex-col justify-center" data-confidence>
|
|
117
120
|
<div
|
|
118
121
|
aria-label={ready ? `Cyclotron confidence: leans ${leansAccept ? "Accept" : "Reject"}, ${Math.round(confidenceFillPercent(probability))} percent of the way` : "No confidence"}
|
|
119
122
|
className="relative h-2.5 w-full overflow-hidden rounded-full bg-[var(--feed-control)]"
|
|
@@ -131,9 +134,9 @@ export function RatingGrid({
|
|
|
131
134
|
) : null}
|
|
132
135
|
<span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
|
|
133
136
|
</div>
|
|
134
|
-
{/*
|
|
135
|
-
<small className="
|
|
136
|
-
{prediction.state === "ready" ? "
|
|
137
|
+
{/* Screen-reader status only: a visible line under the bar would push it off the middle between the Cyclotron and You rows. */}
|
|
138
|
+
<small className="sr-only" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
139
|
+
{prediction.state === "ready" ? "" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
137
140
|
</small>
|
|
138
141
|
</div>
|
|
139
142
|
</div> : null}
|
|
@@ -165,7 +168,7 @@ export function RatingGrid({
|
|
|
165
168
|
})}
|
|
166
169
|
</div>
|
|
167
170
|
|
|
168
|
-
<div className={cn("flex
|
|
171
|
+
<div className={cn("flex flex-wrap items-center gap-x-3 text-xs text-muted-foreground", statusOverlay ? "absolute inset-x-4 bottom-0 h-4 overflow-hidden leading-4" : cn("min-h-5", showPrediction ? "col-span-3" : "col-span-2"))} data-feed-saved role="status">
|
|
169
172
|
{note ? <span data-feed-reason>{note}</span> : null}
|
|
170
173
|
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
171
174
|
{feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
|
|
@@ -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,15 +41,17 @@ 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 }: {
|
|
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;
|
|
48
50
|
}): RatingController {
|
|
49
51
|
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
50
52
|
const itemsRef = useRef(items);
|
|
51
|
-
const latest = useRef({ save, saved, seed });
|
|
52
|
-
latest.current = { save, saved, seed };
|
|
53
|
+
const latest = useRef({ save, saved, seed, needsReason });
|
|
54
|
+
latest.current = { save, saved, seed, needsReason };
|
|
53
55
|
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
54
56
|
const inFlight = useRef<Set<string>>(new Set());
|
|
55
57
|
const dirty = useRef<Set<string>>(new Set());
|
|
@@ -75,7 +77,7 @@ export function useRatingStore({ save, saved, seed }: {
|
|
|
75
77
|
|
|
76
78
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
77
79
|
const { note, reasonCode } = values(id);
|
|
78
|
-
if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
80
|
+
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
79
81
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
80
82
|
inFlight.current.add(id);
|
|
81
83
|
dirty.current.delete(id);
|
|
@@ -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
|
+
}
|