@anthusai/papyrus 5.57.0 → 5.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/amplify/data/resource.js +25 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +25 -3
  4. package/amplify/data/schema.ts +25 -3
  5. package/amplify/functions/category-action/handler.ts +51 -125
  6. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  7. package/components/conversation-thread.tsx +27 -12
  8. package/components/conversations-memory-source.ts +46 -0
  9. package/components/conversations-network-source.ts +40 -0
  10. package/components/conversations-page.tsx +14 -0
  11. package/components/conversations-source.ts +24 -0
  12. package/components/conversations-view.tsx +319 -0
  13. package/components/decision-control.tsx +307 -0
  14. package/components/needs-me-bodies.tsx +4 -3
  15. package/components/needs-me-feed.tsx +8 -14
  16. package/components/news-desk-taxonomy-client.ts +83 -14
  17. package/components/newsroom-organizer.css +24 -0
  18. package/components/notification-cards.tsx +5 -4
  19. package/components/reference-feed.tsx +60 -42
  20. package/components/reference-review-card.tsx +8 -134
  21. package/components/story-reporting-trail-client.ts +4 -3
  22. package/components/story-reporting-trail.tsx +8 -0
  23. package/components/topic-steering-workspace.tsx +40 -782
  24. package/components/use-reference-ratings.ts +11 -7
  25. package/lib/category-repository.ts +7 -0
  26. package/lib/conversation-feed.ts +96 -0
  27. package/lib/cyclotron-suggestions.ts +18 -36
  28. package/lib/local-preview-seed.ts +16 -27
  29. package/lib/reference-feed-keys.ts +69 -0
  30. package/lib/reference-policy.ts +83 -11
  31. package/lib/reference-review-details.ts +21 -57
  32. package/lib/relevance-decisions.ts +6 -2
  33. package/lib/story-reporting-trail.ts +28 -2
  34. package/package.json +9 -2
  35. package/routes.manifest.json +1 -1
  36. package/components/review-rating-grid.tsx +0 -190
@@ -247,8 +247,6 @@ const schema = a.schema({
247
247
  referenceId: a.id().required(),
248
248
  status: a.string().required(),
249
249
  reasonCode: a.string(),
250
- messageId: a.id(),
251
- relationId: a.id(),
252
250
  }),
