@anthusai/papyrus 1.4.0-next.5 → 1.4.0-next.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +67 -0
- package/amplify/data/resource.ts +72 -0
- package/amplify/data/schema.js +67 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/category-action/handler.ts +172 -2
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation.ts +67 -0
- package/amplify/functions/shared/reference-curation-metadata.ts +20 -0
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +43 -27
- package/components/curation-delegation-client.ts +81 -0
- package/components/news-desk-taxonomy-client.ts +7 -0
- package/components/newsroom-newsprint-board.css +1 -1
- package/components/newsroom-organizer-shell.tsx +4 -1
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +56 -21
- package/components/prospectus-account-chooser.tsx +98 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/topic-steering-workspace.tsx +49 -3
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +59 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +141 -3
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/relevance-decisions.ts +29 -0
- package/lib/tenant-keys.ts +32 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -1,45 +1,61 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect
|
|
4
|
-
import { createPortal } from "react-dom";
|
|
3
|
+
import { useEffect } from "react";
|
|
5
4
|
|
|
6
5
|
type Excerpt = { issueId: string; text: string };
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const EXCERPT_TOGGLE_THRESHOLD = 130;
|
|
8
|
+
|
|
9
|
+
function buildExcerptElement(text: string): HTMLElement {
|
|
10
|
+
const container = document.createElement("div");
|
|
11
|
+
container.className = "board-card-excerpt";
|
|
12
|
+
container.dataset.boardCardExcerpt = "";
|
|
13
|
+
container.dataset.expanded = "false";
|
|
14
|
+
const paragraph = document.createElement("p");
|
|
15
|
+
paragraph.textContent = text;
|
|
16
|
+
container.append(paragraph);
|
|
17
|
+
if (text.length > EXCERPT_TOGGLE_THRESHOLD) {
|
|
18
|
+
const toggle = document.createElement("button");
|
|
19
|
+
toggle.type = "button";
|
|
20
|
+
toggle.textContent = "More";
|
|
21
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
22
|
+
const flip = (event: Event) => {
|
|
23
|
+
event.stopPropagation();
|
|
24
|
+
const expanded = container.dataset.expanded !== "true";
|
|
25
|
+
container.dataset.expanded = String(expanded);
|
|
26
|
+
toggle.textContent = expanded ? "Less" : "More";
|
|
27
|
+
toggle.setAttribute("aria-expanded", String(expanded));
|
|
28
|
+
};
|
|
29
|
+
toggle.addEventListener("click", flip);
|
|
30
|
+
toggle.addEventListener("keydown", event => event.stopPropagation());
|
|
31
|
+
container.append(toggle);
|
|
32
|
+
}
|
|
33
|
+
return container;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
36
|
/**
|
|
17
37
|
* Adds a short expandable excerpt under each card's headline. The Kanbus card has no excerpt slot,
|
|
18
|
-
* so each excerpt is
|
|
38
|
+
* so each excerpt is written into the card's content block once the card is on the page.
|
|
19
39
|
*/
|
|
20
40
|
export function BoardCardExcerpts({ excerpts, scope }: { excerpts: readonly Excerpt[]; scope: HTMLElement | null }) {
|
|
21
|
-
const [hosts, setHosts] = useState<{ host: HTMLElement; text: string }[]>([]);
|
|
22
41
|
useEffect(() => {
|
|
23
42
|
if (!scope) return;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return [{ host, text }];
|
|
36
|
-
});
|
|
37
|
-
setHosts(previous => previous.length === found.length && previous.every((entry, index) => entry.host === found[index].host && entry.text === found[index].text) ? previous : found);
|
|
43
|
+
const textByIssueId = new Map(excerpts.map(({ issueId, text }) => [issueId, text]));
|
|
44
|
+
const place = () => {
|
|
45
|
+
for (const card of Array.from(scope.querySelectorAll<HTMLElement>(".issue-card"))) {
|
|
46
|
+
const text = textByIssueId.get(card.dataset.issueId ?? "");
|
|
47
|
+
const content = card.querySelector<HTMLElement>(".issue-card-content");
|
|
48
|
+
if (!text || !content) continue;
|
|
49
|
+
const existing = content.querySelector<HTMLElement>(":scope > [data-board-card-excerpt]");
|
|
50
|
+
if (existing && existing.querySelector("p")?.textContent === text) continue;
|
|
51
|
+
existing?.remove();
|
|
52
|
+
content.querySelector("h3")?.after(buildExcerptElement(text));
|
|
53
|
+
}
|
|
38
54
|
};
|
|
39
|
-
|
|
40
|
-
const observer = new MutationObserver(
|
|
55
|
+
place();
|
|
56
|
+
const observer = new MutationObserver(place);
|
|
41
57
|
observer.observe(scope, { childList: true, subtree: true });
|
|
42
58
|
return () => observer.disconnect();
|
|
43
59
|
}, [excerpts, scope]);
|
|
44
|
-
return
|
|
60
|
+
return null;
|
|
45
61
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
|
+
import type { CurationDelegationRecord } from "../lib/curation-delegation-helpers";
|
|
6
|
+
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
7
|
+
|
|
8
|
+
const USER_POOL_AUTH_MODE = "userPool";
|
|
9
|
+
|
|
10
|
+
export type CreatedCurationDelegation = CurationDelegationRecord & { token: string };
|
|
11
|
+
|
|
12
|
+
const CREATE_MUTATION = `
|
|
13
|
+
mutation CreateCurationDelegation($agentLabel: String!, $hours: Int) {
|
|
14
|
+
createCurationDelegation(agentLabel: $agentLabel, hours: $hours) {
|
|
15
|
+
delegationId token agentLabel grantorLabel expiresAt revokedAt
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const REVOKE_MUTATION = `
|
|
21
|
+
mutation RevokeCurationDelegation($delegationId: ID!) {
|
|
22
|
+
revokeCurationDelegation(delegationId: $delegationId) {
|
|
23
|
+
delegationId agentLabel grantorLabel expiresAt revokedAt
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const LIST_QUERY = `
|
|
29
|
+
query ListCurationDelegations {
|
|
30
|
+
listCurationDelegations {
|
|
31
|
+
delegationId agentLabel grantorLabel expiresAt revokedAt
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const previewDelegations: CurationDelegationRecord[] = [];
|
|
37
|
+
|
|
38
|
+
async function runGraphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
39
|
+
configureAmplifyClient();
|
|
40
|
+
const client = generateClient() as unknown as {
|
|
41
|
+
graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: Array<{ message?: string }> | null }>;
|
|
42
|
+
};
|
|
43
|
+
const response = await client.graphql({ query, variables, authMode: USER_POOL_AUTH_MODE });
|
|
44
|
+
if (response.errors?.length) throw Object.assign(new Error(response.errors[0]?.message ?? "Request failed."), { errors: response.errors });
|
|
45
|
+
if (!response.data) throw new Error("The server returned no result.");
|
|
46
|
+
return response.data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createCurationDelegation(agentLabel: string, hours: number): Promise<CreatedCurationDelegation> {
|
|
50
|
+
if (isLocalPreviewEnabled()) {
|
|
51
|
+
const delegationId = `curation-delegation-preview-${previewDelegations.length + 1}`;
|
|
52
|
+
const record: CurationDelegationRecord = {
|
|
53
|
+
delegationId,
|
|
54
|
+
agentLabel,
|
|
55
|
+
grantorLabel: "ryan@example.com",
|
|
56
|
+
expiresAt: new Date(Date.now() + hours * 3_600_000).toISOString(),
|
|
57
|
+
revokedAt: null,
|
|
58
|
+
};
|
|
59
|
+
previewDelegations.push(record);
|
|
60
|
+
return { ...record, token: `${delegationId}~preview-token-not-valid-anywhere` };
|
|
61
|
+
}
|
|
62
|
+
const data = await runGraphql<{ createCurationDelegation: CreatedCurationDelegation }>(CREATE_MUTATION, { agentLabel, hours });
|
|
63
|
+
return data.createCurationDelegation;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function revokeCurationDelegation(delegationId: string): Promise<CurationDelegationRecord> {
|
|
67
|
+
if (isLocalPreviewEnabled()) {
|
|
68
|
+
const record = previewDelegations.find((entry) => entry.delegationId === delegationId);
|
|
69
|
+
if (!record) throw new Error("That delegation was not found.");
|
|
70
|
+
record.revokedAt = record.revokedAt ?? new Date().toISOString();
|
|
71
|
+
return { ...record };
|
|
72
|
+
}
|
|
73
|
+
const data = await runGraphql<{ revokeCurationDelegation: CurationDelegationRecord }>(REVOKE_MUTATION, { delegationId });
|
|
74
|
+
return data.revokeCurationDelegation;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function listCurationDelegations(): Promise<CurationDelegationRecord[]> {
|
|
78
|
+
if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry })).reverse();
|
|
79
|
+
const data = await runGraphql<{ listCurationDelegations: CurationDelegationRecord[] }>(LIST_QUERY, {});
|
|
80
|
+
return data.listCurationDelegations ?? [];
|
|
81
|
+
}
|
|
@@ -3621,6 +3621,13 @@ export async function loadCyclotronStatus(cyclotronId: string): Promise<import("
|
|
|
3621
3621
|
return latestCyclotronStatus(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
|
|
3622
3622
|
}
|
|
3623
3623
|
|
|
3624
|
+
/** The relevance cyclotron's latest cyclotron-shadow-agreement/v1 payload: suggestions compared with later human decisions. */
|
|
3625
|
+
export async function loadCyclotronShadowAgreement(cyclotronId: string): Promise<import("../lib/cyclotron-suggestions").ShadowAgreement | null> {
|
|
3626
|
+
const { latestShadowAgreement, shadowAgreementPayloadId } = await import("../lib/cyclotron-suggestions");
|
|
3627
|
+
const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", shadowAgreementPayloadId(cyclotronId), ["raw_payload"]);
|
|
3628
|
+
return latestShadowAgreement(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3624
3631
|
/** Ask the next decide-relevance sweep to set or clear a manual review rate. */
|
|
3625
3632
|
export async function requestCyclotronReviewRate(
|
|
3626
3633
|
cyclotronId: string,
|
|
@@ -51,6 +51,6 @@
|
|
|
51
51
|
.papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
|
|
52
52
|
.papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
|
|
53
53
|
.papyrus-newsprint-board .issue-card h3 { margin: 0; }
|
|
54
|
-
.board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp:
|
|
54
|
+
.board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
55
55
|
.board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
|
|
56
56
|
.board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
|
|
@@ -10,6 +10,7 @@ import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
|
10
10
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
11
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
12
12
|
import { ReaderAuthControl } from "./reader-auth-control";
|
|
13
|
+
import { ProspectusAccountChooser, useProspectusAccountSummary } from "./prospectus-account-chooser";
|
|
13
14
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from "./ui/sheet";
|
|
14
15
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
|
|
15
16
|
import type { NewsroomOpsShellProps } from "./newsroom-ops-shell";
|
|
@@ -54,6 +55,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
|
|
|
54
55
|
const session = useOptionalNewsDeskClient();
|
|
55
56
|
const auth = session?.shell.auth ?? { status: "loading" as const, label: "Checking sign-in" };
|
|
56
57
|
const [open, setOpen] = useState(false);
|
|
58
|
+
const accountSummary = useProspectusAccountSummary();
|
|
57
59
|
useEffect(() => {
|
|
58
60
|
const media = matchMedia("(max-width: 900px)");
|
|
59
61
|
const close = () => setOpen(false);
|
|
@@ -62,11 +64,12 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
|
|
|
62
64
|
}, []);
|
|
63
65
|
return <DropdownMenu open={open} onOpenChange={setOpen}>
|
|
64
66
|
<DropdownMenuTrigger id={`organizer-account-trigger-${placement}`} className={`organizer-account organizer-account-${placement}`} aria-label="Account menu" title="Account menu">
|
|
65
|
-
<UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session"}</small></span>
|
|
67
|
+
<UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{accountSummary ?? (auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session")}</small></span>
|
|
66
68
|
</DropdownMenuTrigger>
|
|
67
69
|
<DropdownMenuContent className="organizer-account-popup" side={placement === "sidebar" ? "top" : "bottom"} align="end">
|
|
68
70
|
<DropdownMenuGroup>
|
|
69
71
|
<DropdownMenuLabel>{auth.label}</DropdownMenuLabel><DropdownMenuSeparator />
|
|
72
|
+
<ProspectusAccountChooser />
|
|
70
73
|
<DropdownMenuItem render={<Link href={getNewsroomNavHref(ORGANIZER_SETTINGS_DESTINATION.path)} />}><Settings aria-hidden="true" />{ORGANIZER_SETTINGS_DESTINATION.label}</DropdownMenuItem>
|
|
71
74
|
<ReaderAuthControl authState={auth} renderAction={({ disabled, onClick, signedIn }) => <DropdownMenuItem disabled={disabled} closeOnClick={false} onClick={onClick}>
|
|
72
75
|
{signedIn ? <LogOut aria-hidden="true" /> : <LogIn aria-hidden="true" />}{signedIn ? "Sign out" : "Sign in"}
|
|
@@ -265,3 +265,34 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
265
265
|
.news-desk-columns--taxonomy .category-steering-category-card button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;padding:4px 12px;border:2px solid var(--org-ink);border-radius:0;background:transparent;color:var(--org-ink);font:700 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.05em;text-transform:uppercase;cursor:pointer}
|
|
266
266
|
.news-desk-columns--taxonomy .category-steering-category-card button:disabled{border-color:var(--org-line);opacity:.6;cursor:default}
|
|
267
267
|
.news-desk-columns--taxonomy .news-desk-rail-column .category-steering-section__header{border-bottom:0;padding-bottom:4px}
|
|
268
|
+
.organizer-account-chooser{padding:8px 12px;border-bottom:1px solid var(--org-line);display:grid;gap:6px}
|
|
269
|
+
.organizer-account-chooser ul{list-style:none;margin:0;padding:0;display:grid}
|
|
270
|
+
.organizer-account-chooser li button{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;text-align:left;background:none;border:0;border-left:3px solid transparent;padding:6px 8px;font:inherit;color:var(--org-ink);cursor:pointer}
|
|
271
|
+
.organizer-account-chooser li button[data-selected]{border-left-color:var(--org-ink);font-weight:700}
|
|
272
|
+
.organizer-account-chooser li button:disabled{opacity:.6;cursor:default}
|
|
273
|
+
.organizer-account-chooser li button span{display:grid;gap:1px}
|
|
274
|
+
.organizer-account-chooser li button small,.organizer-account-chooser-note{font-size:12px;color:var(--org-ink);margin:0}
|
|
275
|
+
.organizer-account-chooser-heading{margin:0;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
|
|
276
|
+
.organizer-account-chooser-link{color:var(--org-ink);text-decoration:underline;text-underline-offset:3px}
|
|
277
|
+
[data-reference-cyclotron-status] .cyclotron-status[data-variant]{border-radius:0;background:transparent;border-top:1px solid var(--org-ink);border-bottom:1px solid var(--org-ink);padding-left:0;padding-right:0}
|
|
278
|
+
[data-reference-cyclotron-status] :is(.cyclotron-status-note,.cyclotron-status-definition,.cyclotron-status-header p,.cyclotron-status-override){color:var(--org-ink)}
|
|
279
|
+
[data-reference-cyclotron-status] .cyclotron-status[data-variant="strip"]>span+span::before{color:var(--org-ink)}
|
|
280
|
+
[data-reference-cyclotron-status] .cyclotron-status-bar{border-radius:0;background:transparent;border:1px solid var(--org-ink)}
|
|
281
|
+
[data-reference-cyclotron-status] .cyclotron-status-bar>span{opacity:1}
|
|
282
|
+
[data-reference-cyclotron-status] :is(.cyclotron-review-secondary,.cyclotron-review-choice,.cyclotron-review-submit){border-radius:0;opacity:1}
|
|
283
|
+
.news-desk-columns--administration .news-desk-delegation-note{margin:0;color:var(--administration-strong);max-width:68ch}
|
|
284
|
+
.news-desk-columns--administration .news-desk-delegation-form{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr) auto;gap:12px;align-items:end}
|
|
285
|
+
.news-desk-columns--administration .news-desk-delegation-error{margin:0;padding:8px 0 8px 12px;border-left:3px solid var(--org-ink);color:var(--administration-strong);font-weight:600}
|
|
286
|
+
.news-desk-columns--administration .news-desk-delegation-token{display:grid;gap:12px;min-width:0;padding:16px;border:2px solid var(--org-ink)}
|
|
287
|
+
.news-desk-columns--administration .news-desk-delegation-token h3{margin:0;font-size:20px}
|
|
288
|
+
.news-desk-columns--administration .news-desk-delegation-token p{margin:0}
|
|
289
|
+
.news-desk-columns--administration .news-desk-delegation-token__row{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch}
|
|
290
|
+
.news-desk-columns--administration .news-desk-delegation-token__row code{flex:1 1 280px;min-width:0;padding:8px 10px;border:1px solid var(--org-line);font:500 13px/1.5 ui-monospace,Menlo,monospace;overflow-wrap:anywhere;color:var(--administration-strong)}
|
|
291
|
+
.news-desk-columns--administration .news-desk-delegation-token__row button,.news-desk-columns--administration .news-desk-delegation-list button{min-height:36px;padding:4px 12px;border:1px solid var(--org-line);background:transparent;color:var(--administration-strong);font:600 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.05em;text-transform:uppercase;cursor:pointer}
|
|
292
|
+
.news-desk-columns--administration .news-desk-delegation-list{list-style:none;margin:0;padding:0;display:grid}
|
|
293
|
+
.news-desk-columns--administration .news-desk-delegation-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px 16px;align-items:center;padding:12px 0;border-top:1px solid var(--org-line)}
|
|
294
|
+
.news-desk-columns--administration .news-desk-delegation-list li>div{display:grid;gap:2px}
|
|
295
|
+
.news-desk-columns--administration .news-desk-delegation-list li[data-delegation-live=true] .news-desk-delegation-state{font-weight:700;text-decoration:underline;text-underline-offset:4px}
|
|
296
|
+
.news-desk-columns--administration .news-desk-delegation-state{font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;color:var(--administration-strong)}
|
|
297
|
+
.news-desk-columns--administration .news-desk-delegation-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
298
|
+
@container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-form{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-list li{grid-template-columns:minmax(0,1fr)}}
|
|
@@ -49,6 +49,15 @@ import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
|
49
49
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
50
50
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
51
51
|
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
52
|
+
import {
|
|
53
|
+
CountsAsTrainingLabelNote,
|
|
54
|
+
DelegatedCurationNote,
|
|
55
|
+
ReferenceSuggestionBlock,
|
|
56
|
+
ReferenceSuggestionProvenance,
|
|
57
|
+
ReferenceSuggestionRowLine,
|
|
58
|
+
} from "./reference-cyclotron-suggestion";
|
|
59
|
+
import { ReferenceLiveAgreement } from "./reference-live-agreement";
|
|
60
|
+
import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
52
61
|
import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
|
|
53
62
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
54
63
|
import type { PrimingRequestView } from "../lib/relevance-status";
|
|
@@ -75,6 +84,12 @@ type NewsroomReferencesViewProps = {
|
|
|
75
84
|
demo?: boolean;
|
|
76
85
|
/** The relevance cyclotron's current decisions, keyed by reference lineage. */
|
|
77
86
|
decisions?: Map<string, RelevanceDecisionView>;
|
|
87
|
+
/** Reference lineage ids whose latest curation Message was made by a person. */
|
|
88
|
+
humanCuratedReferenceLineageIds?: Set<string>;
|
|
89
|
+
/** Delegated curations by reference lineage. */
|
|
90
|
+
delegatedCurations?: Map<string, DelegatedCuration>;
|
|
91
|
+
/** The live-agreement payload; the strip is hidden without it. */
|
|
92
|
+
shadowAgreement?: ShadowAgreement | null;
|
|
78
93
|
disabled?: boolean;
|
|
79
94
|
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
|
|
80
95
|
initialReferenceLineageId?: string | null;
|
|
@@ -212,17 +227,14 @@ function useReferencePreviewAttachments(
|
|
|
212
227
|
return previewAttachments;
|
|
213
228
|
}
|
|
214
229
|
|
|
215
|
-
function decisionSummary(decision: RelevanceDecisionView): string {
|
|
216
|
-
const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
217
|
-
return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
230
|
function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
|
|
221
231
|
return Boolean(decision?.reviewRecommended)
|
|
222
232
|
&& normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
function ReferenceDetailPanel({
|
|
236
|
+
countsAsTrainingLabel,
|
|
237
|
+
delegated,
|
|
226
238
|
decision,
|
|
227
239
|
demo,
|
|
228
240
|
disabled,
|
|
@@ -232,6 +244,8 @@ function ReferenceDetailPanel({
|
|
|
232
244
|
reference,
|
|
233
245
|
onClose,
|
|
234
246
|
}: {
|
|
247
|
+
countsAsTrainingLabel?: boolean;
|
|
248
|
+
delegated?: DelegatedCuration;
|
|
235
249
|
decision?: RelevanceDecisionView;
|
|
236
250
|
demo?: boolean;
|
|
237
251
|
disabled?: boolean;
|
|
@@ -267,25 +281,39 @@ function ReferenceDetailPanel({
|
|
|
267
281
|
) : null}
|
|
268
282
|
</div>
|
|
269
283
|
|
|
270
|
-
{decision
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
{decision ? (
|
|
285
|
+
<div className="space-y-3" data-reference-relevance-decision={lineageId}>
|
|
286
|
+
<ReferenceSuggestionBlock
|
|
287
|
+
decision={decision}
|
|
288
|
+
disabled={disabled}
|
|
289
|
+
onCurate={onRelevanceReview}
|
|
290
|
+
referenceLineageId={lineageId}
|
|
291
|
+
settled={status !== "pending"}
|
|
292
|
+
statusLabel={referenceStatusLabel(status)}
|
|
293
|
+
/>
|
|
294
|
+
<ReferenceSuggestionProvenance decision={decision} />
|
|
295
|
+
{status === "pending" && onRelevanceReview ? (
|
|
296
|
+
<details className="text-sm text-foreground">
|
|
297
|
+
<summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">Review with a reason</summary>
|
|
298
|
+
<div className="mt-2">
|
|
299
|
+
<ReferenceRelevanceReview
|
|
300
|
+
decision={decision}
|
|
301
|
+
disabled={disabled}
|
|
302
|
+
onCurate={onRelevanceReview}
|
|
303
|
+
referenceLineageId={lineageId}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
</details>
|
|
307
|
+
) : null}
|
|
308
|
+
</div>
|
|
281
309
|
) : null}
|
|
310
|
+
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
282
311
|
|
|
283
312
|
<div
|
|
284
313
|
className="flex flex-wrap items-center gap-2"
|
|
285
314
|
data-news-desk-reference-curation-cluster
|
|
286
315
|
data-reference-curation-status={status}
|
|
287
316
|
>
|
|
288
|
-
{decision && status === "pending" && onRelevanceReview ? null : <>
|
|
289
317
|
<Button
|
|
290
318
|
aria-label="Accept reference"
|
|
291
319
|
aria-pressed={status === "accepted"}
|
|
@@ -312,7 +340,6 @@ function ReferenceDetailPanel({
|
|
|
312
340
|
<ThumbsDownIcon className="size-4" />
|
|
313
341
|
Reject
|
|
314
342
|
</Button>
|
|
315
|
-
</>}
|
|
316
343
|
<Button
|
|
317
344
|
aria-label="Archive reference"
|
|
318
345
|
disabled={archiveDisabled}
|
|
@@ -409,6 +436,9 @@ export function NewsroomReferencesView({
|
|
|
409
436
|
cyclotronStatus,
|
|
410
437
|
cyclotronPriming,
|
|
411
438
|
decisions,
|
|
439
|
+
humanCuratedReferenceLineageIds,
|
|
440
|
+
delegatedCurations,
|
|
441
|
+
shadowAgreement = null,
|
|
412
442
|
onRequestReviewRate,
|
|
413
443
|
demo = false,
|
|
414
444
|
disabled = false,
|
|
@@ -523,6 +553,7 @@ export function NewsroomReferencesView({
|
|
|
523
553
|
Review source intake before it becomes accepted evidence.
|
|
524
554
|
</p>
|
|
525
555
|
</div>
|
|
556
|
+
<ReferenceLiveAgreement agreement={shadowAgreement} />
|
|
526
557
|
<ReferenceCurationControls disabled={disabled} />
|
|
527
558
|
{priming && !disabled ? <ReferencePriming {...priming} /> : null}
|
|
528
559
|
{cyclotronPriming && !disabled ? (
|
|
@@ -595,12 +626,12 @@ export function NewsroomReferencesView({
|
|
|
595
626
|
</div>
|
|
596
627
|
<p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
|
|
597
628
|
{decision ? (
|
|
598
|
-
<
|
|
599
|
-
<
|
|
629
|
+
<div className="flex flex-wrap items-center gap-2" data-reference-relevance-row={lineageId}>
|
|
630
|
+
<ReferenceSuggestionRowLine decision={decision} referenceLineageId={lineageId} />
|
|
600
631
|
{needsReview(reference, decision) ? (
|
|
601
632
|
<Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
|
|
602
633
|
) : null}
|
|
603
|
-
</
|
|
634
|
+
</div>
|
|
604
635
|
) : null}
|
|
605
636
|
</button>
|
|
606
637
|
);
|
|
@@ -650,6 +681,8 @@ export function NewsroomReferencesView({
|
|
|
650
681
|
<ScrollArea className="min-h-0 flex-1">
|
|
651
682
|
{selectedReference ? (
|
|
652
683
|
<ReferenceDetailPanel
|
|
684
|
+
countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
|
|
685
|
+
delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
|
|
653
686
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
654
687
|
demo={demo}
|
|
655
688
|
disabled={disabled}
|
|
@@ -681,6 +714,8 @@ export function NewsroomReferencesView({
|
|
|
681
714
|
{selectedReference ? (
|
|
682
715
|
<ScrollArea className="max-h-[75dvh]">
|
|
683
716
|
<ReferenceDetailPanel
|
|
717
|
+
countsAsTrainingLabel={humanCuratedReferenceLineageIds?.has(referenceLineageId(selectedReference))}
|
|
718
|
+
delegated={delegatedCurations?.get(referenceLineageId(selectedReference))}
|
|
684
719
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
685
720
|
demo={demo}
|
|
686
721
|
disabled={disabled}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
|
+
import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
|
|
7
|
+
import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
8
|
+
|
|
9
|
+
function previewAccountState(): ProspectusAccountState {
|
|
10
|
+
return {
|
|
11
|
+
status: "signedIn",
|
|
12
|
+
publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
|
|
13
|
+
selectedAccountId: getLocalPreviewTenantId(),
|
|
14
|
+
cyclotronLabAccess: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ACCOUNT_STATE_EVENT = "papyrus:prospectus-account-state";
|
|
19
|
+
|
|
20
|
+
function summaryOf(state: ProspectusAccountState): string | null {
|
|
21
|
+
if (state.status !== "signedIn") return null;
|
|
22
|
+
const selected = state.publishingAccounts.find(account => account.accountId === state.selectedAccountId);
|
|
23
|
+
return selected ? `${selected.name ?? selected.accountId} · ${roleLabel(selected.role)}` : "Choose an account";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const roleLabel = (role: string) => role.replace(/_/g, " ");
|
|
27
|
+
|
|
28
|
+
/** The signed-in person's current publishing account as "Name · role", or null when the site has no Prospectus accounts. */
|
|
29
|
+
export function useProspectusAccountSummary(): string | null {
|
|
30
|
+
const [summary, setSummary] = useState<string | null>(null);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
const returnPath = `${window.location.pathname}${window.location.search}`;
|
|
34
|
+
(isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath)).then(loaded => {
|
|
35
|
+
if (!cancelled) setSummary(summaryOf(loaded));
|
|
36
|
+
});
|
|
37
|
+
const onChange = (event: Event) => setSummary(summaryOf((event as CustomEvent<ProspectusAccountState>).detail));
|
|
38
|
+
window.addEventListener(ACCOUNT_STATE_EVENT, onChange);
|
|
39
|
+
return () => { cancelled = true; window.removeEventListener(ACCOUNT_STATE_EVENT, onChange); };
|
|
40
|
+
}, []);
|
|
41
|
+
return summary;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The publishing account the signed-in person is acting as. It appears only on sites that have Prospectus
|
|
46
|
+
* accounts configured; a site with its own user pool never shows it. Choosing an account records the choice
|
|
47
|
+
* in the sign-in session and reloads so every page reads the new account.
|
|
48
|
+
*/
|
|
49
|
+
export function ProspectusAccountChooser() {
|
|
50
|
+
const [state, setState] = useState<ProspectusAccountState>({ status: "unavailable" });
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
const returnPath = `${window.location.pathname}${window.location.search}`;
|
|
56
|
+
(isLocalPreviewEnabled() ? Promise.resolve(previewAccountState()) : loadProspectusAccountState(returnPath))
|
|
57
|
+
.then(loaded => { if (!cancelled) setState(loaded); });
|
|
58
|
+
return () => { cancelled = true; };
|
|
59
|
+
}, []);
|
|
60
|
+
if (state.status === "unavailable") return null;
|
|
61
|
+
if (state.status === "signedOut") {
|
|
62
|
+
return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-out">
|
|
63
|
+
<a className="organizer-account-chooser-link" href={state.signInUrl}>Sign in with Prospectus</a>
|
|
64
|
+
</div>;
|
|
65
|
+
}
|
|
66
|
+
const choose = async (accountId: string) => {
|
|
67
|
+
if (accountId === state.selectedAccountId || pending) return;
|
|
68
|
+
setPending(accountId); setError(null);
|
|
69
|
+
if (isLocalPreviewEnabled()) {
|
|
70
|
+
setLocalPreviewTenantId(accountId);
|
|
71
|
+
window.location.reload();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const result = await selectProspectusAccount(accountId);
|
|
75
|
+
setPending(null);
|
|
76
|
+
if ("error" in result) { setError(result.error); return; }
|
|
77
|
+
window.location.reload();
|
|
78
|
+
};
|
|
79
|
+
return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-in">
|
|
80
|
+
<p className="organizer-account-chooser-heading">Publishing account</p>
|
|
81
|
+
{state.publishingAccounts.length === 0
|
|
82
|
+
? <p className="organizer-account-chooser-note">No publishing accounts yet.</p>
|
|
83
|
+
: <ul role="listbox" aria-label="Publishing account">
|
|
84
|
+
{state.publishingAccounts.map(account => {
|
|
85
|
+
const selected = account.accountId === state.selectedAccountId;
|
|
86
|
+
return <li key={account.accountId} role="presentation">
|
|
87
|
+
<button aria-selected={selected} data-selected={selected || undefined} disabled={pending !== null} onClick={() => { void choose(account.accountId); }} role="option" type="button">
|
|
88
|
+
<span><strong>{account.name ?? account.accountId}</strong><small>{roleLabel(account.role)}</small></span>
|
|
89
|
+
{selected ? <Check size={14} aria-label="Selected" /> : null}
|
|
90
|
+
</button>
|
|
91
|
+
</li>;
|
|
92
|
+
})}
|
|
93
|
+
</ul>}
|
|
94
|
+
{!state.selectedAccountId && state.publishingAccounts.length > 0 ? <p className="organizer-account-chooser-note">Choose the account you are working as.</p> : null}
|
|
95
|
+
{state.cyclotronLabAccess ? <p className="organizer-account-chooser-note" data-cyclotron-lab-access>Cyclotron lab access is on for you.</p> : null}
|
|
96
|
+
{error ? <p className="organizer-account-chooser-note" role="alert">{error}</p> : null}
|
|
97
|
+
</div>;
|
|
98
|
+
}
|
|
@@ -48,14 +48,14 @@ export function ReferenceCyclotronStatus({
|
|
|
48
48
|
<section aria-label="Relevance cyclotron" className="space-y-2" data-reference-cyclotron-status>
|
|
49
49
|
<CyclotronStatusView status={status} />
|
|
50
50
|
{stale ? (
|
|
51
|
-
<p className="text-xs text-
|
|
51
|
+
<p className="text-xs text-foreground" data-reference-cyclotron-status-stale>
|
|
52
52
|
<span aria-hidden="true">⚠ </span>
|
|
53
53
|
Snapshot from {Math.round(ageHours)} hours ago; the next sweep refreshes it.
|
|
54
54
|
</p>
|
|
55
55
|
) : null}
|
|
56
56
|
<button
|
|
57
57
|
aria-expanded={open}
|
|
58
|
-
className="text-xs text-
|
|
58
|
+
className="text-xs text-foreground underline underline-offset-4"
|
|
59
59
|
onClick={() => setOpen((value) => !value)}
|
|
60
60
|
type="button"
|
|
61
61
|
>
|
|
@@ -75,7 +75,7 @@ export function ReferenceCyclotronStatus({
|
|
|
75
75
|
}}
|
|
76
76
|
>
|
|
77
77
|
<label className="flex flex-col gap-1">
|
|
78
|
-
<span className="text-xs text-
|
|
78
|
+
<span className="text-xs text-foreground">Review rate for confident decisions</span>
|
|
79
79
|
<select aria-label="Manual review rate" onChange={(event) => setRate(Number(event.target.value))} value={rate}>
|
|
80
80
|
{RATE_CHOICES.map((choice) => (
|
|
81
81
|
<option key={choice} value={choice}>{Math.round(choice * 100)}%</option>
|
|
@@ -83,7 +83,7 @@ export function ReferenceCyclotronStatus({
|
|
|
83
83
|
</select>
|
|
84
84
|
</label>
|
|
85
85
|
<label className="flex flex-col gap-1">
|
|
86
|
-
<span className="text-xs text-
|
|
86
|
+
<span className="text-xs text-foreground">For</span>
|
|
87
87
|
<select aria-label="How long the manual rate lasts" onChange={(event) => setDays(Number(event.target.value))} value={days}>
|
|
88
88
|
{DAY_CHOICES.map((choice) => (
|
|
89
89
|
<option key={choice} value={choice}>{choice} day{choice === 1 ? "" : "s"}</option>
|
|
@@ -96,7 +96,7 @@ export function ReferenceCyclotronStatus({
|
|
|
96
96
|
Clear manual rate
|
|
97
97
|
</button>
|
|
98
98
|
) : null}
|
|
99
|
-
<p className="basis-full text-xs text-
|
|
99
|
+
<p className="basis-full text-xs text-foreground">
|
|
100
100
|
Low-confidence decisions and the random audit share are always reviewed.
|
|
101
101
|
</p>
|
|
102
102
|
{message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
|