@anthusai/papyrus 5.43.0-next.7 → 5.43.0-next.8

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.
@@ -3,7 +3,7 @@
3
3
  * the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
4
4
  *
5
5
  * Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
6
- * what happens next without the reader, and only a hard gate says "Waiting for you".
6
+ * what happens next without the reader, and only a hard gate says "Waiting for you". One decision vocabulary: Accept or Reject.
7
7
  */
8
8
  import type { ReferenceRecord } from "./category-repository";
9
9
  import { getNewsroomNavHref } from "./newsroom-nav";
@@ -16,9 +16,11 @@ export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
16
16
  export type ReviewDecision = "approve" | "acknowledge" | "reject" | "feedback";
17
17
  export type ReviewSteer = "adjust" | "not_this_kind";
18
18
 
19
- export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
19
+ /** One AI draft's answer inside a batch request: Accept or Reject, an optional why, and whether the editor called it the wrong kind of story. */
20
+ export type DraftDecision = { choice: "accept" | "reject"; title?: string; why?: string | null; notThisKind?: boolean; by: string; at: string };
21
+ export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string; drafts?: (DraftDecision & { id: string })[] };
20
22
  export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
21
- export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
23
+ export type ReviewLink = { label?: string; url?: string; type?: string; id?: string; decision?: DraftDecision };
22
24
 
23
25
  export type ReviewRequest = {
24
26
  id: string;
@@ -84,7 +86,11 @@ export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string,
84
86
  return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
85
87
  }
86
88
 
87
- export type AnswerInput = { decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null };
89
+ export type AnswerInput = {
90
+ decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null;
91
+ /** A batch of AI drafts: each draft's decision, recorded on the answer and on the request's links. */
92
+ drafts?: (DraftDecision & { id: string })[]; links?: ReviewLink[];
93
+ };
88
94
 
