@anthusai/papyrus 5.43.0-next.5 → 5.43.0-next.50

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 (139) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +34 -3
  9. package/amplify/data/resource.ts +35 -3
  10. package/amplify/data/schema.js +40 -5
  11. package/amplify/data/schema.ts +41 -5
  12. package/amplify/functions/category-action/handler.ts +53 -126
  13. package/amplify/functions/content-actions/handler.py +8 -0
  14. package/amplify/functions/content-actions/requirements.txt +2 -0
  15. package/amplify/functions/content-actions/resource.js +1 -1
  16. package/amplify/functions/content-actions/resource.ts +1 -1
  17. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  18. package/amplify/functions/monitoring-view/schema.js +16 -0
  19. package/amplify/functions/monitoring-view/schema.ts +16 -0
  20. package/amplify/functions/procedure-action/handler.ts +2 -10
  21. package/amplify/functions/proposal-actions/proposals.py +20 -3
  22. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  23. package/amplify/plugins/reader-steering/function/handler.py +1 -3
  24. package/amplify/plugins/reader-steering/function/moderation.py +19 -0
  25. package/app/api/newsroom/source-frameable/route.ts +12 -0
  26. package/app/api/newsroom/source-icon/route.ts +20 -0
  27. package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
  28. package/app/newsroom/[section]/page.tsx +1 -0
  29. package/bin/papyrus-app.mjs +12 -1
  30. package/bin/papyrus-infra.mjs +21 -1
  31. package/components/auto-accepted-badge.tsx +21 -0
  32. package/components/conversation-context.tsx +52 -0
  33. package/components/conversation-provider.tsx +20 -0
  34. package/components/conversation-thread.tsx +188 -0
  35. package/components/conversations-memory-source.ts +46 -0
  36. package/components/conversations-network-source.ts +40 -0
  37. package/components/conversations-page.tsx +14 -0
  38. package/components/conversations-source.ts +24 -0
  39. package/components/conversations-view.tsx +319 -0
  40. package/components/decision-control.tsx +307 -0
  41. package/components/dev-sandbox-editor-sign-in.tsx +2 -2
  42. package/components/markdown-context.tsx +412 -0
  43. package/components/monitoring-overview-view.tsx +143 -21
  44. package/components/monitoring-panel.tsx +64 -4
  45. package/components/needs-me-bodies.tsx +302 -0
  46. package/components/needs-me-detail.tsx +24 -0
  47. package/components/needs-me-feed.tsx +594 -0
  48. package/components/needs-me-reference-detail.tsx +79 -0
  49. package/components/needs-me-shared.tsx +48 -0
  50. package/components/news-desk-client-provider.tsx +24 -3
  51. package/components/news-desk-page.tsx +2 -1
  52. package/components/news-desk-taxonomy-client.ts +337 -77
  53. package/components/newsroom-access-gate.tsx +13 -20
  54. package/components/newsroom-board-skeleton.tsx +20 -0
  55. package/components/newsroom-board-view.tsx +2 -6
  56. package/components/newsroom-client-shell.tsx +2 -1
  57. package/components/newsroom-ops-overview.tsx +88 -73
  58. package/components/newsroom-ops-shell.tsx +8 -28
  59. package/components/newsroom-organizer.css +127 -3
  60. package/components/newsroom-references-view.tsx +334 -158
  61. package/components/notification-cards.tsx +370 -0
  62. package/components/notification-center.tsx +376 -213
  63. package/components/papyrus-console-shell.tsx +2 -1
  64. package/components/proposals-panel.tsx +21 -24
  65. package/components/proposals-view.tsx +4 -2
  66. package/components/reader-auth-control.tsx +3 -1
  67. package/components/reference-capture-client.ts +138 -0
  68. package/components/reference-capture-panel.tsx +268 -0
  69. package/components/reference-conversation.tsx +43 -0
  70. package/components/reference-feed.tsx +541 -0
  71. package/components/reference-review-card.tsx +62 -197
  72. package/components/reference-source-preview.tsx +27 -5
  73. package/components/reference-stories.tsx +34 -0
  74. package/components/references-page-loading.tsx +61 -0
  75. package/components/references-prefetch.ts +52 -0
  76. package/components/story-reporting-trail-client.ts +48 -10
  77. package/components/story-reporting-trail.tsx +32 -13
  78. package/components/topic-steering-workspace.tsx +92 -1223
  79. package/components/ui/auto-grow-textarea.tsx +18 -0
  80. package/components/use-conversation.ts +84 -0
  81. package/components/use-media-query.ts +16 -0
  82. package/components/use-reference-preview-attachments.ts +101 -0
  83. package/components/use-reference-ratings.ts +192 -0
  84. package/infra/amplify-app-shell.js +18 -2
  85. package/infra/amplify-app-shell.ts +18 -2
  86. package/infra/chat-stack.js +222 -0
  87. package/infra/chat-stack.ts +250 -0
  88. package/infra/chat.test.js +142 -0
  89. package/infra/chat.test.ts +151 -0
  90. package/infra/index.js +1 -0
  91. package/infra/site-config.js +58 -1
  92. package/infra/site-config.ts +78 -1
  93. package/lib/category-repository.ts +17 -39
  94. package/lib/console-chat-client.ts +8 -178
  95. package/lib/content-actions-client.ts +20 -0
  96. package/lib/conversation-feed.ts +96 -0
  97. package/lib/conversation.ts +154 -0
  98. package/lib/cyclotron-suggestions.ts +18 -36
  99. package/lib/local-preview-feed-fixture.ts +63 -0
  100. package/lib/local-preview-seed.ts +40 -29
  101. package/lib/local-preview-synthetic-rejects.ts +41 -0
  102. package/lib/monitoring-client.ts +29 -2
  103. package/lib/monitoring-overview.ts +52 -1
  104. package/lib/monitoring-paging.ts +58 -0
  105. package/lib/needs-me-items.ts +289 -0
  106. package/lib/needs-me-ranking.ts +315 -0
  107. package/lib/newsroom-all-pages.ts +21 -0
  108. package/lib/newsroom-chat-identity.ts +2 -1
  109. package/lib/newsroom-demo-dashboard.ts +0 -7
  110. package/lib/newsroom-index-filters.ts +18 -0
  111. package/lib/newsroom-nav.ts +1 -1
  112. package/lib/newsroom-organizer-nav.ts +2 -1
  113. package/lib/newsroom-references.ts +25 -1
  114. package/lib/notification-feed.ts +212 -0
  115. package/lib/post-message.ts +54 -0
  116. package/lib/proposals-client.ts +29 -0
  117. package/lib/reader-subject.ts +3 -2
  118. package/lib/reference-capture.ts +294 -0
  119. package/lib/reference-feed-cache.ts +170 -0
  120. package/lib/reference-feed-keys.ts +69 -0
  121. package/lib/reference-feed-ranking.ts +236 -0
  122. package/lib/reference-policy.ts +95 -11
  123. package/lib/reference-preview.ts +112 -0
  124. package/lib/reference-review-details.ts +21 -57
  125. package/lib/relevance-decisions.ts +6 -2
  126. package/lib/review-client.ts +71 -5
  127. package/lib/review-drafts.ts +277 -0
  128. package/lib/review-fixture.ts +56 -2
  129. package/lib/review-requests.ts +89 -24
  130. package/lib/semantic-graph.ts +1 -1
  131. package/lib/site-brand.ts +2 -0
  132. package/lib/source-frameable.ts +77 -0
  133. package/lib/source-icon.ts +104 -0
  134. package/lib/staff-groups.ts +23 -0
  135. package/lib/staging-gate.ts +2 -2
  136. package/lib/story-reporting-trail.ts +61 -4
  137. package/package.json +53 -2
  138. package/routes.manifest.json +23 -1
  139. package/components/newsroom-overview-skeleton.tsx +0 -25
