@anthusai/papyrus 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +70 -3
- package/amplify/data/resource.ts +75 -3
- package/amplify/data/schema.js +67 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/category-action/handler.ts +190 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- 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/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +0 -1
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +45 -0
- package/components/curation-delegation-client.ts +81 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +32 -220
- package/components/newsroom-article-editor.tsx +135 -39
- package/components/newsroom-assignment-detail.tsx +35 -1
- package/components/newsroom-board-view.tsx +29 -7
- package/components/newsroom-newsprint-board.css +9 -2
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +25 -7
- package/components/newsroom-organizer.css +218 -4
- package/components/newsroom-references-view.tsx +56 -21
- package/components/priming-proof-page.tsx +17 -8
- 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/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +203 -674
- package/components/ui/sheet.tsx +1 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -3
- package/lib/content-item-lifecycle.ts +4 -2
- 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/front-matter-fields.ts +42 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +174 -3
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-board.ts +4 -0
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +5 -12
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/relevance-decisions.ts +29 -0
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +9 -0
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/wiki.ts +56 -0
- package/package.json +21 -1
- package/routes.manifest.json +23 -7
|
@@ -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}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
curationForSuggestionChoice,
|
|
5
|
+
type DelegatedCuration,
|
|
6
|
+
provenanceSummary,
|
|
7
|
+
suggestionConfidenceWords,
|
|
8
|
+
type SuggestionChoice,
|
|
9
|
+
} from "../lib/cyclotron-suggestions";
|
|
10
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
11
|
+
|
|
12
|
+
const RULE_BUTTON_CLASS =
|
|
13
|
+
"border border-foreground px-3 py-1 text-sm font-semibold text-foreground underline-offset-4 hover:underline disabled:cursor-not-allowed disabled:opacity-100 disabled:line-through";
|
|
14
|
+
|
|
15
|
+
/** UTC and fixed-format so server and browser render the same words. */
|
|
16
|
+
export function formatSuggestionTime(instant: string | null): string {
|
|
17
|
+
if (!instant) return "time not recorded";
|
|
18
|
+
const parsed = new Date(instant);
|
|
19
|
+
if (Number.isNaN(parsed.getTime())) return "time not recorded";
|
|
20
|
+
return `${parsed.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The one-line suggestion in a queue row. Never worded as a decision. */
|
|
24
|
+
export function ReferenceSuggestionRowLine({
|
|
25
|
+
decision,
|
|
26
|
+
referenceLineageId,
|
|
27
|
+
}: {
|
|
28
|
+
decision: RelevanceDecisionView;
|
|
29
|
+
referenceLineageId: string;
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<p className="mt-1 text-xs text-foreground" data-reference-suggestion-row={referenceLineageId}>
|
|
33
|
+
<span className="font-semibold">Suggestion:</span> {decision.label}, {suggestionConfidenceWords(decision)}
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The detail's Suggestion block. Agree and Overrule go through the ordinary
|
|
40
|
+
* accept and reject actions; nothing changes until an editor clicks.
|
|
41
|
+
*/
|
|
42
|
+
export function ReferenceSuggestionBlock({
|
|
43
|
+
decision,
|
|
44
|
+
disabled,
|
|
45
|
+
onCurate,
|
|
46
|
+
referenceLineageId,
|
|
47
|
+
statusLabel,
|
|
48
|
+
settled,
|
|
49
|
+
}: {
|
|
50
|
+
decision: RelevanceDecisionView;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
onCurate?: (curation: RelevanceCuration) => void;
|
|
53
|
+
referenceLineageId: string;
|
|
54
|
+
statusLabel: string;
|
|
55
|
+
settled: boolean;
|
|
56
|
+
}) {
|
|
57
|
+
const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
|
|
58
|
+
const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
|
|
59
|
+
return (
|
|
60
|
+
<section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
|
|
61
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
|
|
62
|
+
<p className="text-sm text-foreground">
|
|
63
|
+
The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
|
|
64
|
+
</p>
|
|
65
|
+
<p className="text-xs text-foreground" data-reference-suggestion-notice>
|
|
66
|
+
A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
|
|
67
|
+
</p>
|
|
68
|
+
{!settled && onCurate ? (
|
|
69
|
+
<div className="flex flex-wrap gap-2">
|
|
70
|
+
<button
|
|
71
|
+
className={RULE_BUTTON_CLASS}
|
|
72
|
+
data-reference-suggestion-agree
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
onClick={() => choose("agree")}
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
Agree ({suggestsAccept ? "accept" : "reject"})
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
className={RULE_BUTTON_CLASS}
|
|
81
|
+
data-reference-suggestion-overrule
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
onClick={() => choose("overrule")}
|
|
84
|
+
type="button"
|
|
85
|
+
>
|
|
86
|
+
Overrule ({suggestsAccept ? "reject" : "accept"})
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The expandable "How this suggestion was made" block, from the recorded provenance. */
|
|
95
|
+
export function ReferenceSuggestionProvenance({ decision }: { decision: RelevanceDecisionView }) {
|
|
96
|
+
const summary = provenanceSummary(decision);
|
|
97
|
+
return (
|
|
98
|
+
<details className="text-sm text-foreground" data-reference-suggestion-provenance>
|
|
99
|
+
<summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
|
|
100
|
+
{summary ? (
|
|
101
|
+
<ul className="mt-2 space-y-1">
|
|
102
|
+
<li>{summary.teachingLabelsSentence}.</li>
|
|
103
|
+
<li>{summary.primingExamplesSentence}.</li>
|
|
104
|
+
<li>Doctrine fingerprint {summary.doctrineShortHash ?? "not recorded"}.</li>
|
|
105
|
+
<li>Model {summary.modelVersion ?? "version not recorded"}.</li>
|
|
106
|
+
<li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
|
|
107
|
+
</ul>
|
|
108
|
+
) : (
|
|
109
|
+
<p className="mt-2">This suggestion was made before the cyclotron recorded how. Only its model version and confidence are known.</p>
|
|
110
|
+
)}
|
|
111
|
+
</details>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Shown only when the latest curation of the reference was made by a signed-in person. */
|
|
116
|
+
export function CountsAsTrainingLabelNote({ referenceLineageId }: { referenceLineageId: string }) {
|
|
117
|
+
return (
|
|
118
|
+
<p className="border-l-2 border-foreground pl-2 text-xs font-semibold text-foreground" data-reference-training-label={referenceLineageId}>
|
|
119
|
+
Counts as a training label: an editor made this decision, so the cyclotron learns from it.
|
|
120
|
+
</p>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Shown on a record an agent decided for a person under a delegation. The
|
|
126
|
+
* backend stamps it human, so it counts as that person's training label.
|
|
127
|
+
*/
|
|
128
|
+
export function DelegatedCurationNote({ delegated, referenceLineageId }: { delegated: DelegatedCuration; referenceLineageId: string }) {
|
|
129
|
+
return (
|
|
130
|
+
<section aria-label="Delegated decision" className="space-y-1 border-l-2 border-foreground pl-2 text-sm text-foreground" data-reference-delegated-curation={referenceLineageId}>
|
|
131
|
+
<p className="font-semibold">
|
|
132
|
+
Decided by {delegated.agent} on behalf of {delegated.person ?? "a person"}
|
|
133
|
+
</p>
|
|
134
|
+
{delegated.instruction ? <blockquote className="italic">“{delegated.instruction}”</blockquote> : null}
|
|
135
|
+
<p className="text-xs">Delegation {delegated.delegationShortId || "id not recorded"}.</p>
|
|
136
|
+
<p className="text-xs font-semibold" data-reference-training-label={referenceLineageId}>
|
|
137
|
+
{delegated.countsAsTrainingLabel
|
|
138
|
+
? `Counts as a training label: the record is stamped as ${delegated.person ?? "the person"}'s own decision, so the cyclotron learns from it.`
|
|
139
|
+
: "Does not count as a training label."}
|
|
140
|
+
</p>
|
|
141
|
+
</section>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
agreementStripState,
|
|
5
|
+
formatPercentage,
|
|
6
|
+
dailyCapWords,
|
|
7
|
+
loopStateWords,
|
|
8
|
+
type AgreementStripMeasurement,
|
|
9
|
+
type ShadowAgreement,
|
|
10
|
+
} from "../lib/cyclotron-suggestions";
|
|
11
|
+
import { formatSuggestionTime } from "./reference-cyclotron-suggestion";
|
|
12
|
+
|
|
13
|
+
function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasurement }) {
|
|
14
|
+
const figures: Array<[string, string]> = [
|
|
15
|
+
["Agreement", formatPercentage(measurement.agreement)],
|
|
16
|
+
["Precision", formatPercentage(measurement.precision)],
|
|
17
|
+
["Recall", formatPercentage(measurement.recall)],
|
|
18
|
+
["Comparisons", String(measurement.comparisons)],
|
|
19
|
+
];
|
|
20
|
+
return (
|
|
21
|
+
<dl className="flex flex-wrap gap-x-6 gap-y-1">
|
|
22
|
+
{figures.map(([name, value]) => (
|
|
23
|
+
<div key={name}>
|
|
24
|
+
<dt className="text-xs font-semibold uppercase tracking-wide text-foreground">{name}</dt>
|
|
25
|
+
<dd className="text-base font-semibold text-foreground">{value}</dd>
|
|
26
|
+
</div>
|
|
27
|
+
))}
|
|
28
|
+
</dl>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Live agreement at the top of References: how often the cyclotron's
|
|
34
|
+
* suggestions matched what editors then decided. Hidden without a payload.
|
|
35
|
+
*/
|
|
36
|
+
export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreement | null }) {
|
|
37
|
+
const state = agreementStripState(agreement);
|
|
38
|
+
if (state.kind === "hidden") return null;
|
|
39
|
+
return (
|
|
40
|
+
<section aria-label="Live agreement" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
|
|
41
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Live agreement</h3>
|
|
42
|
+
{state.loop ? (
|
|
43
|
+
<p className="text-sm text-foreground" data-reference-live-agreement-loop>
|
|
44
|
+
<span className="font-semibold">{loopStateWords(state.loop)}</span>
|
|
45
|
+
{" · "}Last sweep {formatSuggestionTime(state.loop.lastSweepAt)}
|
|
46
|
+
{" · "}{dailyCapWords(state.loop)}
|
|
47
|
+
</p>
|
|
48
|
+
) : null}
|
|
49
|
+
{state.stopReasonSentence ? (
|
|
50
|
+
<p className="text-sm font-semibold text-foreground" data-reference-live-agreement-stopped>{state.stopReasonSentence}</p>
|
|
51
|
+
) : null}
|
|
52
|
+
{state.kind === "not_enough" ? (
|
|
53
|
+
<p className="text-sm text-foreground" data-reference-live-agreement-not-enough>
|
|
54
|
+
Not enough comparisons yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
|
|
55
|
+
</p>
|
|
56
|
+
) : <MeasurementFigures measurement={state.measurement} />}
|
|
57
|
+
<p className="text-xs text-foreground">
|
|
58
|
+
Suggestions compared with the decisions editors made afterwards. Computed {formatSuggestionTime(agreement?.computedAt ?? null)}.
|
|
59
|
+
</p>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { Schema } from "../amplify/data/resource";
|
|
5
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
|
+
import { createLocalPreviewReportingTrail } from "../lib/local-preview-seed";
|
|
7
|
+
import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailAssignment, type TrailAttachment, type TrailReference, type TrailThread, type TrailVersion } from "../lib/story-reporting-trail";
|
|
8
|
+
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
9
|
+
|
|
10
|
+
type GraphqlResponse = { data?: Record<string, { items?: Record<string, unknown>[] | null; nextToken?: string | null } | null> | null; errors?: { message?: string }[] | null };
|
|
11
|
+
type GraphqlClient = { graphql: (options: Record<string, unknown>) => Promise<GraphqlResponse> };
|
|
12
|
+
|
|
13
|
+
const USER_POOL = "userPool";
|
|
14
|
+
const PAGE_LIMIT = 200;
|
|
15
|
+
|
|
16
|
+
async function listAll(client: GraphqlClient, field: string, selection: string, variableDeclarations: string, fieldArguments: string, variables: Record<string, unknown>): Promise<Record<string, unknown>[]> {
|
|
17
|
+
const rows: Record<string, unknown>[] = [];
|
|
18
|
+
let nextToken: string | null = null;
|
|
19
|
+
do {
|
|
20
|
+
const response: GraphqlResponse = await client.graphql({
|
|
21
|
+
query: `query ($limit: Int, $nextToken: String${variableDeclarations}) { ${field}(limit: $limit, nextToken: $nextToken${fieldArguments}) { items { ${selection} } nextToken } }`,
|
|
22
|
+
variables: { ...variables, limit: PAGE_LIMIT, nextToken },
|
|
23
|
+
authMode: USER_POOL,
|
|
24
|
+
});
|
|
25
|
+
if (response.errors?.length) throw new Error(response.errors.map(error => error.message ?? "GraphQL error").join("; "));
|
|
26
|
+
const connection = response.data?.[field];
|
|
27
|
+
rows.push(...((connection?.items ?? []).filter(Boolean) as Record<string, unknown>[]));
|
|
28
|
+
nextToken = connection?.nextToken ?? null;
|
|
29
|
+
} while (nextToken);
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const text = (value: unknown): string | null => typeof value === "string" ? value : null;
|
|
34
|
+
|
|
35
|
+
function parseMetadata(value: unknown): Record<string, unknown> {
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
try { const parsed = JSON.parse(value); return typeof parsed === "object" && parsed ? parsed as Record<string, unknown> : {}; } catch { return {}; }
|
|
38
|
+
}
|
|
39
|
+
return typeof value === "object" && value ? value as Record<string, unknown> : {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getReference(client: GraphqlClient, referenceId: string): Promise<Record<string, unknown> | null> {
|
|
43
|
+
try {
|
|
44
|
+
const response = await client.graphql({ query: "query ($id: ID!) { getReference(id: $id) { id title sourceUri } }", variables: { id: referenceId }, authMode: USER_POOL });
|
|
45
|
+
return (response.data?.getReference as unknown as Record<string, unknown> | null) ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads the whole reporting trail of one story from the backend; the local preview serves a snapshot instead. */
|
|
52
|
+
export async function loadStoryReportingTrail(lineageId: string, itemId: string): Promise<StoryReportingTrail> {
|
|
53
|
+
if (isLocalPreviewEnabled()) return createLocalPreviewReportingTrail(lineageId);
|
|
54
|
+
configureAmplifyClient();
|
|
55
|
+
const client = generateClient<Schema>() as unknown as GraphqlClient;
|
|
56
|
+
const [versionRows, assignmentRows, attachmentRows, relationRows] = await Promise.all([
|
|
57
|
+
listAll(client, "listItemsByLineageAndVersion", "id versionNumber versionState status stage headline title changeReason versionCreatedBy versionCreatedAt updatedAt", ", $lineageId: ID!", ", lineageId: $lineageId", { lineageId }).catch(() => []),
|
|
58
|
+
listAll(client, "listAssignmentsByItemLineageAndCreatedAt", "id title assignmentTypeKey status assigneeKey summary createdAt", ", $itemLineageId: ID!", ", itemLineageId: $itemLineageId", { itemLineageId: lineageId }).catch(() => []),
|
|
59
|
+
listAll(client, "listModelAttachmentsByOwnerRoleAndSortKey", "id role filename mediaType byteSize", ", $ownerId: ID!", ", ownerId: $ownerId", { ownerId: itemId }).catch(() => []),
|
|
60
|
+
listAll(client, "listSemanticRelations", "id predicate subjectKind subjectLineageId objectKind objectId objectLineageId metadata", ", $filter: ModelSemanticRelationFilterInput", ", filter: $filter", { filter: { or: [{ subjectLineageId: { eq: lineageId } }, { objectLineageId: { eq: lineageId } }] } }).catch(() => []),
|
|
61
|
+
]);
|
|
62
|
+
const versions: TrailVersion[] = versionRows.map(row => ({
|
|
63
|
+
id: String(row.id), versionNumber: Number(row.versionNumber ?? 0), versionState: text(row.versionState), status: text(row.status), stage: text(row.stage),
|
|
64
|
+
headline: text(row.headline) ?? text(row.title), changeReason: text(row.changeReason), versionCreatedBy: text(row.versionCreatedBy), versionCreatedAt: text(row.versionCreatedAt), updatedAt: text(row.updatedAt),
|
|
65
|
+
})).sort((left, right) => right.versionNumber - left.versionNumber);
|
|
66
|
+
const assignments: TrailAssignment[] = await Promise.all(assignmentRows.map(async row => {
|
|
67
|
+
const eventRows = await listAll(client, "listAssignmentEventsByAssignmentAndCreatedAt", "id eventType fromStatus toStatus actorLabel note createdAt", ", $assignmentId: ID!", ", assignmentId: $assignmentId", { assignmentId: row.id }).catch(() => []);
|
|
68
|
+
return {
|
|
69
|
+
id: String(row.id), title: String(row.title ?? ""), assignmentTypeKey: String(row.assignmentTypeKey ?? ""), status: String(row.status ?? ""), assigneeKey: text(row.assigneeKey), summary: text(row.summary), createdAt: String(row.createdAt ?? ""),
|
|
70
|
+
events: eventRows.map(event => ({ id: String(event.id), eventType: String(event.eventType ?? ""), fromStatus: text(event.fromStatus), toStatus: text(event.toStatus), actorLabel: text(event.actorLabel), note: text(event.note), createdAt: String(event.createdAt ?? "") })),
|
|
71
|
+
};
|
|
72
|
+
}));
|
|
73
|
+
const anchorFilters = [{ primaryAnchorLineageId: { eq: lineageId } }, ...assignments.map(assignment => ({ primaryAnchorId: { eq: assignment.id } }))];
|
|
74
|
+
const threadRows = await listAll(client, "listMessageThreads", "id title threadKind summary", ", $filter: ModelMessageThreadFilterInput", ", filter: $filter", { filter: { or: anchorFilters } }).catch(() => []);
|
|
75
|
+
const threads: TrailThread[] = await Promise.all(threadRows.map(async row => {
|
|
76
|
+
const messageRows = await listAll(client, "listMessagesByThreadAndSequence", "id messageKind authorLabel summary content createdAt sequenceNumber", ", $threadId: ID!", ", threadId: $threadId", { threadId: row.id }).catch(() => []);
|
|
77
|
+
return { id: String(row.id), title: text(row.title), threadKind: text(row.threadKind), summary: text(row.summary), messages: messageRows.map(message => ({ id: String(message.id), sequenceNumber: typeof message.sequenceNumber === "number" ? message.sequenceNumber : null, messageKind: text(message.messageKind), authorLabel: text(message.authorLabel), summary: text(message.summary), content: text(message.content), createdAt: String(message.createdAt ?? "") })) };
|
|
78
|
+
}));
|
|
79
|
+
const referenceRelations = relationRows.filter(relation => relation.predicate === "uses_evidence" && relation.subjectLineageId === lineageId && relation.objectKind === "reference");
|
|
80
|
+
const references: TrailReference[] = await Promise.all(referenceRelations.map(async relation => {
|
|
81
|
+
const metadata = parseMetadata(relation.metadata);
|
|
82
|
+
const referenceRow = await getReference(client, String(relation.objectId));
|
|
83
|
+
return {
|
|
84
|
+
relationId: String(relation.id), predicate: String(relation.predicate ?? ""), referenceId: String(relation.objectId), referenceLineageId: text(relation.objectLineageId),
|
|
85
|
+
title: text(referenceRow?.title), sourceUri: text(referenceRow?.sourceUri),
|
|
86
|
+
role: metadata.role === "cited" ? "cited" as const : "consulted" as const, note: text(metadata.note), citationKey: text(metadata.citationKey), addedBy: text(metadata.addedBy), addedAt: text(metadata.addedAt),
|
|
87
|
+
};
|
|
88
|
+
}));
|
|
89
|
+
const attachments: TrailAttachment[] = attachmentRows.map(row => ({ id: String(row.id), role: text(row.role), filename: text(row.filename), mediaType: text(row.mediaType), byteSize: typeof row.byteSize === "number" ? row.byteSize : null }));
|
|
90
|
+
return { ...EMPTY_STORY_REPORTING_TRAIL, versions, assignments, threads, references, attachments };
|
|
91
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
6
|
+
import { storyMessageFeed, storyReportingTrailIsEmpty, type StoryReportingTrail, type TrailReference } from "../lib/story-reporting-trail";
|
|
7
|
+
import { loadStoryReportingTrail } from "./story-reporting-trail-client";
|
|
8
|
+
|
|
9
|
+
const timestampFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
|
|
10
|
+
|
|
11
|
+
function formatTimestamp(value: string | null | undefined): string {
|
|
12
|
+
if (!value) return "";
|
|
13
|
+
const parsed = new Date(value);
|
|
14
|
+
return Number.isNaN(parsed.getTime()) ? value : timestampFormat.format(parsed);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ReferenceSummary({ citedKeys, demo, focused, reference }: { citedKeys: readonly string[]; demo: boolean; focused: boolean; reference: TrailReference }) {
|
|
18
|
+
return <>
|
|
19
|
+
<div className="story-trail-row">
|
|
20
|
+
<strong>{reference.title ?? reference.referenceId}</strong>
|
|
21
|
+
<span className="story-trail-chip" data-story-trail-reference-role={reference.role}>{reference.role === "cited" ? "Cited" : "Consulted"}</span>
|
|
22
|
+
{reference.citationKey && citedKeys.includes(reference.citationKey) && <span className="story-trail-chip">In the text</span>}
|
|
23
|
+
</div>
|
|
24
|
+
<div className="story-trail-meta">{[reference.addedBy && `added by ${reference.addedBy}`, formatTimestamp(reference.addedAt), reference.citationKey && `citation key ${reference.citationKey}`].filter(Boolean).join(" · ")}</div>
|
|
25
|
+
{reference.note && <p data-story-trail-reference-summary={focused ? "focused" : undefined}>{reference.note}</p>}
|
|
26
|
+
{reference.sourceUri && <div className="story-trail-meta">{reference.sourceUri}</div>}
|
|
27
|
+
{reference.referenceLineageId && <Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.referenceLineageId)}`, demo)}>Open the full reference page</Link>}
|
|
28
|
+
</>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function TrailSection({ count, children, title }: { count: number; children: React.ReactNode; title: string }) {
|
|
32
|
+
return <section className="story-trail-section" data-story-trail-section={title.toLowerCase()}>
|
|
33
|
+
<h3>{title} <small>{count}</small></h3>
|
|
34
|
+
{count ? children : <p className="story-trail-empty">None recorded.</p>}
|
|
35
|
+
</section>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The messages and references of one story: draft versions, the assignments and agent events that did the work,
|
|
40
|
+
* notes and packets, the references behind it, and attached files. Container queries lay the sections out
|
|
41
|
+
* in one column in a narrow Board panel and side by side on a wide page.
|
|
42
|
+
*/
|
|
43
|
+
export function StoryReportingTrailView({ citedKeys = [], demo = false, focusedReferenceId = null, itemId, lineageId, onOpenVersion, onShowReference, view = "messages" }: { citedKeys?: readonly string[]; demo?: boolean; focusedReferenceId?: string | null; itemId: string; lineageId: string | null | undefined; onOpenVersion?: (versionId: string) => void; onShowReference?: (referenceId: string) => void; view?: "messages" | "references" }) {
|
|
44
|
+
const [trail, setTrail] = useState<StoryReportingTrail | null>(null);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
setTrail(null); setError(null);
|
|
49
|
+
loadStoryReportingTrail(lineageId || itemId, itemId)
|
|
50
|
+
.then(loaded => { if (!cancelled) setTrail(loaded); })
|
|
51
|
+
.catch((reason: unknown) => { if (!cancelled) setError(reason instanceof Error ? reason.message : "Could not load the reporting trail."); });
|
|
52
|
+
return () => { cancelled = true; };
|
|
53
|
+
}, [itemId, lineageId]);
|
|
54
|
+
if (error) return <p role="alert" className="story-trail-empty">{error}</p>;
|
|
55
|
+
if (!trail) return <p className="story-trail-empty">Loading the reporting trail…</p>;
|
|
56
|
+
const messageFeed = storyMessageFeed(trail);
|
|
57
|
+
if (view === "references") return <div className="story-trail" data-story-trail data-story-trail-view="references">
|
|
58
|
+
<TrailSection count={trail.references.length} title="References">
|
|
59
|
+
<ul className="story-trail-list" data-story-references-list>
|
|
60
|
+
{trail.references.map(reference => <li data-story-reference-entry={reference.referenceId} data-story-reference-focused={reference.referenceId === focusedReferenceId ? "true" : undefined} key={reference.relationId} ref={reference.referenceId === focusedReferenceId ? node => node?.scrollIntoView({ block: "nearest" }) : undefined}>
|
|
61
|
+
<ReferenceSummary citedKeys={citedKeys} demo={demo} focused={reference.referenceId === focusedReferenceId} reference={reference} />
|
|
62
|
+
</li>)}
|
|
63
|
+
</ul>
|
|
64
|
+
</TrailSection>
|
|
65
|
+
</div>;
|
|
66
|
+
return <div className="story-trail" data-story-trail data-story-trail-view="messages">
|
|
67
|
+
{storyReportingTrailIsEmpty(trail) && <p className="story-trail-empty">No messages have been recorded for this story yet. Notes and packets, the work done on it, its draft versions and the references behind it appear here as agents and editors work on it.</p>}
|
|
68
|
+
<TrailSection count={messageFeed.length} title="Messages">
|
|
69
|
+
<ol className="story-trail-list" data-story-trail-feed>
|
|
70
|
+
{messageFeed.map(entry => entry.entryKind === "reference"
|
|
71
|
+
? <li data-story-trail-feed-reference={entry.reference.referenceId} key={entry.id}>
|
|
72
|
+
<div className="story-trail-row"><span className="story-trail-chip">Reference found</span><span className="story-trail-meta">{formatTimestamp(entry.createdAt)}</span></div>
|
|
73
|
+
<ReferenceSummary citedKeys={citedKeys} demo={demo} focused={false} reference={entry.reference} />
|
|
74
|
+
{onShowReference && <button className="newsroom-article-link-button" data-story-trail-show-reference={entry.reference.referenceId} onClick={() => onShowReference(entry.reference.referenceId)} type="button">Show in the References list</button>}
|
|
75
|
+
</li>
|
|
76
|
+
: <li key={entry.id}>
|
|
77
|
+
<div className="story-trail-row"><strong>{entry.authorLabel ?? "Unknown author"}</strong>{entry.messageKind && <span className="story-trail-chip">{entry.messageKind.replace(/_/g, " ")}</span>}<span className="story-trail-meta">{formatTimestamp(entry.createdAt)}</span></div>
|
|
78
|
+
{entry.summary && <p><strong>{entry.summary}</strong></p>}
|
|
79
|
+
{entry.content && <details className="story-trail-message" open={messageFeed.length <= 3}><summary>Show the message</summary><pre>{entry.content}</pre></details>}
|
|
80
|
+
</li>)}
|
|
81
|
+
</ol>
|
|
82
|
+
</TrailSection>
|
|
83
|
+
<TrailSection count={trail.versions.length} title="Draft versions">
|
|
84
|
+
<ol className="story-trail-list">
|
|
85
|
+
{trail.versions.map((version, versionIndex) => <li key={version.id}>
|
|
86
|
+
<div className="story-trail-row"><strong>Version {version.versionNumber}</strong>{versionIndex === 0 && <span className="story-trail-chip">Latest</span>}<span className="story-trail-chip">{version.versionState ?? "draft"}</span>{version.stage && <span className="story-trail-chip">{version.stage}</span>}</div>
|
|
87
|
+
<div className="story-trail-meta">{[formatTimestamp(version.versionCreatedAt ?? version.updatedAt), version.versionCreatedBy, version.changeReason].filter(Boolean).join(" · ")}</div>
|
|
88
|
+
{onOpenVersion
|
|
89
|
+
? <button className="newsroom-article-link-button" data-story-trail-open-version={version.versionNumber} onClick={() => onOpenVersion(version.id)} type="button">{versionIndex === 0 ? "Open the latest version" : "Open this older version (read-only)"}</button>
|
|
90
|
+
: <Link href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(version.id)}`, demo)}>{versionIndex === 0 ? "Open the latest version" : "Open this older version (read-only)"}</Link>}
|
|
91
|
+
</li>)}
|
|
92
|
+
</ol>
|
|
93
|
+
</TrailSection>
|
|
94
|
+
<TrailSection count={trail.assignments.length} title="Work">
|
|
95
|
+
<ol className="story-trail-list">
|
|
96
|
+
{trail.assignments.map(assignment => <li key={assignment.id}>
|
|
97
|
+
<div className="story-trail-row"><strong>{assignment.title}</strong> <span className="story-trail-chip">{assignment.status}</span></div>
|
|
98
|
+
<div className="story-trail-meta">{[assignment.assignmentTypeKey, assignment.assigneeKey, formatTimestamp(assignment.createdAt)].filter(Boolean).join(" · ")}</div>
|
|
99
|
+
{assignment.summary && <p>{assignment.summary}</p>}
|
|
100
|
+
{assignment.events.length > 0 && <ol className="story-trail-events">
|
|
101
|
+
{assignment.events.map(event => <li key={event.id}>
|
|
102
|
+
<span className="story-trail-meta">{formatTimestamp(event.createdAt)}</span> {event.eventType}{event.toStatus ? ` → ${event.toStatus}` : ""}{event.actorLabel ? ` by ${event.actorLabel}` : ""}{event.note ? `: ${event.note}` : ""}
|
|
103
|
+
</li>)}
|
|
104
|
+
</ol>}
|
|
105
|
+
<Link href={getNewsroomNavHref(`/newsroom/assignments/${encodeURIComponent(assignment.id)}`, demo)}>Open assignment</Link>
|
|
106
|
+
</li>)}
|
|
107
|
+
</ol>
|
|
108
|
+
</TrailSection>
|
|
109
|
+
<TrailSection count={trail.attachments.length} title="Files">
|
|
110
|
+
<ul className="story-trail-list">
|
|
111
|
+
{trail.attachments.map(attachment => <li key={attachment.id}><strong>{attachment.filename ?? attachment.id}</strong> <span className="story-trail-meta">{[attachment.role, attachment.mediaType].filter(Boolean).join(" · ")}</span></li>)}
|
|
112
|
+
</ul>
|
|
113
|
+
</TrailSection>
|
|
114
|
+
</div>;
|
|
115
|
+
}
|