@anthusai/papyrus 5.42.0-next.2 → 5.42.0-next.3

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.
Files changed (34) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/globals.css +2 -35
  22. package/app/tailwind.css +2 -0
  23. package/components/monitoring-overview-view.tsx +7 -5
  24. package/components/monitoring-panel.tsx +33 -3
  25. package/components/newsroom-references-view.tsx +2 -3
  26. package/components/proposals-panel.tsx +110 -0
  27. package/components/proposals-view.tsx +93 -0
  28. package/components/topic-steering-workspace.tsx +13 -2
  29. package/lib/monitoring-client.ts +11 -2
  30. package/lib/proposals-admin.ts +115 -0
  31. package/lib/relevance-decisions.ts +9 -0
  32. package/package.json +4 -1
  33. package/routes.manifest.json +1 -1
  34. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -0,0 +1,93 @@
1
+ import { useMemo } from "react";
2
+ import {
3
+ countProposals,
4
+ filterProposals,
5
+ kindLabel,
6
+ proposalMoves,
7
+ proposerLabel,
8
+ sortProposals,
9
+ stateLabel,
10
+ type ProposalFilter,
11
+ type ProposalMove,
12
+ type ProposalRow,
13
+ } from "../lib/proposals-admin";
14
+
15
+ const FILTERS: Array<{ key: ProposalFilter; label: string }> = [
16
+ { key: "open", label: "Open" },
17
+ { key: "draft", label: "Drafts" },
18
+ { key: "in_steering", label: "In steering" },
19
+ { key: "editor_review", label: "Editor review" },
20
+ { key: "decided", label: "Decided" },
21
+ ];
22
+
23
+ export type ProposalsViewProps = {
24
+ rows: ProposalRow[];
25
+ /** True when the site has the reader-steering plugin; without it nothing reader-facing is shown. */
26
+ readers: boolean;
27
+ filter: ProposalFilter;
28
+ busy: boolean;
29
+ status: string;
30
+ reason: string;
31
+ onFilter: (filter: ProposalFilter) => void;
32
+ onReason: (reason: string) => void;
33
+ onMove: (proposalId: string, move: ProposalMove) => void;
34
+ onRefresh: () => void;
35
+ };
36
+
37
+ export function ProposalsView(props: ProposalsViewProps) {
38
+ const sorted = useMemo(() => sortProposals(props.rows), [props.rows]);
39
+ const counts = useMemo(() => countProposals(sorted), [sorted]);
40
+ const shown = filterProposals(sorted, props.filter);
41
+ return (
42
+ <section aria-labelledby="newsroom-proposals-title" data-news-desk-admin-panel="proposals">
43
+ <h3 id="newsroom-proposals-title">Proposals</h3>
44
+ <p>Story drafts and other changes that AI proposes. Editors pin, hold and decide them. Only editors and admins can see this.</p>
45
+ <p role="tablist" aria-label="Proposal views">
46
+ {FILTERS.filter((entry) => props.readers || entry.key !== "in_steering").map((entry) => (
47
+ <span key={entry.key}>
48
+ <button type="button" role="tab" aria-selected={props.filter === entry.key} onClick={() => props.onFilter(entry.key)}>
49
+ {entry.label} ({counts[entry.key]})
50
+ </button>{" "}
51
+ </span>
52
+ ))}
53
+ <button type="button" onClick={props.onRefresh} disabled={props.busy}>Refresh</button>
54
+ </p>
55
+ <label>
56
+ Reason for the next move (optional){" "}
57
+ <input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
58
+ </label>
59
+ <p role="status" aria-live="polite">{props.status}</p>
60
+ {shown.length === 0 ? (
61
+ <p>Nothing here.</p>
62
+ ) : (
63
+ <ul>
64
+ {shown.map((row) => (
65
+ <li key={row.id} data-proposal-row={row.id}>
66
+ <article aria-label={row.title}>
67
+ <p>
68
+ <strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
69
+ {row.held ? ", on hold" : ""}) from {proposerLabel(row.proposer)} <time dateTime={row.createdAt}>{row.createdAt}</time>
70
+ </p>
71
+ {row.summary ? <p>{row.summary}</p> : null}
72
+ {row.body ? (
73
+ <details>
74
+ <summary>Read the draft</summary>
75
+ <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
76
+ </details>
77
+ ) : null}
78
+ <p>
79
+ {proposalMoves(row, props.readers).map((move) => (
80
+ <span key={move.label}>
81
+ <button type="button" disabled={props.busy} onClick={() => props.onMove(row.id, move)}>{move.label}</button>{" "}
82
+ </span>
83
+ ))}
84
+ </p>
85
+ </article>
86
+ </li>
87
+ ))}
88
+ </ul>
89
+ )}
90
+ </section>
91
+ );
92
+ }
93
+
@@ -17,6 +17,7 @@ import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
17
  import { AutomationPanel } from "./automation-panel";