89
95
  /** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
90
96
  export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
@@ -94,9 +100,12 @@ export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: str
94
100
  const steer = input.steer ?? null;
95
101
  if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
96
102
  const at = now.toISOString();
97
- const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
103
+ const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at, ...(input.drafts?.length ? { drafts: input.drafts } : {}) };
98
104
  const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
99
- return { id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
105
+ return {
106
+ id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history),
107
+ ...(input.links ? { links: JSON.stringify(input.links) } : {}),
108
+ };
100
109
  }
101
110
 
102
111
  // --- words ------------------------------------------------------------------------------------------------
@@ -112,30 +121,49 @@ export function modeLabel(mode: ReviewMode): string {
112
121
  return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
113
122
  }
114
123
 
115
- /** A mark and the words, never colour alone. */
116
- export function modeMark(mode: ReviewMode): string {
117
- return mode === "hard_gate" ? "■" : mode === "soft_review" ? "◐" : "⚑";
124
+ /** The proposal ids a request is about: its proposal links, or its subject when that is a proposal. Empty for every other request. */
125
+ export function draftIdsOf(request: Pick<ReviewRequest, "subject" | "links">): string[] {
126
+ const linked = request.links.filter((link) => link.type === "proposal" && link.id).map((link) => String(link.id));
127
+ const ids = linked.length ? linked : request.subject.type === "proposal" ? [request.subject.id] : [];
128
+ return [...new Set(ids)];
129
+ }
130
+
131
+ /** A review of AI drafts: each draft is shown and decided in the card. Flags are never drafts to decide. */
132
+ export function isDraftReview(request: Pick<ReviewRequest, "mode" | "subject" | "links">): boolean {
133
+ return request.mode !== "flag" && draftIdsOf(request).length > 0;
118
134
  }
119
135
 
120
136
  /** What happens next without the reader. Only a hard gate says "Waiting for you". */
121
137
  export function nextText(request: ReviewRequest): string {
138
+ const drafts = isDraftReview(request);
122
139
  if (request.state === "open") {
123
- if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
124
- if (request.mode === "soft_review") return request.deadline ? `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.` : "Stays open until someone handles it. It never proceeds or expires on its own.";
140
+ if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you accept or reject.";
141
+ if (request.mode === "soft_review") {
142
+ if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The drafts stay in Proposals.` : "No deadline. The drafts stay in Proposals until an editor decides them.";
143
+ return request.deadline ? `If nobody answers by ${formatWhen(request.deadline)}, this closes and ${request.raisedBy} carries on without a review.` : "No deadline. Stays open until someone answers it.";
144
+ }
125
145
  return "Nothing is waiting on this. It stays here until you acknowledge it.";
126
146
  }
127
- if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
147
+ if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The drafts stay in Proposals.` : `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
128
148
  if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
129
149
  const answer = request.answer;
130
150
  return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
131
151
  }
132
152
 
133
153
  export function answerWords(answer: ReviewAnswer, request?: Pick<ReviewRequest, "mode">): string {
134
- if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
154
+ if (answer.drafts?.length) {
155
+ const accepted = answer.drafts.filter((draft) => draft.choice === "accept").length;
156
+ const rejected = answer.drafts.length - accepted;
157
+ const noun = (count: number) => `${count} ${count === 1 ? "draft" : "drafts"}`;
158
+ if (!rejected) return `Accepted ${noun(accepted)}`;
159
+ if (!accepted) return `Rejected ${noun(rejected)}`;
160
+ return `Accepted ${noun(accepted)} and rejected ${rejected}`;
161
+ }
135
162
  if (answer.decision === "acknowledge") return "Acknowledged";
136
- if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Approved";
137
- if (answer.decision === "reject") return "Rejected";
138
- return "Steered with a note";
163
+ if (answer.decision === "approve") return request?.mode === "flag" ? "Acknowledged" : "Accepted";
164
+ if (answer.decision === "reject") return answer.steer === "not_this_kind" ? "Rejected as not this kind of thing" : "Rejected";
165
+ if (answer.steer === "not_this_kind") return "Rejected as not this kind of thing";
166
+ return "Answered with a note";
139
167
  }
140
168
 
141
169
  /** "You steered X, applied in run Y": what became of a person's input. */
@@ -153,11 +181,16 @@ export function subjectLabel(subject: { type: string; id: string }, titles: Read
153
181
  return `${subject.type.replace("_", " ")} ${subject.id}`;
154
182
  }
155
183
 
184
+ /** The Proposals panel, opened on one proposal. */
185
+ export function proposalHref(proposalId: string, demo = false): string {
186
+ return getNewsroomNavHref(`/newsroom/administration/proposals?proposal=${encodeURIComponent(proposalId)}`, demo);
187
+ }
188
+
156
189
  export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
157
190
  const id = encodeURIComponent(subject.id);
158
191
  if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
159
192
  if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
160
- if (subject.type === "proposal") return getNewsroomNavHref(`/newsroom/topics?proposal=${id}`, demo);
193
+ if (subject.type === "proposal") return proposalHref(subject.id, demo);
161
194
  if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
162
195
  return null;
163
196
  }
@@ -210,6 +243,9 @@ export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: R
210
243
 
211
244
  export type NotificationSections = {
212
245
  hardGates: ReviewRequest[];
246
+ /** Open soft reviews of AI drafts (each draft is decided in its card). */
247
+ draftBatches: ReviewRequest[];
248
+ /** Every other open soft review. */
213
249
  softReviews: ReviewRequest[];
214
250
  flags: ReviewRequest[];
215
251
  /** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
@@ -221,26 +257,30 @@ export function sectionReviews(requests: ReviewRequest[]): NotificationSections
221
257
  const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
222
258
  // Requests with a deadline first, soonest first; requests with no deadline after them, oldest first.
223
259
  const bySoonestDeadline = (a: ReviewRequest, b: ReviewRequest) => (a.deadline && b.deadline ? a.deadline.localeCompare(b.deadline) : a.deadline ? -1 : b.deadline ? 1 : byRaised(a, b));
260
+ const soft = open.filter((request) => request.mode === "soft_review");
224
261
  return {
225
262
  hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
226
- softReviews: open.filter((request) => request.mode === "soft_review").sort(bySoonestDeadline),
263
+ draftBatches: soft.filter(isDraftReview).sort(bySoonestDeadline),
264
+ softReviews: soft.filter((request) => !isDraftReview(request)).sort(bySoonestDeadline),
227
265
  flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
228
266
  decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
229
267
  };
230
268
  }
231
269
 
232
- export type NotificationSummary = { waiting: number; proceedingSoon: number; decidedBySystem: number; unread: number };
270
+ export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
233
271
 
234
272
  /**
235
- * "N waiting for you · N proceeding soon · N recently decided by the system".
236
- * Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). Proceeding soon = open soft reviews.
273
+ * "N waiting for you · N AI drafts to review · N other reviews open · N recently decided by the system".
274
+ * Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). `draftsToDecide` is the number of
275
+ * drafts in open draft reviews that nobody has decided yet.
237
276
  */
238
- export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
239
- return { waiting: sections.hardGates.length + held.length, proceedingSoon: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
277
+ export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number, draftsToDecide?: number): NotificationSummary {
278
+ const drafts = draftsToDecide ?? sections.draftBatches.reduce((total, request) => total + draftIdsOf(request).length, 0);
279
+ return { waiting: sections.hardGates.length + held.length, drafts, otherReviews: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
240
280
  }
241
281
 
242
282
  export function summaryText(summary: NotificationSummary): string {
243
- return `${summary.waiting} waiting for you · ${summary.proceedingSoon} proceeding soon · ${summary.decidedBySystem} recently decided by the system`;
283
+ return `${summary.waiting} waiting for you · ${summary.drafts} AI ${summary.drafts === 1 ? "draft" : "drafts"} to review · ${summary.otherReviews} other ${summary.otherReviews === 1 ? "review" : "reviews"} open · ${summary.decidedBySystem} recently decided by the system`;
244
284
  }
245
285
 
246
286
  export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
package/package.json CHANGED
@@ -293,6 +293,7 @@
293
293
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
294
294
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
295
295
  "./lib/proposals-admin": "./lib/proposals-admin.ts",
296
+ "./lib/proposals-client": "./lib/proposals-client.ts",
296
297
  "./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
297
298
  "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
298
299
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
@@ -325,6 +326,7 @@
325
326
  "./lib/revalidate-prewarm": "./lib/revalidate-prewarm.ts",
326
327
  "./lib/revalidate-secret": "./lib/revalidate-secret.ts",
327
328
  "./lib/review-client": "./lib/review-client.ts",
329
+ "./lib/review-drafts": "./lib/review-drafts.ts",
328
330
  "./lib/review-fixture": "./lib/review-fixture.ts",
329
331
  "./lib/review-requests": "./lib/review-requests.ts",
330
332
  "./lib/robots-policy": "./lib/robots-policy.ts",
@@ -468,7 +470,7 @@
468
470
  "optional": true
469
471
  }
470
472
  },
471
- "version": "5.43.0-next.7",
473
+ "version": "5.43.0-next.8",
472
474
  "dependencies": {
473
475
  "@aws-amplify/adapter-nextjs": "^1.7.3",
474
476
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.43.0-next.7",
2
+ "version": "5.43.0-next.8",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",