@anthusai/papyrus 5.57.0 → 5.59.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 (68) 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 +28 -3
  9. package/amplify/data/resource.ts +28 -3
  10. package/amplify/data/schema.js +34 -5
  11. package/amplify/data/schema.ts +34 -5
  12. package/amplify/functions/category-action/handler.ts +51 -125
  13. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  14. package/components/conversation-context.tsx +29 -2
  15. package/components/conversation-provider.tsx +5 -1
  16. package/components/conversation-thread.tsx +154 -25
  17. package/components/conversations-memory-source.ts +83 -0
  18. package/components/conversations-network-source.ts +52 -0
  19. package/components/conversations-page.tsx +14 -0
  20. package/components/conversations-source.ts +36 -0
  21. package/components/conversations-view.tsx +680 -0
  22. package/components/decision-control.tsx +307 -0
  23. package/components/list-detail-layout.tsx +44 -0
  24. package/components/needs-me-bodies.tsx +18 -17
  25. package/components/needs-me-feed.tsx +16 -32
  26. package/components/news-desk-taxonomy-client.ts +216 -62
  27. package/components/newsroom-article-editor.tsx +10 -4
  28. package/components/newsroom-organizer.css +34 -1
  29. package/components/newsroom-references-view.tsx +41 -100
  30. package/components/notification-cards.tsx +9 -8
  31. package/components/notification-center.tsx +5 -7
  32. package/components/proposals-view.tsx +4 -4
  33. package/components/reference-conversation.tsx +5 -0
  34. package/components/reference-feed.tsx +129 -127
  35. package/components/reference-review-card.tsx +9 -135
  36. package/components/references-page-loading.tsx +12 -6
  37. package/components/story-reporting-trail-client.ts +4 -3
  38. package/components/story-reporting-trail.tsx +8 -0
  39. package/components/topic-steering-workspace.tsx +44 -786
  40. package/components/use-conversation.ts +36 -2
  41. package/components/use-feed-preferences.ts +33 -0
  42. package/components/use-reference-ratings.ts +11 -7
  43. package/lib/category-repository.ts +14 -38
  44. package/lib/console-chat-client.ts +8 -178
  45. package/lib/conversation-feed.ts +96 -0
  46. package/lib/conversation.ts +73 -2
  47. package/lib/cyclotron-suggestions.ts +18 -36
  48. package/lib/local-preview-seed.ts +16 -27
  49. package/lib/needs-me-items.ts +4 -4
  50. package/lib/needs-me-ranking.ts +9 -22
  51. package/lib/newsroom-board.ts +10 -1
  52. package/lib/newsroom-demo-dashboard.ts +0 -7
  53. package/lib/newsroom-organizer-nav.ts +2 -2
  54. package/lib/post-message.ts +54 -0
  55. package/lib/proposals-admin.ts +2 -2
  56. package/lib/reference-feed-keys.ts +69 -0
  57. package/lib/reference-feed-ranking.ts +77 -160
  58. package/lib/reference-policy.ts +83 -11
  59. package/lib/reference-review-details.ts +21 -57
  60. package/lib/relevance-decisions.ts +6 -2
  61. package/lib/review-drafts.ts +2 -2
  62. package/lib/review-fixture.ts +1 -1
  63. package/lib/review-requests.ts +4 -4
  64. package/lib/semantic-graph.ts +1 -1
  65. package/lib/story-reporting-trail.ts +28 -2
  66. package/package.json +12 -2
  67. package/routes.manifest.json +1 -1
  68. package/components/review-rating-grid.tsx +0 -190
