@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.
@@ -28,6 +28,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
28
28
  const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
29
29
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
30
30
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
31
+ const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
32
+ const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
33
+ const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
31
34
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
32
35
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
33
36
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
@@ -211,6 +214,58 @@ export function resolveStagingDomainName(config) {
211
214
  export function resolveStackName(config) {
212
215
  return config.stackName ?? `amplify-app-shell-${config.siteId}`;
213
216
  }
217
+ /** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
218
+ export function resolveChatStackName(config) {
219
+ if (!config.chat?.enabled)
220
+ return undefined;
221
+ return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
222
+ }
223
+ export function resolveChatLogGroupPrefix(config) {
224
+ return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
225
+ }
226
+ /** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
227
+ export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"];
228
+ export const CHAT_ENVIRONMENT_NAMES = {
229
+ MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
230
+ ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
231
+ SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
232
+ TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
233
+ TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
234
+ };
235
+ export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
236
+ export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
237
+ export function chatExportName(stackName, resource) {
238
+ return `${stackName}:${resource}`;
239
+ }
240
+ function parseChat(value, cms) {
241
+ if (value === undefined)
242
+ return undefined;
243
+ const record = requireObject(value, "chat");
244
+ rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
245
+ if (typeof record.enabled !== "boolean")
246
+ fail("chat.enabled", "must be a boolean");
247
+ const chat = {
248
+ enabled: record.enabled,
249
+ modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
250
+ stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
251
+ logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
252
+ workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
253
+ };
254
+ for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"]) {
255
+ if (chat[key] === undefined)
256
+ delete chat[key];
257
+ }
258
+ if (chat.enabled && cms === undefined)
259
+ fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
260
+ if (chat.enabled && cms) {
261
+ for (const key of Object.keys(cms.environment)) {
262
+ if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
263
+ fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
264
+ }
265
+ }
266
+ }
267
+ return chat;
268
+ }
214
269
  export function resolveStoragePreviewPrefix(config) {
215
270
  return config.storagePreviewPrefix ?? "preview/";
216
271
  }
@@ -355,7 +410,7 @@ function parseAws(value) {
355
410
  }
356
411
  export function parseSiteConfig(raw) {
357
412
  const record = requireObject(raw, "site");
358
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
413
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"], "site");
359
414
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
360
415
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
361
416
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -448,6 +503,7 @@ export function parseSiteConfig(raw) {
448
503
  fail("github", "ownerId and repoId must be set together");
449
504
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
450
505
  }
506
+ const chat = parseChat(record.chat, cms);
451
507
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
452
508
  if (hasDomain && hostedZoneId === undefined) {
453
509
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
@@ -469,6 +525,7 @@ export function parseSiteConfig(raw) {
469
525
  reader,
470
526
  papyrusVersion,
471
527
  storagePreviewPrefix,
528
+ ...(chat ? { chat } : {}),
472
529
  autoDeploy: autoDeploy,
473
530
  github,
474
531
  ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
@@ -55,6 +55,21 @@ export type CmsConfig = {
55
55
  deployStackInBuild?: boolean;
56
56
  };
57
57
 
58
+ export type ChatConfig = {
59
+ enabled: boolean;
60
+ /** SSM SecureString parameter holding the site's OpenAI key; only the chat run worker may read it. */
61
+ modelKeyParameter: string;
62
+ /** Chat data stack name (also its CDK construct id). Default `papyrus-chat-<siteId>`. */
63
+ stackName?: string;
64
+ /** CloudWatch log group prefix of the two workers (`<prefix>-run-worker`, `<prefix>-probe-worker`). Default `/aws/lambda/papyrus-chat-<siteId>`. */
65
+ logGroupPrefix?: string;
66
+ /**
67
+ * Pins the worker code to an already uploaded CDK asset object (key in the CDK assets bucket) instead of
68
+ * bundling and uploading new bytes. Only for adopting a stack whose workers are already verified live.
69
+ */
70
+ workerCodeS3Key?: string;
71
+ };
72
+
58
73
  export type AmplifyAppShellSiteConfig = {
59
74
  siteId: string;
60
75
  repository: string;
@@ -82,6 +97,11 @@ export type AmplifyAppShellSiteConfig = {
82
97
  };
83
98
  papyrusVersion: string;
84
99
  storagePreviewPrefix?: string;
100
+ /**
101
+ * Private newsroom chat for this publication (docs/private-newsroom-chat-per-site.md). Absent or
102
+ * `enabled: false` means the site gets neither the chat data stack nor the chat environment on its CMS.
103
+ */
104
+ chat?: ChatConfig;
85
105
  /**
86
106
  * The scheduled cloud knowledge run for this publication. The app-shell stack ignores it; it is validated by
87
107
  * infra/scheduled-jobs/lib/knowledge-run-config.ts, which owns the schema.
@@ -153,6 +173,9 @@ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
153
173
  const SITE_ORIGIN_PATTERN = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(:\d{1,5})?$/;
154
174
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
155
175
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
176
+ const CHAT_MODEL_KEY_PARAMETER_PATTERN = /^\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)+$/;
177
+ const CHAT_LOG_GROUP_PREFIX_PATTERN = /^\/aws\/lambda\/[A-Za-z0-9_.-]{1,100}$/;
178
+ const CHAT_WORKER_CODE_KEY_PATTERN = /^[0-9a-f]{64}\.zip$/;
156
179
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
157
180
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
158
181
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
@@ -339,6 +362,57 @@ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId
339
362
  return config.stackName ?? `amplify-app-shell-${config.siteId}`;
340
363
  }
341
364
 
365
+ /** The chat data stack of a site; its construct id and CloudFormation name. Nothing when chat is not enabled. */
366
+ export function resolveChatStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string | undefined {
367
+ if (!config.chat?.enabled) return undefined;
368
+ return config.chat.stackName ?? `papyrus-chat-${config.siteId}`;
369
+ }
370
+
371
+ export function resolveChatLogGroupPrefix(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "chat">): string {
372
+ return config.chat?.logGroupPrefix ?? `/aws/lambda/papyrus-chat-${config.siteId}`;
373
+ }
374
+
375
+ /** CloudFormation export names the chat stack publishes and the app shell imports (no stack ever references the other directly). */
376
+ export const CHAT_EXPORTED_RESOURCES = ["MessagingTableName", "ConversationIndexTableName", "SessionBucketName", "TurnRunsQueueUrl", "TurnProbesQueueUrl"] as const;
377
+ export const CHAT_ENVIRONMENT_NAMES: Record<(typeof CHAT_EXPORTED_RESOURCES)[number], string> = {
378
+ MessagingTableName: "PAPYRUS_CHAT_MESSAGING_TABLE",
379
+ ConversationIndexTableName: "PAPYRUS_CHAT_CONVERSATIONS_TABLE",
380
+ SessionBucketName: "PAPYRUS_CHAT_SESSION_BUCKET",
381
+ TurnRunsQueueUrl: "PAPYRUS_CHAT_TURN_RUNS_QUEUE_URL",
382
+ TurnProbesQueueUrl: "PAPYRUS_CHAT_TURN_PROBES_QUEUE_URL",
383
+ };
384
+ export const CHAT_PRIVATE_ASSISTANT_ID = "papyrus-private-assistant";
385
+ export const CHAT_PRIVATE_ASSISTANT_NAME = "Papyrus Assistant";
386
+ export function chatExportName(stackName: string, resource: (typeof CHAT_EXPORTED_RESOURCES)[number]): string {
387
+ return `${stackName}:${resource}`;
388
+ }
389
+
390
+ function parseChat(value: unknown, cms: CmsConfig | undefined): ChatConfig | undefined {
391
+ if (value === undefined) return undefined;
392
+ const record = requireObject(value, "chat");
393
+ rejectUnknownKeys(record, ["enabled", "modelKeyParameter", "stackName", "logGroupPrefix", "workerCodeS3Key"], "chat");
394
+ if (typeof record.enabled !== "boolean") fail("chat.enabled", "must be a boolean");
395
+ const chat: ChatConfig = {
396
+ enabled: record.enabled,
397
+ modelKeyParameter: requireString(record.modelKeyParameter, "chat.modelKeyParameter", CHAT_MODEL_KEY_PARAMETER_PATTERN, "SSM parameter name such as /papyrus/<site>/openai-api-key"),
398
+ stackName: optionalString(record.stackName, "chat.stackName", STACK_NAME_PATTERN, "CloudFormation stack name"),
399
+ logGroupPrefix: optionalString(record.logGroupPrefix, "chat.logGroupPrefix", CHAT_LOG_GROUP_PREFIX_PATTERN, "/aws/lambda/<name>"),
400
+ workerCodeS3Key: optionalString(record.workerCodeS3Key, "chat.workerCodeS3Key", CHAT_WORKER_CODE_KEY_PATTERN, "<64 hex>.zip CDK asset key"),
401
+ };
402
+ for (const key of ["stackName", "logGroupPrefix", "workerCodeS3Key"] as const) {
403
+ if (chat[key] === undefined) delete chat[key];
404
+ }
405
+ if (chat.enabled && cms === undefined) fail("chat.enabled", "requires cms (the chat gateway runs in the CMS compute role)");
406
+ if (chat.enabled && cms) {
407
+ for (const key of Object.keys(cms.environment)) {
408
+ if (key === "PAPYRUS_PRIVATE_CHAT_ENABLED" || key.startsWith("PAPYRUS_CHAT_")) {
409
+ fail(`cms.environment.${key}`, "is managed by the chat block when chat is enabled: remove it and use chat.enabled");
410
+ }
411
+ }
412
+ }
413
+ return chat;
414
+ }
415
+
342
416
  export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
343
417
  return config.storagePreviewPrefix ?? "preview/";
344
418
  }