@@ -4,6 +4,7 @@ import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
5
  import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
6
6
  import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema";
7
+ import { messageActionsSchemaDefinitions } from "./message-actions";
7
8
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
8
9
  import { contentActions } from "../functions/content-actions/resource";
9
10
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
@@ -285,8 +286,6 @@ const definitions = {
285
286
  referenceId: a.id().required(),
286
287
  status: a.string().required(),
287
288
  reasonCode: a.string(),
288
- messageId: a.id(),
289
- relationId: a.id(),
290
289
  }),
291
290
 
292
291
  ReferenceQualityActionResult: a.customType({
@@ -582,6 +581,13 @@ const definitions = {
582
581
  .authorization((allow) => [allow.groups(contentWriteGroups)])
583
582
  .handler(functionHandler("contentActions", contentActions)),
584
583
 
584
+ correctReferenceCapture: a
585
+ .mutation()
586
+ .arguments({ input: a.json().required() })
587
+ .returns(a.json())
588
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
589
+ .handler(functionHandler("contentActions", contentActions)),
590
+
585
591
  saveItemDraft: a
586
592
  .mutation()
587
593
  .arguments({ input: a.json().required() })
@@ -1579,8 +1585,26 @@ const definitions = {
1579
1585
  curationStatusUpdatedAt: a.datetime(),
1580
1586
  curationStatusUpdatedBy: a.string(),
1581
1587
  curationStatusReason: a.string(),
1588
+ curationReasonCode: a.string(),
1589
+ curationActorKind: a.string(),
1590
+ curationSectionId: a.string(),
1591
+ curationShareable: a.boolean(),
1592
+ curationDelegation: a.json(),
1593
+ curationDecisionRelationId: a.string(),
1582
1594
  newsroomFeedKey: a.string(),
1583
1595
  reviewedFeedKey: a.string(),
1596
+ discoveredByMonitorId: a.string(),
1597
+ discoveryDetails: a.json(),
1598
+ discoveryRule: a.json(),
1599
+ // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1600
+ // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1601
+ // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
1602
+ // P(accept), written once, and never rewritten by a vote.
1603
+ feedStateKey: a.string(),
1604
+ feedArrivalAt: a.datetime(),
1605
+ predictedAcceptProbability: a.float(),
1606
+ predictionDecidedAt: a.datetime(),
1607
+ predictionSortKey: a.string(),
1584
1608
  updatedAt: a.datetime(),
1585
1609
  })
1586
1610
  .secondaryIndexes((index) => [
@@ -1593,6 +1617,9 @@ const definitions = {
1593
1617
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1594
1618
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1595
1619
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1620
+ index("discoveredByMonitorId").sortKeys(["createdAt"]).queryField("listReferencesByMonitorAndCreatedAt"),
1621
+ index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1622
+ index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1596
1623
  ])
1597
1624
  .authorization((allow) => [
1598
1625
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1680,6 +1707,9 @@ const definitions = {
1680
1707
  authorSub: a.string(),
1681
1708
  authorUserProfileId: a.id(),
1682
1709
  authorLabel: a.string(),
1710
+ editedAt: a.datetime(),
1711
+ deletedAt: a.datetime(),
1712
+ deletedBySub: a.string(),
1683
1713
  threadId: a.id(),
1684
1714
  parentMessageId: a.id(),
1685
1715
  sequenceNumber: a.integer(),
@@ -1714,20 +1744,25 @@ const definitions = {
1714
1744
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1715
1745
  ])
1716
1746
  .authorization((allow) => [
1717
- allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
1747
+ // Messages belong to their author, and the backend stamps the author: people read Messages here and write them only through
1748
+ // postMessage, editMessage, deleteOwnMessage and moderateMessage (message-actions.ts). Functions and IAM callers write directly.
1749
+ allow.group("editor").to(["read"]),
1750
+ allow.group(adminGroup).to(["read"]),
1718
1751
  ]),
1719
1752
 
1720
1753
  MessageThread: realtimeModel({
1721
1754
  id: a.id().required(),
1722
1755
  threadKind: a.string().required(),
1723
1756
  status: a.string().required(),
1724
- title: a.string().required(),
1757
+ // The title and the creator are set when a conversation is created and read afterwards; a title changes only through
1758
+ // renameThread (its creator or an admin), and the creator never changes.
1759
+ title: a.string().required().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1725
1760
  summary: a.string(),
1726
1761
  primaryAnchorKind: a.string(),
1727
1762
  primaryAnchorId: a.id(),
1728
1763
  primaryAnchorLineageId: a.id(),
1729
1764
  primaryAnchorKey: a.string(),
1730
- createdBySub: a.string(),
1765
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1731
1766
  createdByUserProfileId: a.id(),
1732
1767
  createdByLabel: a.string(),
1733
1768
  messageCount: a.integer(),
@@ -2227,6 +2262,7 @@ const definitions = {
2227
2262
  ...monitoringViewSchemaDefinitions,
2228
2263
  ...automationViewSchemaDefinitions,
2229
2264
  ...proposalActionsSchemaDefinitions,
2265
+ ...messageActionsSchemaDefinitions,
2230
2266
  };
2231
2267
  if (!includeEditions) {
2232
2268
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -4,7 +4,7 @@ import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
6
  import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
- import { curationActorKind, referenceCurationMetadata, validatedCurationSectionId } from "../shared/reference-curation-metadata";
7
+ import { curationActorKind, referenceVoteFields, validatedCurationSectionId } from "../shared/reference-curation-metadata";
8
8
  import {
9
9
  CURATION_DELEGATION_OWNER_TYPE,
10
10
  CURATION_DELEGATION_PAYLOAD_KIND,
@@ -24,6 +24,9 @@ import {
24
24
  requestDelegation,
25
25
  type DelegationStore,
26
26
  } from "../shared/curation-delegation-flow";
27
+ import { rejectionReasonCodeForWrittenReason } from "../../../lib/reference-policy";
28
+ import { referenceFeedKeys, referenceFeedStateKey } from "../../../lib/reference-feed-keys";
29
+ import { claimGroups } from "../../../lib/staff-groups";
27
30
 
28
31
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
29
32
  type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
@@ -213,7 +216,15 @@ async function reviewReferenceCuration(
213
216
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
214
217
  const action = normalizeReferenceCurationAction(event.arguments.action);
215
218
  const nextStatus = referenceCurationStatusForAction(action);
216
- const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
219
+ const writtenReason = normalizeOptionalString(event.arguments.note);
220
+ // A rejection needs a reason, and the editor's own sentence is one: with no reason code sent, the
221
+ // words are read for the code they name, so the scope-training negatives keep arriving from the
222
+ // surfaces that ask for words instead of a chip. Words that name no code are filed under "other".
223
+ const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode)
224
+ ?? (action === "reject" ? rejectionReasonCodeForWrittenReason(writtenReason) : null);
225
+ if (action === "reject" && !reasonCode) {
226
+ throw new Error("A rejection needs a reason: say why in your own words, or send a reasonCode.");
227
+ }
217
228
  const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
218
229
  const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
219
230
  const sectionRecord = requestedSectionId
@@ -224,18 +235,11 @@ async function reviewReferenceCuration(
224
235
  const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
225
236
  const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
226
237
  const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
227
- const note = normalizeOptionalString(event.arguments.note);
228
- const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
229
- const referenceVersionNumber = typeof reference.versionNumber === "number" ? reference.versionNumber : null;
230
- const referenceTitle = normalizeOptionalString(reference.title) ?? normalizeOptionalString(reference.externalItemId) ?? referenceId;
231
- const messageId = `message-reference-curation-${safeId(referenceLineageId)}-${safeId(action)}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
232
- const relationId = `semantic-relation-${hashStable([
233
- `message#${messageId}`,
234
- "comment",
235
- `reference#${referenceId}`,
236
- action,
237
- ]).slice(0, 16)}`;
238
+ const note = writtenReason;
239
+ const decisionRelationId = normalizeOptionalString(event.arguments.decisionRelationId);
238
240
 
241
+ // The vote is recorded on the Reference itself: its status, who set it and the note, plus the details of the vote.
242
+ // Messages come only from people, so a vote writes no Message and no comment relation.
239
243
  await requireDataResult(
240
244
  client.models.Reference.update({
241
245
  id: referenceId,
@@ -244,98 +248,23 @@ async function reviewReferenceCuration(
244
248
  curationStatusUpdatedAt: now,
245
249
  curationStatusUpdatedBy: actor,
246
250
  curationStatusReason: note,
251
+ ...referenceVoteFields({
252
+ actorKind: delegation ? "human" : curationActorKind(event.identity),
253
+ reasonCode,
254
+ sectionId,
255
+ shareable: event.arguments.shareable ?? null,
256
+ delegation,
257
+ decisionRelationId,
258
+ }),
247
259
  newsroomFeedKey: reference.newsroomFeedKey ?? "references",
248
260
  reviewedFeedKey: reviewedFeedKeyForStatus(nextStatus),
261
+ // A vote moves the Reference between the unrated and rated feeds; its original prediction and arrival stay as they were.
262
+ feedStateKey: referenceFeedStateKey({ ...reference, curationStatus: nextStatus }),
249
263
  updatedAt: now,
250
264
  }),
251
265
  "update Reference curation status",
252
266
  );
253
267
 
254
- await requireDataResult(
255
- client.models.Message.create(buildCanonicalMessageInput({
256
- id: messageId,
257
- messageKind: "reference_curation",
258
- messageDomain: "commentary",
259
- summary: `${referenceTitle}: ${nextStatus}`,
260
- source: "newsroom",
261
- authorSub: actorSub,
262
- authorLabel: actor,
263
- now,
264
- })),
265
- "create Message",
266
- );
267
- await putTextModelPayload(
268
- client as any,
269
- { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
270
- "message_body",
271
- "message",
272
- note ?? `${actor} marked this reference ${nextStatus}.`,
273
- { filename: "message.txt", now },
274
- );
275
- await putJsonModelPayload(
276
- client as any,
277
- { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
278
- "metadata",
279
- "metadata",
280
- referenceCurationMetadata({
281
- action,
282
- curationStatus: nextStatus,
283
- reasonCode,
284
- referenceId,
285
- referenceLineageId,
286
- decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
287
- shareable: event.arguments.shareable ?? null,
288
- actorKind: delegation ? "human" : curationActorKind(event.identity),
289
- sectionId,
290
- delegation,
291
- }),
292
- { filename: "metadata.json", now },
293
- );
294
-
295
- await requireDataResult(
296
- client.models.SemanticRelation.create({
297
- id: relationId,
298
- relationState: "current",
299
- predicate: "comment",
300
- relationTypeId: "semantic-relation-type-comment",
301
- relationTypeKey: "comment",
302
- relationDomain: "commentary",
303
- subjectKind: "message",
304
- subjectId: messageId,
305
- subjectLineageId: messageId,
306
- subjectVersionNumber: 1,
307
- objectKind: "reference",
308
- objectId: referenceId,
309
- objectLineageId: referenceLineageId,
310
- objectVersionNumber: referenceVersionNumber,
311
- subjectStateKey: semanticStateKey("message", messageId),
312
- objectStateKey: semanticStateKey("reference", referenceLineageId),
313
- objectSubjectStateKey: `${semanticStateKey("reference", referenceLineageId)}#message`,
314
- predicateObjectStateKey: `comment#${semanticStateKey("reference", referenceLineageId)}`,
315
- subjectVersionKey: semanticVersionKey("message", messageId),
316
- objectVersionKey: semanticVersionKey("reference", referenceId),
317
- score: 1,
318
- confidence: null,
319
- rank: 1,
320
- classifierId: null,
321
- modelVersion: null,
322
- reviewRecommended: false,
323
- sourceSnapshotId: null,
324
- importRunId: null,
325
- importedAt: now,
326
- createdAt: now,
327
- updatedAt: now,
328
- newsroomFeedKey: "semanticRelations",
329
- metadata: JSON.stringify({
330
- action,
331
- curationStatus: nextStatus,
332
- reasonCode,
333
- messageKind: "reference_curation",
334
- }),
335
- }),
336
- "create Message SemanticRelation",
337
- );
338
-
339
268
  await updateNewsroomSummaryForReferenceCuration(client, {
340
269
  corpusId: normalizeOptionalString(reference.corpusId),
341
270
  previousStatus: normalizeOptionalString(reference.curationStatus) ?? "pending",
@@ -349,8 +278,6 @@ async function reviewReferenceCuration(
349
278
  referenceId,
350
279
  status: nextStatus,
351
280
  reasonCode,
352
- messageId,
353
- relationId,
354
281
  };
355
282
  }
356
283
 
@@ -360,7 +287,7 @@ function requireSignedInPerson(event: { identity?: unknown }): { sub: string; la
360
287
  }
361
288
  const identity = event.identity as { sub?: unknown; claims?: Record<string, unknown> };
362
289
  const claims = identity.claims ?? {};
363
- const groups = Array.isArray(claims["cognito:groups"]) ? (claims["cognito:groups"] as unknown[]) : [];
290
+ const groups = claimGroups(claims["cognito:groups"]);
364
291
  return {
365
292
  sub: normalizeRequiredString(claims.sub ?? identity.sub, "identity.sub"),
366
293
  label: normalizeOptionalString(claims.email) ?? normalizeOptionalString(claims.name) ?? "Newsroom editor",
@@ -632,6 +559,8 @@ async function setReferenceQualityRating(event: Parameters<ReferenceQualityHandl
632
559
  curationStatusReason: note,
633
560
  newsroomFeedKey: reference.newsroomFeedKey ?? "references",
634
561
  reviewedFeedKey: reviewedFeedKeyForStatus(nextStatus),
562
+ // A vote moves the Reference between the unrated and rated feeds; its original prediction and arrival stay as they were.
563
+ feedStateKey: referenceFeedStateKey({ ...reference, curationStatus: nextStatus }),
635
564
  updatedAt: now,
636
565
  }),
637
566
  "update Reference quality status",
@@ -808,6 +737,7 @@ async function moveReferenceCorpus(event: Parameters<MoveReferenceCorpusHandler>
808
737
  client.models.Reference.update({
809
738
  id: normalizeRequiredString(current.id, "reference.id"),
810
739
  versionState: "superseded",
740
+ feedStateKey: null, // a superseded version is in no feed
811
741
  updatedAt: now,
812
742
  }),
813
743
  "supersede current Reference version",
@@ -845,8 +775,27 @@ async function moveReferenceCorpus(event: Parameters<MoveReferenceCorpusHandler>
845
775
  curationStatusUpdatedAt: normalizeOptionalString(current.curationStatusUpdatedAt) ?? now,
846
776
  curationStatusUpdatedBy: normalizeOptionalString(current.curationStatusUpdatedBy) ?? actor,
847
777
  curationStatusReason: normalizeOptionalString(current.curationStatusReason),
778
+ curationReasonCode: normalizeOptionalString(current.curationReasonCode),
779
+ curationActorKind: normalizeOptionalString(current.curationActorKind),
780
+ curationSectionId: normalizeOptionalString(current.curationSectionId),
781
+ curationShareable: typeof current.curationShareable === "boolean" ? current.curationShareable : null,
782
+ curationDelegation: current.curationDelegation ?? null,
783
+ curationDecisionRelationId: normalizeOptionalString(current.curationDecisionRelationId),
848
784
  newsroomFeedKey: normalizeOptionalString(current.newsroomFeedKey) ?? "references",
849
785
  reviewedFeedKey: normalizeOptionalString(current.reviewedFeedKey) ?? reviewedFeedKeyForStatus(curationStatus),
786
+ predictedAcceptProbability: typeof current.predictedAcceptProbability === "number" ? current.predictedAcceptProbability : null,
787
+ predictionDecidedAt: normalizeOptionalString(current.predictionDecidedAt),
788
+ ...referenceFeedKeys({
789
+ id: nextReferenceId,
790
+ corpusId: targetCorpusId,
791
+ curationStatus,
792
+ versionState: "current",
793
+ predictedAcceptProbability: typeof current.predictedAcceptProbability === "number" ? current.predictedAcceptProbability : null,
794
+ createdAt: normalizeOptionalString(current.createdAt) ?? now,
795
+ importedAt: normalizeOptionalString(current.importedAt),
796
+ retrievedAt: normalizeOptionalString(current.retrievedAt),
797
+ sourcePublishedAt: normalizeOptionalString(current.sourcePublishedAt),
798
+ }),
850
799
  updatedAt: now,
851
800
  }),
852
801
  "create moved Reference version",
@@ -2094,12 +2043,10 @@ const REFERENCE_REJECTION_REASON_CODES = new Set([
2094
2043
  "other",
2095
2044
  ]);
2096
2045
 
2097
- function normalizeReferenceRejectionReasonCode(value: unknown, required: boolean): string | null {
2046
+ /** A supplied reason code, validated. Whether a rejection has a reason at all is the caller's rule. */
2047
+ function normalizeReferenceRejectionReasonCode(value: unknown): string | null {
2098
2048
  const normalized = normalizePolicyToken(value);
2099
- if (!normalized) {
2100
- if (required) throw new Error("reasonCode is required when rejecting a reference.");
2101
- return null;
2102
- }
2049
+ if (!normalized) return null;
2103
2050
  if (!REFERENCE_REJECTION_REASON_CODES.has(normalized)) {
2104
2051
  throw new Error(`Unsupported reference rejection reason code ${normalized}.`);
2105
2052
  }
@@ -2161,9 +2108,6 @@ async function updateNewsroomSummaryForReferenceCuration(
2161
2108
  payload.generatedAt = input.now;
2162
2109
  payload.staleAt = new Date(Date.parse(input.now) + SUMMARY_STALE_AFTER_MS).toISOString();
2163
2110
  payload.source = "incremental";
2164
- payload.counts.messages = Math.max(0, (payload.counts.messages ?? 0) + 1);
2165
- payload.counts.modelAttachments = Math.max(0, (payload.counts.modelAttachments ?? 0) + 2);
2166
- payload.counts.semanticRelations = Math.max(0, (payload.counts.semanticRelations ?? 0) + 1);
2167
2111
  if (input.previousStatus !== input.nextStatus) {
2168
2112
  increment(payload.referenceStatusCounts, input.previousStatus, -1);
2169
2113
  increment(payload.referenceStatusCounts, input.nextStatus, 1);
@@ -2175,23 +2119,6 @@ async function updateNewsroomSummaryForReferenceCuration(
2175
2119
  increment(payload.facets.references.statusByCorpus[input.corpusId], input.nextStatus, 1);
2176
2120
  }
2177
2121
  }
2178
- increment(payload.messageKindCounts, "reference_curation", 1);
2179
- increment(payload.messageDomainCounts, "commentary", 1);
2180
- increment(payload.facets.messages.byKind, "reference_curation", 1);
2181
- increment(payload.facets.messages.byDomain, "commentary", 1);
2182
- increment(payload.facets.messages.byStatus, "active", 1);
2183
- if (!payload.facets.messages.domainByKind.reference_curation) payload.facets.messages.domainByKind.reference_curation = {};
2184
- increment(payload.facets.messages.domainByKind.reference_curation, "commentary", 1);
2185
- increment(payload.facets.modelAttachments.byOwnerKind, "message", 2);
2186
- increment(payload.facets.modelAttachments.byRole, "message_body", 1);
2187
- increment(payload.facets.modelAttachments.byRole, "metadata", 1);
2188
- increment(payload.facets.modelAttachments.byMediaType, "text/plain", 1);
2189
- increment(payload.facets.modelAttachments.byMediaType, "application/json", 1);
2190
- increment(payload.facets.modelAttachments.byStatus, "active", 2);
2191
- increment(payload.facets.semanticRelations.byRelationTypeKey, "comment", 1);
2192
- increment(payload.facets.semanticRelations.byRelationDomain, "workflow", 1);
2193
- increment(payload.facets.semanticRelations.bySubjectKind, "message", 1);
2194
- increment(payload.facets.semanticRelations.byObjectKind, "reference", 1);
2195
2122
  await upsertNewsroomSummaryPayload(client, payload, response.data, input.now);
2196
2123
  }
2197
2124
 
@@ -365,6 +365,14 @@ def handler(event, context):
365
365
  client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
366
366
  except IntakeError as error:
367
367
  return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
368
+ if field == "correctReferenceCapture":
369
+ from papyrus_content.reference_curation_store import CurationError, correct_reference_capture
370
+
371
+ client, _claims = create_authoring_client()
372
+ try:
373
+ return correct_reference_capture(client, arguments, event.get("identity") or {})
374
+ except CurationError as error:
375
+ return {"ok": False, "errors": [{"code": "correction-rejected", "message": str(error)}]}
368
376
  if field == "deriveMarkus":
369
377
  return derive_markus_action(arguments)
370
378
  if field == "saveItemDraft":
@@ -4,3 +4,5 @@ anthus-markus==0.7.0
4
4
  Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
6
6
  humanus>=0.33,<0.34
7
+ pypdf>=6.12.1,<7
8
+ youtube-transcript-api>=1.2.4,<2
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7", "youtube-transcript-api>=1.2.4,<2"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "awscrt>=0.23,<1", "humanus>=0.33,<0.34", "pypdf>=6.12.1,<7", "youtube-transcript-api>=1.2.4,<2"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
14
14
  .returns(a.json())
15
15
  .authorization((allow) => [allow.groups(editors)])
16
16
  .handler(functionHandler("monitoringView", monitoringView)),
17
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
18
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
19
+ monitoringPage: a
20
+ .query()
21
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
22
+ .returns(a.json())
23
+ .authorization((allow) => [allow.groups(editors)])
24
+ .handler(functionHandler("monitoringView", monitoringView)),
17
25
  monitoringEmailSubmission: a
18
26
  .query()
19
27
  .arguments({ receiptId: a.string().required() })
@@ -26,6 +34,14 @@ export const monitoringViewSchemaDefinitions = {
26
34
  .returns(a.json())
27
35
  .authorization((allow) => [allow.groups(editors)])
28
36
  .handler(functionHandler("monitoringView", monitoringView)),
37
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
38
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
39
+ monitoringDecideEmailSubmission: a
40
+ .mutation()
41
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
42
+ .returns(a.json())
43
+ .authorization((allow) => [allow.groups(editors)])
44
+ .handler(functionHandler("monitoringView", monitoringView)),
29
45
  monitoringResolveEmailArticleLink: a
30
46
  .mutation()
31
47
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
16
16
  .returns(a.json())
17
17
  .authorization((allow) => [allow.groups(editors)])
18
18
  .handler(functionHandler("monitoringView", monitoringView)),
19
+ // One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
20
+ // the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
21
+ monitoringPage: a
22
+ .query()
23
+ .arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
24
+ .returns(a.json())
25
+ .authorization((allow) => [allow.groups(editors)])
26
+ .handler(functionHandler("monitoringView", monitoringView)),
19
27
  monitoringEmailSubmission: a
20
28
  .query()
21
29
  .arguments({ receiptId: a.string().required() })
@@ -28,6 +36,14 @@ export const monitoringViewSchemaDefinitions = {
28
36
  .returns(a.json())
29
37
  .authorization((allow) => [allow.groups(editors)])
30
38
  .handler(functionHandler("monitoringView", monitoringView)),
39
+ // Accept ("release") or Reject ("discard") one held email submission. The function validates the
40
+ // signed-in editor and that the message is routed to this publication; the receiver applies it.
41
+ monitoringDecideEmailSubmission: a
42
+ .mutation()
43
+ .arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
44
+ .returns(a.json())
45
+ .authorization((allow) => [allow.groups(editors)])
46
+ .handler(functionHandler("monitoringView", monitoringView)),
31
47
  monitoringResolveEmailArticleLink: a
32
48
  .mutation()
33
49
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -3,6 +3,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
3
3
  import { Amplify } from "aws-amplify";
4
4
  import { generateClient } from "aws-amplify/data";
5
5
  import type { Schema } from "../../data/resource";
6
+ import { claimGroups } from "../../../lib/staff-groups";
6
7
 
7
8
  type DataClient = ReturnType<typeof generateClient<Schema>>;
8
9
  type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
@@ -474,20 +475,11 @@ function validateJsonSchemaInput(schema: Record<string, unknown>, input: Record<
474
475
 
475
476
  function requireAdmin(event: ProcedureEvent): void {
476
477
  const claims = objectValue(event.identity?.claims);
477
- const groups = [
478
- ...normalizeClaimGroups(claims["cognito:groups"]),
479
- ...normalizeClaimGroups(claims.groups),
480
- ];
478
+ const groups = [...claimGroups(claims["cognito:groups"]), ...claimGroups(claims.groups)];
481
479
  if (groups.includes("admin")) return;
482
480
  throw new Error("Admin role required.");
483
481
  }
484
482
 
485
- function normalizeClaimGroups(value: unknown): string[] {
486
- if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
487
- if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean);
488
- return [];
489
- }
490
-
491
483
  async function getDataClient(): Promise<DataClient> {
492
484
  if (!clientPromise) {
493
485
  clientPromise = (async () => {
@@ -92,6 +92,25 @@ def is_appsync_event(event: dict[str, Any]) -> bool:
92
92
  return "arguments" in event and ("info" in event or event_field_name(event) is not None)
93
93
  PROPOSER_AI = "ai"
94
94
  STAFF_GROUPS = frozenset({"editor", "admin"})
95
+
96
+ _GROUP_SEPARATORS = re.compile(r"[\s,]+")
97
+
98
+
99
+ def claim_groups(value: Any) -> list[str]:
100
+ """Copy of ``papyrus_content.staff_groups.claim_groups`` (this function ships without that package; a test keeps them equal)."""
101
+ if isinstance(value, (list, tuple, set, frozenset)):
102
+ raw = [item for item in value if isinstance(item, str)]
103
+ elif isinstance(value, str):
104
+ raw = _GROUP_SEPARATORS.split(value)
105
+ else:
106
+ return []
107
+ cleaned = (item.strip().strip("\"'[](){}").strip() for item in raw)
108
+ return [item for item in cleaned if item]
109
+
110
+
111
+ def has_editor_rights(groups: Any) -> bool:
112
+ """``admin`` has every editor right: an admin who is not in the ``editor`` group is staff."""
113
+ return any(group.lower() in STAFF_GROUPS for group in claim_groups(groups))
95
114
  #: The only moves an operator's IAM role may make, as (from, to). Everything after the draft is an editor's decision.
96
115
  OPERATOR_TRANSITIONS = frozenset({(DRAFT, IN_STEERING), (DRAFT, DECLINED)})
97
116
  BY_STAFF, BY_OPERATOR = "staff", "operator"
@@ -140,10 +159,8 @@ def staff_subject(identity: dict[str, Any]) -> str | None:
140
159
  """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
141
160
  claims = identity.get("claims") or {}
142
161
  groups = claims.get("cognito:groups") or identity.get("groups") or []
143
- if isinstance(groups, str):
144
- groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
145
162
  subject = identity.get("sub")
146
- if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
163
+ if not isinstance(subject, str) or not subject or not has_editor_rights(groups):
147
164
  return None
148
165
  return subject
149
166
 
@@ -1,23 +1,19 @@
1
1
  /**
2
- * Metadata stored with an editor's reference curation Message. When the
3
- * relevance cyclotron decided the reference, the review records which
4
- * decision it answered and whether its explanation may be shared (for the
5
- * Cyclotron marketing recording). Both are optional.
2
+ * The details of a vote, kept on the Reference itself (Messages come only from people).
3
+ * When the relevance cyclotron decided the reference, the vote records which decision
4
+ * it answered and whether its explanation may be shared (for the Cyclotron marketing
5
+ * recording). Both are optional. A Reference keeps only its latest vote.
6
6
  */
7
- export type ReferenceCurationMetadataInput = {
8
- action: string
9
- curationStatus: string
10
- reasonCode: string | null
11
- referenceId: string
12
- referenceLineageId: string
7
+ export type ReferenceVoteInput = {
8
+ actorKind: CurationActorKind
9
+ reasonCode?: string | null
10
+ sectionId?: string | null
13
11
  decisionRelationId?: string | null
14
12
  shareable?: boolean | null
15
- actorKind?: CurationActorKind | null
16
- sectionId?: string | null
17
13
  delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
18
14
  }
19
15
 
20
- export type CurationActorKind = "human" | "service"
16
+ export type CurationActorKind = "human" | "agent" | "cyclotron" | "rule" | "catalog"
21
17
 
22
18
  /**
23
19
  * A curation is human only when the caller signed in through the user pool, so
@@ -27,26 +23,22 @@ export type CurationActorKind = "human" | "service"
27
23
  export function curationActorKind(identity: unknown): CurationActorKind {
28
24
  const claims = (identity as {claims?: Record<string, unknown>} | null | undefined)?.claims
29
25
  const subject = claims?.sub
30
- return typeof subject === "string" && subject.trim() !== "" ? "human" : "service"
26
+ return typeof subject === "string" && subject.trim() !== "" ? "human" : "agent"
31
27
  }
32
28
 
33
- export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
29
+ /**
30
+ * The Reference fields a vote sets. Every field is always present, so a new vote clears
31
+ * what the last one carried. The delegation is stored as JSON.
32
+ */
33
+ export function referenceVoteFields(input: ReferenceVoteInput): Record<string, unknown> {
34
34
  const decisionRelationId = input.decisionRelationId?.trim() || null
35
35
  return {
36
- action: input.action,
37
- curationStatus: input.curationStatus,
38
- reasonCode: input.reasonCode,
39
- curationReasonCode: input.reasonCode,
40
- referenceId: input.referenceId,
41
- referenceLineageId: input.referenceLineageId,
42
- ...(input.actorKind ? {actorKind: input.actorKind} : {}),
43
- ...(input.sectionId?.trim() ? {sectionId: input.sectionId.trim()} : {}),
44
- ...(input.delegation
45
- ? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
46
- instruction: input.delegation.instruction}
47
- : {}),
48
- ...(decisionRelationId ? {decisionRelationId} : {}),
49
- ...(decisionRelationId ? {shareable: input.shareable === true} : {}),
36
+ curationActorKind: input.actorKind,
37
+ curationReasonCode: input.reasonCode?.trim() || null,
38
+ curationSectionId: input.sectionId?.trim() || null,
39
+ curationShareable: decisionRelationId ? input.shareable === true : null,
40
+ curationDelegation: input.delegation ? JSON.stringify(input.delegation) : null,
41
+ curationDecisionRelationId: decisionRelationId,
50
42
  }
51
43
  }
52
44