@@ -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({
@@ -1586,8 +1585,26 @@ const definitions = {
1586
1585
  curationStatusUpdatedAt: a.datetime(),
1587
1586
  curationStatusUpdatedBy: a.string(),
1588
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(),
1589
1594
  newsroomFeedKey: a.string(),
1590
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(),
1591
1608
  updatedAt: a.datetime(),
1592
1609
  })
1593
1610
  .secondaryIndexes((index) => [
@@ -1600,6 +1617,9 @@ const definitions = {
1600
1617
  index("curationStatus").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusAndUpdatedAt"),
1601
1618
  index("curationStatusKey").sortKeys(["curationStatusUpdatedAt"]).queryField("listReferencesByCurationStatusKeyAndUpdatedAt"),
1602
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"),
1603
1623
  ])
1604
1624
  .authorization((allow) => [
1605
1625
  allow.groups(categoryWriteGroups).to(["read"]),
@@ -1687,6 +1707,9 @@ const definitions = {
1687
1707
  authorSub: a.string(),
1688
1708
  authorUserProfileId: a.id(),
1689
1709
  authorLabel: a.string(),
1710
+ editedAt: a.datetime(),
1711
+ deletedAt: a.datetime(),
1712
+ deletedBySub: a.string(),
1690
1713
  threadId: a.id(),
1691
1714
  parentMessageId: a.id(),
1692
1715
  sequenceNumber: a.integer(),
@@ -1721,20 +1744,25 @@ const definitions = {
1721
1744
  index("searchVisibility").sortKeys(["createdAt"]).name("messagesBySearchVisibilityCreatedAt").queryField("listMessagesBySearchVisibilityAndCreatedAt"),
1722
1745
  ])
1723
1746
  .authorization((allow) => [
1724
- 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"]),
1725
1751
  ]),
1726
1752
 
1727
1753
  MessageThread: realtimeModel({
1728
1754
  id: a.id().required(),
1729
1755
  threadKind: a.string().required(),
1730
1756
  status: a.string().required(),
1731
- 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"])]),
1732
1760
  summary: a.string(),
1733
1761
  primaryAnchorKind: a.string(),
1734
1762
  primaryAnchorId: a.id(),
1735
1763
  primaryAnchorLineageId: a.id(),
1736
1764
  primaryAnchorKey: a.string(),
1737
- createdBySub: a.string(),
1765
+ createdBySub: a.string().authorization((allow) => [allow.group("editor").to(["read", "create"]), allow.group(adminGroup).to(["read", "create"])]),
1738
1766
  createdByUserProfileId: a.id(),
1739
1767
  createdByLabel: a.string(),
1740
1768
  messageCount: a.integer(),
@@ -2234,6 +2262,7 @@ const definitions = {
2234
2262
  ...monitoringViewSchemaDefinitions,
2235
2263
  ...automationViewSchemaDefinitions,
2236
2264
  ...proposalActionsSchemaDefinitions,
2265
+ ...messageActionsSchemaDefinitions,
2237
2266
  };
2238
2267
  if (!includeEditions) {
2239
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,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
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useContext, type ReactNode } from "react";
4
- import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
4
+ import { NO_VIEWER, canDeleteMessage, canEditMessage, type ConversationAnchor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
5
5
 
6
6
  /**
7
7
  * Where conversations are read and written. The newsroom provides the network one; a demo or a test provides the in-memory one. A
@@ -14,6 +14,11 @@ export type ConversationStore = {
14
14
  append: (input: { anchor: ConversationAnchor; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
15
15
  /** Message counts by Reference lineage, for the feed cards. */
16
16
  counts: () => Promise<Map<string, number>>;
17
+ /** Who is looking, and display names by profile id: what Edit, Delete and the author's name are decided from. */
18
+ viewer: () => Promise<ConversationViewer>;
19
+ authors: (profileIds: string[]) => Promise<Map<string, string>>;
20
+ edit: (messageId: string, body: string) => Promise<ConversationMessage>;
21
+ remove: (messageId: string, moderate: boolean) => Promise<ConversationMessage>;
17
22
  };
18
23
 
19
24
  const ConversationStoreContext = createContext<ConversationStore | null>(null);
@@ -27,11 +32,32 @@ export function useConversationStore(): ConversationStore | null {
27
32
  }
28
33
 
29
34
  /** A store that keeps everything in memory: the demo newsroom, and the tests. */
30
- export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}): ConversationStore {
35
+ export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}, who: { viewer?: ConversationViewer; profiles?: Record<string, string> } = {}): ConversationStore {
36
+ const viewer = who.viewer ?? NO_VIEWER;
31
37
  const byAnchor = new Map<string, ConversationMessage[]>(Object.entries(seed));
32
38
  const keyOf = (anchor: ConversationAnchor) => `${anchor.kind}#${anchor.lineageId ?? ""}`;
39
+ const change = (messageId: string, apply: (message: ConversationMessage) => ConversationMessage) => {
40
+ for (const [key, list] of byAnchor) {
41
+ const found = list.find((message) => message.id === messageId);
42
+ if (!found) continue;
43
+ const changed = apply(found);
44
+ byAnchor.set(key, list.map((message) => (message.id === messageId ? changed : message)));
45
+ return changed;
46
+ }
47
+ throw new Error("That message is not here.");
48
+ };
33
49
  return {
34
50
  authorLabel,
51
+ viewer: async () => viewer,
52
+ authors: async (ids) => new Map(ids.filter((id) => who.profiles?.[id]).map((id) => [id, who.profiles![id]])),
53
+ edit: async (messageId, body) => change(messageId, (message) => {
54
+ if (!canEditMessage(viewer, message)) throw new Error("Only the author can edit a message.");
55
+ return { ...message, content: body.trim(), editedAt: new Date().toISOString() };
56
+ }),
57
+ remove: async (messageId) => change(messageId, (message) => {
58
+ if (!canDeleteMessage(viewer, message)) throw new Error("Only the author or an admin can delete a message.");
59
+ return { ...message, content: "", deleted: true, deletedAt: new Date().toISOString() };
60
+ }),
35
61
  load: async (anchor) => [...(byAnchor.get(keyOf(anchor)) ?? [])],
36
62
  append: async ({ anchor, body, parentMessageId }) => {
37
63
  const list = byAnchor.get(keyOf(anchor)) ?? [];
@@ -40,6 +66,7 @@ export function createMemoryConversationStore(authorLabel: string, seed: Record<
40
66
  id: `message-memory-${sequenceNumber}-${list.length}`,
41
67
  parentMessageId,
42
68
  authorLabel,
69
+ authorSub: viewer.sub,
43
70
  content: body.trim(),
44
71
  createdAt: new Date().toISOString(),
45
72
  sequenceNumber,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useMemo, type ReactNode } from "react";
4
4
  import { ConversationStoreProvider, createMemoryConversationStore, type ConversationStore } from "./conversation-context";
5
- import { appendConversationMessage, loadConversationMessages, loadReferenceConversationCounts } from "./news-desk-taxonomy-client";
5
+ import { appendConversationMessage, deleteConversationMessage, editConversationMessage, loadConversationMessages, loadConversationViewer, loadReferenceConversationCounts, loadUserProfileNames } from "./news-desk-taxonomy-client";
6
6
 
7
7
  function networkConversationStore(authorLabel: string): ConversationStore {
8
8
  return {
@@ -10,6 +10,10 @@ function networkConversationStore(authorLabel: string): ConversationStore {
10
10
  load: loadConversationMessages,
11
11
  append: async ({ anchor, body, parentMessageId }) => (await appendConversationMessage({ anchor, body, parentMessageId })).message,
12
12
  counts: loadReferenceConversationCounts,
13
+ viewer: loadConversationViewer,
14
+ authors: loadUserProfileNames,
15
+ edit: editConversationMessage,
16
+ remove: (messageId, moderate) => deleteConversationMessage(messageId, moderate),
13
17
  };
14
18
  }
15
19