253
251
  ReferenceQualityActionResult: a.customType({
254
252
  ok: a.boolean().required(),
@@ -1569,8 +1567,23 @@ const schema = a.schema({
1569
1567
  curationStatusUpdatedAt: a.datetime(),
1570
1568
  curationStatusUpdatedBy: a.string(),
1571
1569
  curationStatusReason: a.string(),
1570
+ curationReasonCode: a.string(),
1571
+ curationActorKind: a.string(),
1572
+ curationSectionId: a.string(),
1573
+ curationShareable: a.boolean(),
1574
+ curationDelegation: a.json(),
1575
+ curationDecisionRelationId: a.string(),
1572
1576
  newsroomFeedKey: a.string(),
1573
1577
  reviewedFeedKey: a.string(),
1578
+ // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1579
+ // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1580
+ // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
1581
+ // P(accept), written once, and never rewritten by a vote.
1582
+ feedStateKey: a.string(),
1583
+ feedArrivalAt: a.datetime(),
1584
+ predictedAcceptProbability: a.float(),
1585
+ predictionDecidedAt: a.datetime(),
1586
+ predictionSortKey: a.string(),
1574
1587
  updatedAt: a.datetime(),
1575
1588
  })
1576
1589
  .secondaryIndexes((index) => [
@@ -1583,6 +1596,8 @@ const schema = a.schema({
1583
1596
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1584
1597
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1585
1598
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1599
+ index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1600
+ index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1586
1601
  ])
1587
1602
  .authorization((allow) => [
1588
1603
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1670,6 +1685,9 @@ const schema = a.schema({
1670
1685
  authorSub: a.string(),
1671
1686
  authorUserProfileId: a.id(),
1672
1687
  authorLabel: a.string(),
1688
+ editedAt: a.datetime(),
1689
+ deletedAt: a.datetime(),
1690
+ deletedBySub: a.string(),
1673
1691
  threadId: a.id(),
1674
1692
  parentMessageId: a.id(),
1675
1693
  sequenceNumber: a.integer(),
@@ -1704,8 +1722,12 @@ const schema = a.schema({
1704
1722
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1705
1723
  ])
1706
1724
  .authorization((allow) => [
1707
- allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
1725
+ // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1726
+ allow.group("editor").to(categoryAppendOnlyOperations),
1727
+ allow.group(adminGroup).to(["read", "create", "delete"]),
1708
1728
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1729
+ // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1730
+ allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1709
1731
  ]),
1710
1732
  MessageThread: a
1711
1733
  .model({
@@ -267,8 +267,6 @@ const schema = a.schema({
267
267
  referenceId: a.id().required(),
268
268
  status: a.string().required(),
269
269
  reasonCode: a.string(),
270
- messageId: a.id(),
271
- relationId: a.id(),
272
270
  }),
273
271
 
274
272
  ReferenceQualityActionResult: a.customType({
@@ -1682,8 +1680,23 @@ const schema = a.schema({
1682
1680
  curationStatusUpdatedAt: a.datetime(),
1683
1681
  curationStatusUpdatedBy: a.string(),
1684
1682
  curationStatusReason: a.string(),
1683
+ curationReasonCode: a.string(),
1684
+ curationActorKind: a.string(),
1685
+ curationSectionId: a.string(),
1686
+ curationShareable: a.boolean(),
1687
+ curationDelegation: a.json(),
1688
+ curationDecisionRelationId: a.string(),
1685
1689
  newsroomFeedKey: a.string(),
1686
1690
  reviewedFeedKey: a.string(),
1691
+ // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1692
+ // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1693
+ // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
1694
+ // P(accept), written once, and never rewritten by a vote.
1695
+ feedStateKey: a.string(),
1696
+ feedArrivalAt: a.datetime(),
1697
+ predictedAcceptProbability: a.float(),
1698
+ predictionDecidedAt: a.datetime(),
1699
+ predictionSortKey: a.string(),
1687
1700
  updatedAt: a.datetime(),
1688
1701
  })
1689
1702
  .secondaryIndexes((index) => [
@@ -1696,6 +1709,8 @@ const schema = a.schema({
1696
1709
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1697
1710
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1698
1711
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1712
+ index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1713
+ index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1699
1714
  ])
1700
1715
  .authorization((allow) => [
1701
1716
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1786,6 +1801,9 @@ const schema = a.schema({
1786
1801
  authorSub: a.string(),
1787
1802
  authorUserProfileId: a.id(),
1788
1803
  authorLabel: a.string(),
1804
+ editedAt: a.datetime(),
1805
+ deletedAt: a.datetime(),
1806
+ deletedBySub: a.string(),
1789
1807
  threadId: a.id(),
1790
1808
  parentMessageId: a.id(),
1791
1809
  sequenceNumber: a.integer(),
@@ -1820,8 +1838,12 @@ const schema = a.schema({
1820
1838
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1821
1839
  ])
1822
1840
  .authorization((allow) => [
1823
- allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
1841
+ // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1842
+ allow.group("editor").to(categoryAppendOnlyOperations),
1843
+ allow.group(adminGroup).to(["read", "create", "delete"]),
1824
1844
  allow.authenticated("identityPool").to(["read", "create", "update"]),
1845
+ // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1846
+ allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1825
1847
  ]),
1826
1848
 
1827
1849
  MessageThread: a
@@ -259,8 +259,6 @@ function buildBaseDefinitions(includeEditions) {
259
259
  referenceId: a.id().required(),
260
260
  status: a.string().required(),
261
261
  reasonCode: a.string(),
262
- messageId: a.id(),
263
- relationId: a.id(),
264
262
  }),
265
263
  ReferenceQualityActionResult: a.customType({
266
264
  ok: a.boolean().required(),
@@ -1475,8 +1473,23 @@ function buildBaseDefinitions(includeEditions) {
1475
1473
  curationStatusUpdatedAt: a.datetime(),
1476
1474
  curationStatusUpdatedBy: a.string(),
1477
1475
  curationStatusReason: a.string(),
1476
+ curationReasonCode: a.string(),
1477
+ curationActorKind: a.string(),
1478
+ curationSectionId: a.string(),
1479
+ curationShareable: a.boolean(),
1480
+ curationDelegation: a.json(),
1481
+ curationDecisionRelationId: a.string(),
1478
1482
  newsroomFeedKey: a.string(),
1479
1483
  reviewedFeedKey: a.string(),
1484
+ // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1485
+ // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1486
+ // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
1487
+ // P(accept), written once, and never rewritten by a vote.
1488
+ feedStateKey: a.string(),
1489
+ feedArrivalAt: a.datetime(),
1490
+ predictedAcceptProbability: a.float(),
1491
+ predictionDecidedAt: a.datetime(),
1492
+ predictionSortKey: a.string(),
1480
1493
  updatedAt: a.datetime(),
1481
1494
  })
1482
1495
  .secondaryIndexes((index) => [
@@ -1489,6 +1502,8 @@ function buildBaseDefinitions(includeEditions) {
1489
1502
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1490
1503
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1491
1504
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1505
+ index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1506
+ index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1492
1507
  ])
1493
1508
  .authorization((allow) => [
1494
1509
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1573,6 +1588,9 @@ function buildBaseDefinitions(includeEditions) {
1573
1588
  authorSub: a.string(),
1574
1589
  authorUserProfileId: a.id(),
1575
1590
  authorLabel: a.string(),
1591
+ editedAt: a.datetime(),
1592
+ deletedAt: a.datetime(),
1593
+ deletedBySub: a.string(),
1576
1594
  threadId: a.id(),
1577
1595
  parentMessageId: a.id(),
1578
1596
  sequenceNumber: a.integer(),
@@ -1607,7 +1625,11 @@ function buildBaseDefinitions(includeEditions) {
1607
1625
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1608
1626
  ])
1609
1627
  .authorization((allow) => [
1610
- allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
1628
+ // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1629
+ allow.group("editor").to(categoryAppendOnlyOperations),
1630
+ allow.group(adminGroup).to(["read", "create", "delete"]),
1631
+ // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1632
+ allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1611
1633
  ]),
1612
1634
  MessageThread: realtimeModel({
1613
1635
  id: a.id().required(),
@@ -285,8 +285,6 @@ const definitions = {
285
285
  referenceId: a.id().required(),
286
286
  status: a.string().required(),
287
287
  reasonCode: a.string(),
288
- messageId: a.id(),
289
- relationId: a.id(),
290
288
  }),
291
289
 
292
290
  ReferenceQualityActionResult: a.customType({
@@ -1586,8 +1584,23 @@ const definitions = {
1586
1584
  curationStatusUpdatedAt: a.datetime(),
1587
1585
  curationStatusUpdatedBy: a.string(),
1588
1586
  curationStatusReason: a.string(),
1587
+ curationReasonCode: a.string(),
1588
+ curationActorKind: a.string(),
1589
+ curationSectionId: a.string(),
1590
+ curationShareable: a.boolean(),
1591
+ curationDelegation: a.json(),
1592
+ curationDecisionRelationId: a.string(),
1589
1593
  newsroomFeedKey: a.string(),
1590
1594
  reviewedFeedKey: a.string(),
1595
+ // The References page reads the feed through two indexes (docs/reference-feed-indexes.md). `feedStateKey` is
1596
+ // "<corpusId>#unrated" or "<corpusId>#rated" for a current, not archived Reference (null otherwise: it is in no feed).
1597
+ // `predictionSortKey` orders Best bets, `feedArrivalAt` orders Newest. The prediction is the Cyclotron's ORIGINAL
1598
+ // P(accept), written once, and never rewritten by a vote.
1599
+ feedStateKey: a.string(),
1600
+ feedArrivalAt: a.datetime(),
1601
+ predictedAcceptProbability: a.float(),
1602
+ predictionDecidedAt: a.datetime(),
1603
+ predictionSortKey: a.string(),
1591
1604
  updatedAt: a.datetime(),
1592
1605
  })
1593
1606
  .secondaryIndexes((index) => [
@@ -1600,6 +1613,8 @@ const definitions = {
1600
1613
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1601
1614
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1602
1615
  index("reviewedFeedKey").sortKeys(["updatedAt"]).queryField("listReferencesByReviewedFeedAndUpdatedAt"),
1616
+ index("feedStateKey").sortKeys(["predictionSortKey"]).queryField("listReferencesByFeedStateAndPrediction"),
1617
+ index("feedStateKey").sortKeys(["feedArrivalAt"]).queryField("listReferencesByFeedStateAndArrival"),
1603
1618
  ])
1604
1619
  .authorization((allow) => [
1605
1620
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1687,6 +1702,9 @@ const definitions = {
1687
1702
  authorSub: a.string(),
1688
1703
  authorUserProfileId: a.id(),
1689
1704
  authorLabel: a.string(),
1705
+ editedAt: a.datetime(),
1706
+ deletedAt: a.datetime(),
1707
+ deletedBySub: a.string(),
1690
1708
  threadId: a.id(),
1691
1709
  parentMessageId: a.id(),
1692
1710
  sequenceNumber: a.integer(),
@@ -1721,7 +1739,11 @@ const definitions = {
1721
1739
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1722
1740
  ])
1723
1741
  .authorization((allow) => [
1724
- allow.groups(categoryWriteGroups).to(categoryAppendOnlyOperations),
1742
+ // One rule per group (a group may appear once per model): editors read and add; admins also delete, for moderation.
1743
+ allow.group("editor").to(categoryAppendOnlyOperations),
1744
+ allow.group(adminGroup).to(["read", "create", "delete"]),
1745
+ // Messages belong to their author: the author edits (and soft-deletes) their own, and only an admin can remove someone else's.
1746
+ allow.ownerDefinedIn("authorSub").identityClaim("sub").to(["read", "update", "delete"]),
1725
1747
  ]),
1726
1748
 
1727
1749
  MessageThread: realtimeModel({
@@ -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,8 @@ 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";
27
29
  import { claimGroups } from "../../../lib/staff-groups";
28
30
 
29
31
  type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
@@ -214,7 +216,15 @@ async function reviewReferenceCuration(
214
216
  const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
215
217
  const action = normalizeReferenceCurationAction(event.arguments.action);
216
218
  const nextStatus = referenceCurationStatusForAction(action);
217
- 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
+ }
218
228
  const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
219
229
  const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
220
230
  const sectionRecord = requestedSectionId
@@ -225,18 +235,11 @@ async function reviewReferenceCuration(
225
235
  const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
226
236
  const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
227
237
  const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
228
- const note = normalizeOptionalString(event.arguments.note);
229
- const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
230
- const referenceVersionNumber = typeof reference.versionNumber === "number" ? reference.versionNumber : null;
231
- const referenceTitle = normalizeOptionalString(reference.title) ?? normalizeOptionalString(reference.externalItemId) ?? referenceId;
232
- const messageId = `message-reference-curation-${safeId(referenceLineageId)}-${safeId(action)}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
233
- const relationId = `semantic-relation-${hashStable([
234
- `message#${messageId}`,
235
- "comment",
236
- `reference#${referenceId}`,
237
- action,
238
- ]).slice(0, 16)}`;
238
+ const note = writtenReason;
239
+ const decisionRelationId = normalizeOptionalString(event.arguments.decisionRelationId);
239
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.
240
243
  await requireDataResult(
241
244
  client.models.Reference.update({
242
245
  id: referenceId,
@@ -245,98 +248,23 @@ async function reviewReferenceCuration(
245
248
  curationStatusUpdatedAt: now,
246
249
  curationStatusUpdatedBy: actor,
247
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
+ }),
248
259
  newsroomFeedKey: reference.newsroomFeedKey ?? "references",
249
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 }),
250
263
  updatedAt: now,
251
264
  }),
252
265
  "update Reference curation status",
253
266
  );
254
267
 
255
- await requireDataResult(
256
- client.models.Message.create(buildCanonicalMessageInput({
257
- id: messageId,
258
- messageKind: "reference_curation",
259
- messageDomain: "commentary",
260
- summary: `${referenceTitle}: ${nextStatus}`,
261
- source: "newsroom",
262
- authorSub: actorSub,
263
- authorLabel: actor,
264
- now,
265
- })),
266
- "create Message",
267
- );
268
- await putTextModelPayload(
269
- client as any,
270
- { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
271
- "message_body",
272
- "message",
273
- note ?? `${actor} marked this reference ${nextStatus}.`,
274
- { filename: "message.txt", now },
275
- );
276
- await putJsonModelPayload(
277
- client as any,
278
- { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
279
- "metadata",
280
- "metadata",
281
- referenceCurationMetadata({
282
- action,
283
- curationStatus: nextStatus,
284
- reasonCode,
285
- referenceId,
286
- referenceLineageId,
287
- decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
288
- shareable: event.arguments.shareable ?? null,
289
- actorKind: delegation ? "human" : curationActorKind(event.identity),
290
- sectionId,
291
- delegation,
292
- }),
293
- { filename: "metadata.json", now },
294
- );
295
-
296
- await requireDataResult(
297
- client.models.SemanticRelation.create({
298
- id: relationId,
299
- relationState: "current",
300
- predicate: "comment",
301
- relationTypeId: "semantic-relation-type-comment",
302
- relationTypeKey: "comment",
303
- relationDomain: "commentary",
304
- subjectKind: "message",
305
- subjectId: messageId,
306
- subjectLineageId: messageId,
307
- subjectVersionNumber: 1,
308
- objectKind: "reference",
309
- objectId: referenceId,
310
- objectLineageId: referenceLineageId,
311
- objectVersionNumber: referenceVersionNumber,
312
- subjectStateKey: semanticStateKey("message", messageId),
313
- objectStateKey: semanticStateKey("reference", referenceLineageId),
314
- objectSubjectStateKey: `${semanticStateKey("reference", referenceLineageId)}#message`,
315
- predicateObjectStateKey: `comment#${semanticStateKey("reference", referenceLineageId)}`,
316
- subjectVersionKey: semanticVersionKey("message", messageId),
317
- objectVersionKey: semanticVersionKey("reference", referenceId),
318
- score: 1,
319
- confidence: null,
320
- rank: 1,
321
- classifierId: null,
322
- modelVersion: null,
323
- reviewRecommended: false,
324
- sourceSnapshotId: null,
325
- importRunId: null,
326
- importedAt: now,
327
- createdAt: now,
328
- updatedAt: now,
329
- newsroomFeedKey: "semanticRelations",
330
- metadata: JSON.stringify({
331
- action,
332
- curationStatus: nextStatus,
333
- reasonCode,
334
- messageKind: "reference_curation",
335
- }),
336
- }),
337
- "create Message SemanticRelation",
338
- );
339
-
340
268
  await updateNewsroomSummaryForReferenceCuration(client, {
341
269
  corpusId: normalizeOptionalString(reference.corpusId),
342
270
  previousStatus: normalizeOptionalString(reference.curationStatus) ?? "pending",
@@ -350,8 +278,6 @@ async function reviewReferenceCuration(
350
278
  referenceId,
351
279
  status: nextStatus,
352
280
  reasonCode,
353
- messageId,
354
- relationId,
355
281
  };
356
282
  }
357
283
 
@@ -633,6 +559,8 @@ async function setReferenceQualityRating(event: Parameters<ReferenceQualityHandl
633
559
  curationStatusReason: note,
634
560
  newsroomFeedKey: reference.newsroomFeedKey ?? "references",
635
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 }),
636
564
  updatedAt: now,
637
565
  }),
638
566
  "update Reference quality status",
@@ -809,6 +737,7 @@ async function moveReferenceCorpus(event: Parameters<MoveReferenceCorpusHandler>
809
737
  client.models.Reference.update({
810
738
  id: normalizeRequiredString(current.id, "reference.id"),
811
739
  versionState: "superseded",
740
+ feedStateKey: null, // a superseded version is in no feed
812
741
  updatedAt: now,
813
742
  }),
814
743
  "supersede current Reference version",
@@ -846,8 +775,27 @@ async function moveReferenceCorpus(event: Parameters<MoveReferenceCorpusHandler>
846
775
  curationStatusUpdatedAt: normalizeOptionalString(current.curationStatusUpdatedAt) ?? now,
847
776
  curationStatusUpdatedBy: normalizeOptionalString(current.curationStatusUpdatedBy) ?? actor,
848
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),
849
784
  newsroomFeedKey: normalizeOptionalString(current.newsroomFeedKey) ?? "references",
850
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
+ }),
851
799
  updatedAt: now,
852
800
  }),
853
801
  "create moved Reference version",
@@ -2095,12 +2043,10 @@ const REFERENCE_REJECTION_REASON_CODES = new Set([
2095
2043
  "other",
2096
2044
  ]);
2097
2045
 
2098
- 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 {
2099
2048
  const normalized = normalizePolicyToken(value);
2100
- if (!normalized) {
2101
- if (required) throw new Error("reasonCode is required when rejecting a reference.");
2102
- return null;
2103
- }
2049
+ if (!normalized) return null;
2104
2050
  if (!REFERENCE_REJECTION_REASON_CODES.has(normalized)) {
2105
2051
  throw new Error(`Unsupported reference rejection reason code ${normalized}.`);
2106
2052
  }
@@ -2162,9 +2108,6 @@ async function updateNewsroomSummaryForReferenceCuration(
2162
2108
  payload.generatedAt = input.now;
2163
2109
  payload.staleAt = new Date(Date.parse(input.now) + SUMMARY_STALE_AFTER_MS).toISOString();
2164
2110
  payload.source = "incremental";
2165
- payload.counts.messages = Math.max(0, (payload.counts.messages ?? 0) + 1);
2166
- payload.counts.modelAttachments = Math.max(0, (payload.counts.modelAttachments ?? 0) + 2);
2167
- payload.counts.semanticRelations = Math.max(0, (payload.counts.semanticRelations ?? 0) + 1);
2168
2111
  if (input.previousStatus !== input.nextStatus) {
2169
2112
  increment(payload.referenceStatusCounts, input.previousStatus, -1);
2170
2113
  increment(payload.referenceStatusCounts, input.nextStatus, 1);
@@ -2176,23 +2119,6 @@ async function updateNewsroomSummaryForReferenceCuration(
2176
2119
  increment(payload.facets.references.statusByCorpus[input.corpusId], input.nextStatus, 1);
2177
2120
  }
2178
2121
  }
2179
- increment(payload.messageKindCounts, "reference_curation", 1);
2180
- increment(payload.messageDomainCounts, "commentary", 1);
2181
- increment(payload.facets.messages.byKind, "reference_curation", 1);
2182
- increment(payload.facets.messages.byDomain, "commentary", 1);
2183
- increment(payload.facets.messages.byStatus, "active", 1);
2184
- if (!payload.facets.messages.domainByKind.reference_curation) payload.facets.messages.domainByKind.reference_curation = {};
2185
- increment(payload.facets.messages.domainByKind.reference_curation, "commentary", 1);
2186
- increment(payload.facets.modelAttachments.byOwnerKind, "message", 2);
2187
- increment(payload.facets.modelAttachments.byRole, "message_body", 1);
2188
- increment(payload.facets.modelAttachments.byRole, "metadata", 1);
2189
- increment(payload.facets.modelAttachments.byMediaType, "text/plain", 1);
2190
- increment(payload.facets.modelAttachments.byMediaType, "application/json", 1);
2191
- increment(payload.facets.modelAttachments.byStatus, "active", 2);
2192
- increment(payload.facets.semanticRelations.byRelationTypeKey, "comment", 1);
2193
- increment(payload.facets.semanticRelations.byRelationDomain, "workflow", 1);
2194
- increment(payload.facets.semanticRelations.bySubjectKind, "message", 1);
2195
- increment(payload.facets.semanticRelations.byObjectKind, "reference", 1);
2196
2122
  await upsertNewsroomSummaryPayload(client, payload, response.data, input.now);
2197
2123
  }
2198
2124
 
@@ -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