18
18
  import { MonitoringPanel } from "./monitoring-panel";
19
19
  import { ModerationPanel } from "./moderation-panel";
20
+ import { ProposalsPanel } from "./proposals-panel";
20
21
  import { NewsletterPanel } from "./newsletter-panel";
21
22
  import { SignupEntriesPanel } from "./signup-entries-panel";
22
23
  import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
@@ -256,7 +257,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
256
257
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
257
258
  type AssignmentDeskViewMode = "queue" | "budget";
258
259
  type UserRoleAction = "grant" | "revoke";
259
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
260
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "proposals" | "editorial-checks" | "monitoring";
260
261
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
261
262
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
262
263
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -4398,6 +4399,15 @@ function AdministrationDeskView({
4398
4399
  <span>Subscribers, issues and delivery for the site&apos;s email</span>
4399
4400
  </Link>
4400
4401
  ) : null}
4402
+ <Link
4403
+ href={administrationPanelHref("proposals", isDemo)}
4404
+ data-news-desk-admin-nav="proposals"
4405
+ data-active={administrationPanel === "proposals" || undefined}
4406
+ onClick={() => onPanelChange("proposals")}
4407
+ >
4408
+ <strong>Proposals</strong>
4409
+ <span>Story drafts and changes that AI proposes</span>
4410
+ </Link>
4401
4411
  {readerPluginEnabled() ? (
4402
4412
  <Link
4403
4413
  href={administrationPanelHref("moderation", isDemo)}
@@ -4413,6 +4423,7 @@ function AdministrationDeskView({
4413
4423
  <div className="news-desk-settings-panel">
4414
4424
  {administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
4415
4425
  {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4426
+ {administrationPanel === "proposals" ? <ProposalsPanel /> : null}
4416
4427
  {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4417
4428
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4418
4429
  {administrationPanel === "automations" ? <AutomationPanel isDemo={isDemo} /> : null}
@@ -12890,7 +12901,7 @@ function readerPluginEnabled(): boolean {
12890
12901
  }
12891
12902
 
12892
12903
  function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12893
- if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12904
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring" || value === "proposals") return value;
12894
12905
  if (value === "signups" && signupPluginEnabled()) return value;
12895
12906
  if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12896
12907
  if (value === "moderation" && readerPluginEnabled()) return value;
@@ -10,7 +10,7 @@ import type {
10
10
  MonitoringResult,
11
11
  } from "./monitoring-overview";
12
12
 
13
- type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences";
13
+ type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
14
14
  type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
15
15
 
16
16
  async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
@@ -74,10 +74,19 @@ export async function importMonitoringEmailCandidates(
74
74
  } catch {
75
75
  return { imported: references.length, references, error: "The import response could not be read. Completed links are safe to retry." };
76
76
  }
77
- if (!response?.ok || !Array.isArray(response.references)) {
77
+ if (!response?.ok || !Array.isArray(response.references) || response.references.length !== batch.length) {
78
78
  return { imported: references.length, references, error: "The import was not accepted. Check your editor access and retry." };
79
79
  }
80
80
  references.push(...response.references);
81
+ for (const [index, reference] of response.references.entries()) {
82
+ const queued = await query<{ queued: boolean }>("monitoringQueueEmailReference",
83
+ `mutation MonitoringQueueEmailReference($input: AWSJSON!) { monitoringQueueEmailReference(input: $input) }`,
84
+ { input: JSON.stringify({ receiptId, ...batch[index], referenceId: reference.id }) });
85
+ if (!("queued" in queued) || !queued.queued) {
86
+ return { imported: references.length, references,
87
+ error: "References were registered, but predictions could not be queued. Retry these selected links safely." };
88
+ }
89
+ }
81
90
  }
82
91
  return { imported: references.length, references };
83
92
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The data side of the Administration "Proposals" panel, which every site has (the queue is core; the reader-steering
3
+ * plugin only adds reader votes). Rows come from the staff-readable `Proposal` model; an editor moves one with the
4
+ * `transitionProposal` mutation. Pure functions, no network, so the rules the panel shows can be tested.
5
+ */
6
+
7
+ export type ProposalRow = {
8
+ id: string;
9
+ kind: string;
10
+ title: string;
11
+ summary?: string | null;
12
+ body?: string | null;
13
+ state: string;
14
+ proposer?: string | null;
15
+ held?: boolean | null;
16
+ pinned?: boolean | null;
17
+ createdAt: string;
18
+ };
19
+
20
+ export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review" | "decided";
21
+ /** A button: the states it moves through in order (one call each) or, for pin and hold, the `editProposal` action it runs. */
22
+ export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
23
+
24
+ const STATE_LABELS: Record<string, string> = {
25
+ draft: "Draft",
26
+ in_steering: "In steering",
27
+ editor_review: "Editor review",
28
+ approved: "Approved",
29
+ declined: "Declined",
30
+ overridden_by_editor: "Decided by an editor",
31
+ };
32
+
33
+ const KIND_LABELS: Record<string, string> = {
34
+ story_draft: "Story draft",
35
+ new_concept: "New concept",
36
+ rename: "Rename",
37
+ merge: "Merge",
38
+ split: "Split",
39
+ new_column: "New column",
40
+ element_symbol: "Element symbol",
41
+ };
42
+
43
+ const DECIDED = new Set(["approved", "declined", "overridden_by_editor"]);
44
+
45
+ /**
46
+ * What an editor may do next; the function enforces the same table (overriding the community needs a reason and is not offered
47
+ * here). On a site without reader-steering nobody steers, so a draft goes straight to editor review (two moves) and the
48
+ * "in steering" wording never shows.
49
+ */
50
+ function stateMoves(state: string, readers: boolean): ProposalMove[] {
51
+ if (state === "draft") {
52
+ return readers
53
+ ? [{ label: "Send to steering", path: ["in_steering"] }, { label: "Decline", path: ["declined"] }]
54
+ : [{ label: "Move to editor review", path: ["in_steering", "editor_review"] }, { label: "Decline", path: ["declined"] }];
55
+ }
56
+ if (state === "in_steering") return [{ label: "Move to editor review", path: ["editor_review"] }];
57
+ if (state === "editor_review") return [{ label: "Approve", path: ["approved"] }, { label: "Decline", path: ["declined"] }];
58
+ return [];
59
+ }
60
+
61
+ export const PROPOSAL_FIELDS = "id kind title summary body state proposer held pinned createdAt";
62
+ export const LIST_PROPOSALS = `query Proposals($nextToken: String) { listProposals(limit: 500, nextToken: $nextToken) { items { ${PROPOSAL_FIELDS} } nextToken } }`;
63
+ export const EDIT_PROPOSAL = `mutation Edit($proposalId: String!, $action: String!) { editProposal(proposalId: $proposalId, action: $action) { status id error message } }`;
64
+ export const TRANSITION_PROPOSAL = `mutation Move($proposalId: String!, $state: String!, $reason: String) { transitionProposal(proposalId: $proposalId, state: $state, reason: $reason) { status id error message } }`;
65
+
66
+ export function stateLabel(state: string, readers = true): string {
67
+ if (!readers && state === "in_steering") return "Waiting for review";
68
+ return STATE_LABELS[state] ?? state;
69
+ }
70
+
71
+ export function kindLabel(kind: string): string {
72
+ return KIND_LABELS[kind] ?? kind;
73
+ }
74
+
75
+ /** AI-filed proposals say "AI"; a reader's proposal says "Reader" (their id is never shown). */
76
+ export function proposerLabel(proposer: string | null | undefined): string {
77
+ return proposer === "ai" ? "AI" : proposer ? "Reader" : "Unknown";
78
+ }
79
+
80
+ export function proposalMoves(row: Pick<ProposalRow, "state" | "held" | "pinned">, readers = true): ProposalMove[] {
81
+ const edits: ProposalMove[] = [
82
+ { label: row.pinned ? "Unpin" : "Pin", edit: row.pinned ? "unpin" : "pin" },
83
+ ...(DECIDED.has(row.state) && !row.held ? [] : [{ label: row.held ? "Release hold" : "Hold", edit: (row.held ? "release" : "hold") as "release" | "hold" }]),
84
+ ];
85
+ // A proposal on hold refuses every state change until it is released.
86
+ return [...(row.held ? [] : stateMoves(row.state, readers)), ...edits];
87
+ }
88
+
89
+ export function sortProposals(rows: ProposalRow[]): ProposalRow[] {
90
+ return [...rows].sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
91
+ }
92
+
93
+ export function filterProposals(rows: ProposalRow[], filter: ProposalFilter): ProposalRow[] {
94
+ if (filter === "open") return rows.filter((row) => !DECIDED.has(row.state));
95
+ if (filter === "decided") return rows.filter((row) => DECIDED.has(row.state));
96
+ return rows.filter((row) => row.state === filter);
97
+ }
98
+
99
+ export function countProposals(rows: ProposalRow[]): Record<ProposalFilter, number> {
100
+ return {
101
+ open: filterProposals(rows, "open").length,
102
+ draft: filterProposals(rows, "draft").length,
103
+ in_steering: filterProposals(rows, "in_steering").length,
104
+ editor_review: filterProposals(rows, "editor_review").length,
105
+ decided: filterProposals(rows, "decided").length,
106
+ };
107
+ }
108
+
109
+ export type ProposalReceipt = { status?: string | null; id?: string | null; error?: string | null; message?: string | null };
110
+
111
+ export function proposalReceiptMessage(receipt: ProposalReceipt | null | undefined): string {
112
+ if (!receipt) return "The action did not answer.";
113
+ if (receipt.status === "ok") return "Done.";
114
+ return receipt.message || receipt.error || "The action was refused.";
115
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SemanticRelationRecord } from "./category-repository";
2
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
2
3
 
3
4
  export const RELEVANCE_RELATION = "relevance_decision_is";
4
5
 
@@ -162,3 +163,11 @@ export function curationForRelevanceReview(decision: RelevanceDecisionView, revi
162
163
  shareable: review.shareable,
163
164
  };
164
165
  }
166
+
167
+ /**
168
+ * A pending reference with a current cyclotron suggestion is waiting for an editor's answer,
169
+ * whether or not the cyclotron flagged the decision itself for review.
170
+ */
171
+ export function awaitsEditorReview(curationStatus: unknown, decision: RelevanceDecisionView | undefined): boolean {
172
+ return Boolean(decision) && normalizeReferenceCurationStatus(curationStatus) === "pending";
173
+ }
package/package.json CHANGED
@@ -127,6 +127,8 @@
127
127
  "./components/presentation-header": "./components/presentation-header.tsx",
128
128
  "./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
129
129
  "./components/priming-proof-page": "./components/priming-proof-page.tsx",
130
+ "./components/proposals-panel": "./components/proposals-panel.tsx",
131
+ "./components/proposals-view": "./components/proposals-view.tsx",
130
132
  "./components/prospectus-account-chooser": "./components/prospectus-account-chooser.tsx",
131
133
  "./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
132
134
  "./components/reader-auth-control": "./components/reader-auth-control.tsx",
@@ -289,6 +291,7 @@
289
291
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
290
292
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
291
293
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
294
+ "./lib/proposals-admin": "./lib/proposals-admin.ts",
292
295
  "./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
293
296
  "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
294
297
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
@@ -463,7 +466,7 @@
463
466
  "optional": true
464
467
  }
465
468
  },
466
- "version": "5.42.0-next.2",
469
+ "version": "5.42.0-next.3",
467
470
  "dependencies": {
468
471
  "@aws-amplify/adapter-nextjs": "^1.7.3",
469
472
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.42.0-next.2",
2
+ "version": "5.42.0-next.3",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",