@@ -498,7 +572,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
498
572
  const record = requireObject(raw, "site");
499
573
  rejectUnknownKeys(
500
574
  record,
501
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
575
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "chat", "knowledgeRun", "autoDeploy", "github"],
502
576
  "site",
503
577
  );
504
578
 
@@ -590,6 +664,8 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
590
664
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean, ...(ownerId !== undefined ? { ownerId, repoId } : {}) };
591
665
  }
592
666
 
667
+ const chat = parseChat(record.chat, cms);
668
+
593
669
  const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
594
670
  if (hasDomain && hostedZoneId === undefined) {
595
671
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
@@ -612,6 +688,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
612
688
  reader,
613
689
  papyrusVersion,
614
690
  storagePreviewPrefix,
691
+ ...(chat ? { chat } : {}),
615
692
  autoDeploy: autoDeploy as boolean,
616
693
  github,
617
694
  ...(record.knowledgeRun !== undefined ? { knowledgeRun: record.knowledgeRun } : {}),
@@ -47,6 +47,8 @@ export type MonitoringEmailDetails = {
47
47
 
48
48
  export type MonitoringEmailArticleResolution = {
49
49
  url: string; title: string; resolved: true;
50
+ /** Present when the original link could not be followed and the destination was found by searching the headline. */
51
+ foundVia?: "search"; search?: { query: string; matchScore: number; provider: string; matchedTitle?: string };
50
52
  } | { error: "forbidden" | "bad_request" | "not_resolvable" | "resolution_failed"; reason?: string } | { state: "unavailable" };
51
53
 
52
54
  export type EmailReferenceImportItem = { candidateIndex: number; source_uri: string };
@@ -11,6 +11,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
11
11
  { key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
+ { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
14
15
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
16
  ] },
16
17
  { title: "Research & knowledge", items: [
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { EDIT_PROPOSAL, TRANSITION_PROPOSAL, type ProposalMove, type ProposalReceipt } from "./proposals-admin";
5
+
6
+ type GraphResult = { data?: Record<string, ProposalReceipt | null>; errors?: { message?: string }[] };
7
+
8
+ async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
9
+ const client = generateClient({ authMode: "userPool" });
10
+ const result = (await client.graphql({ query, variables })) as GraphResult;
11
+ if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
12
+ return result.data?.[field] ?? { status: "refused", message: "The action did not answer." };
13
+ }
14
+
15
+ /** Moves a proposal through `path`, one `transitionProposal` call per state, stopping at the first refusal. */
16
+ export async function transitionProposalPath(proposalId: string, path: readonly string[], reason = ""): Promise<ProposalReceipt> {
17
+ let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
18
+ for (const state of path) {
19
+ receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
20
+ if (receipt.status !== "ok") break;
21
+ }
22
+ return receipt;
23
+ }
24
+
25
+ /** One button of the Proposals panel: a path of states, or `editProposal` for pin and hold. */
26
+ export async function performProposalMove(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
27
+ if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
28
+ return transitionProposalPath(proposalId, move.path ?? [], reason);
29
+ }
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
4
  import { getCurrentUser } from "aws-amplify/auth";
5
+ import { draftDecisionUpdate, draftView, type DraftRow, type DraftView } from "./review-drafts";
5
6
  import {
6
7
  answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
7
- type AnswerInput, type ReviewRequest,
8
+ type AnswerInput, type DraftDecision, type ReviewRequest,
8
9
  } from "./review-requests";
9
10
 
10
11
  /**
@@ -118,3 +119,39 @@ export async function loadStoryTitles(ids: string[]): Promise<{ titles: Record<s
118
119
  }));
119
120
  return { titles, deleted };
120
121
  }
122
+
123
+ const MAX_DRAFT_LOOKUPS = 60;
124
+ const DRAFT_FIELDS = "id kind title summary body state held evidence history override createdAt";
125
+
126
+ /** Records one draft's decision on the request while it stays open. Same condition as an answer: state = open. */
127
+ export async function saveDraftDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): Promise<ReviewRequest> {
128
+ const result = await run(
129
+ `mutation Decide($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
130
+ { input: draftDecisionUpdate(request, draftId, decision), condition: { state: { eq: "open" } } },
131
+ );
132
+ if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
133
+ throw new Error("Someone else answered this, or it closed at its deadline. Refresh to see the result.");
134
+ }
135
+ if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("The draft was decided, but the request could not record it. Try again.");
136
+ return normalizeReviewRow(result.data.updateReviewRequest);
137
+ }
138
+
139
+ export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
140
+
141
+ /**
142
+ * The Proposals that review requests are about, by id (editors and admins may read them). `missing` ids have no proposal (an idea kept
143
+ * only as JSON has no proposal to open); `failed` ids could not be read right now, which says nothing about whether they exist.
144
+ */
145
+ export async function loadDrafts(ids: string[], demo = false): Promise<DraftLoad> {
146
+ const drafts: Record<string, DraftView> = {};
147
+ const missing: string[] = [];
148
+ const failed: string[] = [];
149
+ await Promise.all([...new Set(ids)].slice(0, MAX_DRAFT_LOOKUPS).map(async (id) => {
150
+ const result = await run(`query Draft($id: ID!) { getProposal(id: $id) { ${DRAFT_FIELDS} } }`, { id });
151
+ const row = result.data?.getProposal as DraftRow | null | undefined;
152
+ if (row) drafts[id] = draftView(row, demo);
153
+ else if (result.errors?.length || !result.data) failed.push(id);
154
+ else missing.push(id);
155
+ }));
156
+ return { drafts, missing, failed };
157
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The AI drafts inside a review request (a trend or monitor story batch, an idea batch): what each one says, what it rests on,
3
+ * and how an editor's Accept or Reject on it moves the Proposal and, once every draft is decided, closes the request.
4
+ * Pure functions, no network, so the card and its tests share them.
5
+ */
6
+ import { proposalHref, draftIdsOf, type AnswerInput, type DraftDecision, type ReviewLink, type ReviewRequest } from "./review-requests";
7
+
8
+ /** A Proposal row as the console reads it; JSON fields may arrive as strings. */
9
+ export type DraftRow = {
10
+ id: string;
11
+ kind: string;
12
+ title: string;
13
+ summary?: string | null;
14
+ body?: string | null;
15
+ state: string;
16
+ held?: boolean | null;
17
+ evidence?: unknown;
18
+ history?: unknown;
19
+ override?: unknown;
20
+ createdAt?: string | null;
21
+ };
22
+
23
+ export type DraftSource = { title: string; date: string | null; url: string | null };
24
+ export type DraftChoice = "accept" | "reject";
25
+
26
+ export type DraftView = {
27
+ id: string;
28
+ kindLabel: string;
29
+ headline: string;
30
+ dek: string | null;
31
+ section: string | null;
32
+ /** The evidence in words: the signals it rests on, or the monitor that found its source. */
33
+ evidence: string[];
34
+ sources: DraftSource[];
35
+ moreSources: number;
36
+ state: string;
37
+ held: boolean;
38
+ href: string;
39
+ /** What the Proposal's own state says an editor already decided, or null while it is open. */
40
+ decided: DraftChoice | null;
41
+ };
42
+
43
+ export const MAXIMUM_SOURCES = 5;
44
+ const MAXIMUM_EVIDENCE = 3;
45
+ const KIND_WORDS: Record<string, string> = { story_draft: "Story draft", new_concept: "New concept", rename: "Rename", merge: "Merge", split: "Split", new_column: "New column", element_symbol: "Element symbol" };
46
+
47
+ function parseJson(value: unknown): unknown {
48
+ if (typeof value !== "string") return value ?? null;
49
+ try {
50
+ return JSON.parse(value);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** The lines of a "## Heading" section of the body, up to the next heading or the closing "Section:" line. */
57
+ function bodySection(body: string, heading: RegExp): string[] {
58
+ const lines = body.split(/\r?\n/);
59
+ const start = lines.findIndex((line) => heading.test(line.trim()));
60
+ if (start < 0) return [];
61
+ const found: string[] = [];
62
+ for (const line of lines.slice(start + 1)) {
63
+ const text = line.trim();
64
+ if (/^#{1,6}\s/.test(text) || /^Section:/.test(text)) break;
65
+ if (text) found.push(text);
66
+ }
67
+ return found;
68
+ }
69
+
70
+ export function parseSource(line: string): DraftSource | null {
71
+ const numbered = line.match(/^\d+\.\s+(.+)$/);
72
+ if (!numbered) return null;
73
+ let rest = numbered[1].trim();
74
+ let url: string | null = null;
75
+ const link = rest.match(/^(.*?)\s*(https?:\/\/\S+)$/);
76
+ if (link) {
77
+ rest = link[1].trim();
78
+ url = link[2];
79
+ }
80
+ let date: string | null = null;
81
+ const dated = rest.match(/^(.*?)\s*\((\d{4}-\d{2}-\d{2})\)$/);
82
+ if (dated) {
83
+ rest = dated[1].trim();
84
+ date = dated[2];
85
+ }
86
+ return rest ? { title: rest, date, url } : null;
87
+ }
88
+
89
+ function evidenceLines(row: DraftRow): string[] {
90
+ const body = row.body ?? "";
91
+ const signals = bodySection(body, /^##\s+Signals cited\s*$/i)
92
+ .map((line) => line.match(/^\d+\.\s+(.+?)(?:\s+\(https?:\/\/[^)\s]*\))?\s*(?:`[^`]*`)?\s*$/)?.[1]?.trim())
93
+ .filter((line): line is string => Boolean(line));
94
+ if (signals.length) return signals.slice(0, MAXIMUM_EVIDENCE);
95
+ const evidence = parseJson(row.evidence);
96
+ const source = evidence && typeof evidence === "object" && typeof (evidence as { source?: unknown }).source === "string" ? String((evidence as { source: string }).source) : "";
97
+ if (!source) return [];
98
+ const monitor = source.match(/^(.+?) monitor:/);
99
+ return [monitor ? `Found by the ${monitor[1]} monitor.` : source];
100
+ }
101
+
102
+ export function draftChoiceOf(state: string): DraftChoice | null {
103
+ return state === "approved" ? "accept" : state === "declined" ? "reject" : null;
104
+ }
105
+
106
+ /** What an editor reads before deciding: headline, dek, section, evidence and the cited sources, from the Proposal row. */
107
+ export function draftView(row: DraftRow, demo = false): DraftView {
108
+ const body = row.body ?? "";
109
+ const sources = [...bodySection(body, /^##\s+Sources?\s*$/i)].map(parseSource).filter((source): source is DraftSource => source !== null);
110
+ const section = body.match(/^Section:\s*(.+?)\.\s+Drafted by AI/m)?.[1]?.trim() ?? null;
111
+ const override = parseJson(row.override) as { result?: string } | null;
112
+ return {
113
+ id: row.id,
114
+ kindLabel: KIND_WORDS[row.kind] ?? row.kind,
115
+ headline: row.title,
116
+ dek: row.summary?.trim() || null,
117
+ section,
118
+ evidence: evidenceLines(row),
119
+ sources: sources.slice(0, MAXIMUM_SOURCES),
120
+ moreSources: Math.max(0, sources.length - MAXIMUM_SOURCES),
121
+ state: row.state,
122
+ held: Boolean(row.held),
123
+ href: proposalHref(row.id, demo),
124
+ decided: draftChoiceOf(row.state) ?? (row.state === "overridden_by_editor" ? (override?.result === "declined" ? "reject" : "accept") : null),
125
+ };
126
+ }
127
+
128
+ // --- moving the Proposal ----------------------------------------------------------------------------------------
129
+
130
+ /**
131
+ * The states Accept walks through, one `transitionProposal` call each, ending at "approved" (the same state the Proposals panel's Approve reaches).
132
+ * A draft goes through steering and editor review on the way, as the panel's own "Move to editor review" then "Approve" do.
133
+ */
134
+ export function acceptPath(state: string): string[] | null {
135
+ if (state === "draft") return ["in_steering", "editor_review", "approved"];
136
+ if (state === "in_steering") return ["editor_review", "approved"];
137
+ if (state === "editor_review") return ["approved"];
138
+ return null;
139
+ }
140
+
141
+ /** Reject ends at "declined". */
142
+ export function rejectPath(state: string): string[] | null {
143
+ if (state === "draft") return ["declined"];
144
+ if (state === "in_steering") return ["editor_review", "declined"];
145
+ if (state === "editor_review") return ["declined"];
146
+ return null;
147
+ }
148
+
149
+ /** The sentence for a draft that cannot move, or null when it can. A hold is named, never hidden. */
150
+ export function cannotMoveReason(view: Pick<DraftView, "state" | "held" | "decided">): string | null {
151
+ if (view.decided) return null;
152
+ if (view.held) return "This draft is on hold in Proposals. Release the hold there before you decide it.";
153
+ return null;
154
+ }
155
+
156
+ // --- the request's answer ---------------------------------------------------------------------------------------
157
+
158
+ export type DraftLookup = Readonly<Record<string, DraftView | undefined>>;
159
+
160
+ /** The decision recorded on a link, else what the Proposal's own state says (decided in Proposals), else null. */
161
+ export function draftDecisionOf(link: ReviewLink | undefined, view: DraftView | undefined, fallback: { by: string; at: string }): DraftDecision | null {
162
+ if (link?.decision) return link.decision;
163
+ return view?.decided ? { choice: view.decided, title: view.headline, why: null, notThisKind: false, ...fallback } : null;
164
+ }
165
+
166
+ /** Ids of the request's drafts that exist and are not decided yet. */
167
+ export function undecidedDraftIds(request: ReviewRequest, drafts: DraftLookup): string[] {
168
+ return draftIdsOf(request).filter((id) => {
169
+ const view = drafts[id];
170
+ if (!view) return false;
171
+ return draftDecisionOf(request.links.find((link) => link.id === id), view, { by: "", at: "" }) === null;
172
+ });
173
+ }
174
+
175
+ /** How many drafts in these requests still wait for a decision (drafts not yet loaded count as waiting). */
176
+ export function draftsToDecide(requests: ReviewRequest[], drafts: DraftLookup, missing: ReadonlySet<string> = new Set()): number {
177
+ return requests.reduce((total, request) => total + draftIdsOf(request).filter((id) => !missing.has(id) && (!drafts[id] || undecidedDraftIds(request, drafts).includes(id))).length, 0);
178
+ }
179
+
180
+ /** The links with one draft's decision recorded. Links for drafts the request did not name are not invented. */
181
+ export function withDecision(request: ReviewRequest, draftId: string, decision: DraftDecision): ReviewLink[] {
182
+ const named = request.links.some((link) => link.id === draftId);
183
+ const links = named ? request.links : [...request.links, { type: "proposal", id: draftId }];
184
+ return links.map((link) => (link.id === draftId ? { ...link, decision } : link));
185
+ }
186
+
187
+ /** The update that records a decision on one draft while the request stays open. Applied with the condition state = open. */
188
+ export function draftDecisionUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision): Record<string, unknown> {
189
+ const history = [...request.history, { at: decision.at, event: "draft_decided", by: decision.by, note: `${decision.choice} ${draftId}` }].slice(-50);
190
+ return { id: request.id, links: JSON.stringify(withDecision(request, draftId, decision)), updatedAt: decision.at, history: JSON.stringify(history) };
191
+ }
192
+
193
+ function quote(text: string): string {
194
+ return `"${text.replace(/"/g, "'")}"`;
195
+ }
196
+
197
+ /**
198
+ * The answer that closes a request once every draft that can be decided has been: Accept on all is an approval, Reject on all a rejection,
199
+ * a mix is an answer with a note naming each draft. A draft rejected as the wrong kind of story makes the answer a "not this kind" steer, which
200
+ * the idea pipeline reads back. Null while a draft is undecided, or when no draft of the request could be loaded.
201
+ */
202
+ export function batchAnswer(request: ReviewRequest, drafts: DraftLookup, fallback: { by: string; at: string }): AnswerInput | null {
203
+ const ids = draftIdsOf(request).filter((id) => drafts[id]);
204
+ if (!ids.length) return null;
205
+ const decisions: (DraftDecision & { id: string })[] = [];
206
+ for (const id of ids) {
207
+ const decision = draftDecisionOf(request.links.find((link) => link.id === id), drafts[id], fallback);
208
+ if (!decision) return null;
209
+ decisions.push({ ...decision, title: decision.title ?? drafts[id]?.headline, id });
210
+ }
211
+ const rejected = decisions.filter((decision) => decision.choice === "reject");
212
+ const lines = decisions
213
+ .filter((decision) => decision.choice === "reject" || decision.why)
214
+ .map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
215
+ const mixed = rejected.length > 0 && rejected.length < decisions.length;
216
+ if (mixed) {
217
+ const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
218
+ if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
219
+ }
220
+ const links = request.links.some((link) => link.type === "proposal") ? request.links.map((link) => {
221
+ const decision = decisions.find((entry) => entry.id === link.id);
222
+ return decision ? { ...link, decision } : link;
223
+ }) : [...request.links, ...decisions.map((decision) => ({ type: "proposal", id: decision.id, decision }))];
224
+ return {
225
+ decision: !rejected.length ? "approve" : mixed ? "feedback" : "reject",
226
+ feedback: lines.join(" ") || null,
227
+ steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
228
+ drafts: decisions,
229
+ links,
230
+ };
231
+ }
@@ -1,16 +1,17 @@
1
+ import type { DraftRow } from "./review-drafts";
1
2
  import type { ReviewRequest } from "./review-requests";
2
3
 
3
4
  /** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
4
5
  export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
5
6
  {
6
- id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Approve this ruling summary before it is published",
7
+ id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Accept this ruling summary before it is published",
7
8
  ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
8
9
  state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
9
10
  appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
10
11
  },
11
12
  {
12
13
  id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
13
- ask: "Approve to keep them all, reject to drop the batch, or steer with a note.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
14
+ ask: "Accept or reject each draft.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
14
15
  deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
15
16
  links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
16
17
  history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
@@ -42,4 +43,37 @@ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
42
43
  answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
43
44
  links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
44
45
  },
46
+ {
47
+ id: "rr-soft-3", subject: { type: "story", id: "story-weekly" }, mode: "soft_review", reason: "Check the tone of the weekly summary", ask: "Is the tone right for readers?",
48
+ raisedBy: "copy-editor", raisedAt: "2026-10-23T06:00:00Z", deadline: "2026-10-31T07:00:00Z", state: "open", answer: null, links: [], history: [], appliedIn: null,
49
+ updatedAt: "2026-10-23T06:00:00Z",
50
+ },
51
+ ];
52
+
53
+ const TREND_BODY = (dek: string, section: string) => [
54
+ `${dek} [1][3]`, "", "## Signals cited", "",
55
+ "1. Sparse attention: technique, 32 items in the last 2 weeks against 9 in the 6 weeks before (about 4 expected; p=0.001 after correcting for 40 windows tried) `entity:sparse-attention`",
56
+ "2. Open weights: cluster, steady, 58 items `cluster:open-weights`", "", "## Sources", "",
57
+ "3. A sparse-attention model ships open weights (2026-10-20) https://example.org/sparse-attention",
58
+ "4. Benchmarks for long-context models (2026-10-18) https://example.org/long-context", "", `Section: ${section}. Drafted by AI from the knowledge snapshot; an editor approves the copy and the figure before anything is published.`, "",
59
+ ].join("\n");
60
+
61
+ /** Demo and test drafts for the batch fixtures: a trend story, a monitor story and a draft that already has a decision. */
62
+ export const FIXTURE_DRAFTS: DraftRow[] = [
63
+ {
64
+ id: "proposal-1", kind: "story_draft", title: "Sparse attention goes open", summary: "Open-weight models are adopting sparse attention faster than closed labs reported.",
65
+ body: TREND_BODY("Open-weight models are adopting sparse attention faster than closed labs reported.", "Research"), state: "draft", held: false,
66
+ evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
67
+ },
68
+ {
69
+ id: "proposal-2", kind: "story_draft", title: "A new quantisation paper challenges 4-bit defaults", summary: "A preprint argues that 3-bit weights match 4-bit accuracy on long-context tasks.",
70
+ body: ["A preprint argues that 3-bit weights match 4-bit accuracy on long-context tasks. [1]", "", "## Source", "", "1. Rethinking 4-bit defaults (2026-10-22) https://example.org/quantisation", "",
71
+ "Section: Research. Drafted by AI from an item the arxiv-cs monitor found. An editor approves the copy before anything is published.", ""].join("\n"),
72
+ state: "draft", held: false, evidence: { signals: 1, source: "arxiv-cs monitor: https://example.org/quantisation" }, history: [], createdAt: "2026-10-23T07:00:00Z",
73
+ },
74
+ {
75
+ id: "proposal-3", kind: "story_draft", title: "Eight new models this week", summary: "A list of the week's releases.",
76
+ body: TREND_BODY("A list of the week's releases.", "News"), state: "editor_review", held: false,
77
+ evidence: { signals: 2, source: "knowledge snapshot 2026-10-23 (shipped), papyrus ideas trends" }, history: [], createdAt: "2026-10-23T07:00:00Z",
78
+ },
45
79
  ];