@anthusai/papyrus 0.0.0 → 1.0.0-next.10

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 (282) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,2259 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtime";
3
+ import { Amplify } from "aws-amplify";
4
+ import { generateClient } from "aws-amplify/data";
5
+ import type { Schema } from "../../data/resource";
6
+ import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
7
+
8
+ type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
9
+ type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
10
+ type ReferenceQualityHandler = Schema["setReferenceQualityRating"]["functionHandler"];
11
+ type ReferenceInsightHandler = Schema["createReferenceInsight"]["functionHandler"];
12
+ type MoveReferenceCorpusHandler = Schema["moveReferenceCorpus"]["functionHandler"];
13
+ type StartReferenceCurationHandler = Schema["startReferenceCuration"]["functionHandler"];
14
+ type GetReferenceCurationStatusHandler = Schema["getReferenceCurationStatus"]["functionHandler"];
15
+ type CreateCategorySetDraftHandler = Schema["createCategorySetDraft"]["functionHandler"];
16
+ type PromoteCategorySetDraftHandler = Schema["promoteCategorySetDraft"]["functionHandler"];
17
+ type DiscardCategorySetDraftHandler = Schema["discardCategorySetDraft"]["functionHandler"];
18
+ type CreateDraftCategoryHandler = Schema["createDraftCategory"]["functionHandler"];
19
+ type UpdateDraftCategoryHandler = Schema["updateDraftCategory"]["functionHandler"];
20
+ type ArchiveDraftCategoryHandler = Schema["archiveDraftCategory"]["functionHandler"];
21
+ type ReviewReferenceTopicLabelHandler = Schema["reviewReferenceTopicLabel"]["functionHandler"];
22
+ type CategoryActionEvent =
23
+ | Parameters<ReviewHandler>[0]
24
+ | Parameters<ReferenceCurationHandler>[0]
25
+ | Parameters<ReferenceQualityHandler>[0]
26
+ | Parameters<ReferenceInsightHandler>[0]
27
+ | Parameters<MoveReferenceCorpusHandler>[0]
28
+ | Parameters<StartReferenceCurationHandler>[0]
29
+ | Parameters<GetReferenceCurationStatusHandler>[0]
30
+ | Parameters<CreateCategorySetDraftHandler>[0]
31
+ | Parameters<PromoteCategorySetDraftHandler>[0]
32
+ | Parameters<DiscardCategorySetDraftHandler>[0]
33
+ | Parameters<CreateDraftCategoryHandler>[0]
34
+ | Parameters<UpdateDraftCategoryHandler>[0]
35
+ | Parameters<ArchiveDraftCategoryHandler>[0]
36
+ | Parameters<ReviewReferenceTopicLabelHandler>[0];
37
+ type DataClient = ReturnType<typeof generateClient<Schema>>;
38
+ type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
39
+ type DataClientResult<T = unknown> = {
40
+ data?: T | null;
41
+ errors?: DataClientErrors;
42
+ };
43
+
44
+ const CATEGORY_PROPOSAL_KINDS = new Set([
45
+ "new-category",
46
+ "rename-category",
47
+ "merge-category",
48
+ "deprecate-category",
49
+ "seed-change",
50
+ "holdout-change",
51
+ "category-display-copy-edit",
52
+ "category-copy-edit",
53
+ "display-copy-edit",
54
+ "create-category",
55
+ "move-category",
56
+ "archive-category",
57
+ ]);
58
+ const NEWSROOM_SUMMARY_PAYLOAD_ID = "knowledge-raw-payload-newsroom-summary-current";
59
+ const NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND = "knowledgeRawPayload";
60
+ const SUMMARY_STALE_AFTER_MS = 15 * 60 * 1000;
61
+ const REFERENCE_CURATION_ASSIGNMENT_TYPE = "curation.reference-refresh";
62
+ const REFERENCE_CURATION_STAGE_KEYS = [
63
+ "identifier",
64
+ "publicationDate",
65
+ "titleSubtitle",
66
+ "summary",
67
+ "topicPredictions",
68
+ ] as const;
69
+ const DEFAULT_REFERENCE_CURATION_POLICY = {
70
+ recomputeIdentifier: true,
71
+ recomputePublicationDate: true,
72
+ recomputeTitle: true,
73
+ recomputeSubtitle: true,
74
+ recomputeSummary: true,
75
+ recomputeTopicPredictions: true,
76
+ recomputeQuality: false,
77
+ recomputeCorpus: false,
78
+ };
79
+ const REFERENCE_REVIEWED_FEED_KEY = "references#reviewed";
80
+
81
+ let clientPromise: Promise<DataClient> | null = null;
82
+
83
+ export const handler = async (event: CategoryActionEvent) => {
84
+ const fieldName = normalizeOptionalString(event.info?.fieldName) ?? normalizeOptionalString((event as { fieldName?: string | null }).fieldName);
85
+ if (!fieldName) {
86
+ throw new Error("Unsupported steering action: missing GraphQL field name.");
87
+ }
88
+ if (fieldName === "reviewSteeringProposal") return reviewSteeringProposal(event as Parameters<ReviewHandler>[0]);
89
+ if (fieldName === "reviewReferenceCuration") return reviewReferenceCuration(event as Parameters<ReferenceCurationHandler>[0]);
90
+ if (fieldName === "setReferenceQualityRating") return setReferenceQualityRating(event as Parameters<ReferenceQualityHandler>[0]);
91
+ if (fieldName === "createReferenceInsight") return createReferenceInsight(event as Parameters<ReferenceInsightHandler>[0]);
92
+ if (fieldName === "moveReferenceCorpus") return moveReferenceCorpus(event as Parameters<MoveReferenceCorpusHandler>[0]);
93
+ if (fieldName === "startReferenceCuration") return startReferenceCuration(event as Parameters<StartReferenceCurationHandler>[0]);
94
+ if (fieldName === "getReferenceCurationStatus") return getReferenceCurationStatus(event as Parameters<GetReferenceCurationStatusHandler>[0]);
95
+ if (fieldName === "createCategorySetDraft") return createCategorySetDraft(event as Parameters<CreateCategorySetDraftHandler>[0]);
96
+ if (fieldName === "promoteCategorySetDraft") return promoteCategorySetDraft(event as Parameters<PromoteCategorySetDraftHandler>[0]);
97
+ if (fieldName === "discardCategorySetDraft") return discardCategorySetDraft(event as Parameters<DiscardCategorySetDraftHandler>[0]);
98
+ if (fieldName === "createDraftCategory") return createDraftCategory(event as Parameters<CreateDraftCategoryHandler>[0]);
99
+ if (fieldName === "updateDraftCategory") return updateDraftCategory(event as Parameters<UpdateDraftCategoryHandler>[0]);
100
+ if (fieldName === "archiveDraftCategory") return archiveDraftCategory(event as Parameters<ArchiveDraftCategoryHandler>[0]);
101
+ if (fieldName === "reviewReferenceTopicLabel") return reviewReferenceTopicLabel(event as Parameters<ReviewReferenceTopicLabelHandler>[0]);
102
+ throw new Error(`Unsupported steering action ${fieldName}.`);
103
+ };
104
+
105
+ async function reviewSteeringProposal(event: Parameters<ReviewHandler>[0]) {
106
+ const client = await getDataClient();
107
+ const proposalId = normalizeRequiredString(event.arguments.proposalId, "proposalId");
108
+ const action = normalizeReviewAction(event.arguments.action);
109
+ const proposal = await getRequiredRecord(client.models.SteeringProposal, proposalId, "SteeringProposal");
110
+ const now = new Date().toISOString();
111
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
112
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
113
+ const selectedCategoryKey = normalizeOptionalString(proposal.categoryKey);
114
+ const decisionId = `decision-${proposalId}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
115
+
116
+ await requireDataResult(
117
+ client.models.SteeringDecision.create({
118
+ id: decisionId,
119
+ proposalId,
120
+ categorySetId: proposal.categorySetId ?? null,
121
+ action,
122
+ actorSub,
123
+ actorLabel,
124
+ note: normalizeOptionalString(event.arguments.note),
125
+ selectedCategoryKey,
126
+ createdAt: now,
127
+ }),
128
+ "create SteeringDecision",
129
+ );
130
+
131
+ const proposalStatus = action === "reject" ? "rejected" : action === "defer" ? "deferred" : "accepted";
132
+ await requireDataResult(
133
+ client.models.SteeringProposal.update({
134
+ id: proposalId,
135
+ status: proposalStatus,
136
+ reviewedAt: now,
137
+ reviewedBy: actorLabel ?? actorSub,
138
+ updatedAt: now,
139
+ }),
140
+ "update SteeringProposal",
141
+ );
142
+
143
+ let categoryId: string | null = null;
144
+ if ((action === "accept" || action === "edit") && shouldApplySteeringProposalToCategory(proposal)) {
145
+ categoryId = await createCategoryVersionFromProposal(client, proposal, event.arguments, {
146
+ actorLabel: actorLabel ?? actorSub ?? "Papyrus news desk",
147
+ now,
148
+ reason: `proposal:${proposalId}`,
149
+ });
150
+ await refreshCategorySetCount(client, normalizeRequiredString(proposal.categorySetId, "proposal.categorySetId"));
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ action,
156
+ proposalId,
157
+ categorySetId: proposal.categorySetId ?? null,
158
+ categoryId,
159
+ decisionId,
160
+ status: proposalStatus,
161
+ };
162
+ }
163
+
164
+ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandler>[0]) {
165
+ const client = await getDataClient();
166
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
167
+ const action = normalizeReferenceCurationAction(event.arguments.action);
168
+ const nextStatus = referenceCurationStatusForAction(action);
169
+ const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
170
+ const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
171
+ const now = new Date().toISOString();
172
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
173
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
174
+ const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
175
+ const note = normalizeOptionalString(event.arguments.note);
176
+ const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
177
+ const referenceVersionNumber = typeof reference.versionNumber === "number" ? reference.versionNumber : null;
178
+ const referenceTitle = normalizeOptionalString(reference.title) ?? normalizeOptionalString(reference.externalItemId) ?? referenceId;
179
+ const messageId = `message-reference-curation-${safeId(referenceLineageId)}-${safeId(action)}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
180
+ const relationId = `semantic-relation-${hashStable([
181
+ `message#${messageId}`,
182
+ "comment",
183
+ `reference#${referenceId}`,
184
+ action,
185
+ ]).slice(0, 16)}`;
186
+
187
+ await requireDataResult(
188
+ client.models.Reference.update({
189
+ id: referenceId,
190
+ curationStatus: nextStatus,
191
+ curationStatusKey: `${normalizeRequiredString(reference.corpusId, "reference.corpusId")}#${nextStatus}`,
192
+ curationStatusUpdatedAt: now,
193
+ curationStatusUpdatedBy: actor,
194
+ curationStatusReason: note,
195
+ newsroomFeedKey: reference.newsroomFeedKey ?? "references",
196
+ reviewedFeedKey: reviewedFeedKeyForStatus(nextStatus),
197
+ updatedAt: now,
198
+ }),
199
+ "update Reference curation status",
200
+ );
201
+
202
+ await requireDataResult(
203
+ client.models.Message.create(buildCanonicalMessageInput({
204
+ id: messageId,
205
+ messageKind: "reference_curation",
206
+ messageDomain: "commentary",
207
+ summary: `${referenceTitle}: ${nextStatus}`,
208
+ source: "newsroom",
209
+ authorSub: actorSub,
210
+ authorLabel: actor,
211
+ now,
212
+ })),
213
+ "create Message",
214
+ );
215
+ await putTextModelPayload(
216
+ client as any,
217
+ { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
218
+ "message_body",
219
+ "message",
220
+ note ?? `${actor} marked this reference ${nextStatus}.`,
221
+ { filename: "message.txt", now },
222
+ );
223
+ await putJsonModelPayload(
224
+ client as any,
225
+ { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
226
+ "metadata",
227
+ "metadata",
228
+ {
229
+ action,
230
+ curationStatus: nextStatus,
231
+ reasonCode,
232
+ curationReasonCode: reasonCode,
233
+ referenceId,
234
+ referenceLineageId,
235
+ },
236
+ { filename: "metadata.json", now },
237
+ );
238
+
239
+ await requireDataResult(
240
+ client.models.SemanticRelation.create({
241
+ id: relationId,
242
+ relationState: "current",
243
+ predicate: "comment",
244
+ relationTypeId: "semantic-relation-type-comment",
245
+ relationTypeKey: "comment",
246
+ relationDomain: "commentary",
247
+ subjectKind: "message",
248
+ subjectId: messageId,
249
+ subjectLineageId: messageId,
250
+ subjectVersionNumber: 1,
251
+ objectKind: "reference",
252
+ objectId: referenceId,
253
+ objectLineageId: referenceLineageId,
254
+ objectVersionNumber: referenceVersionNumber,
255
+ subjectStateKey: semanticStateKey("message", messageId),
256
+ objectStateKey: semanticStateKey("reference", referenceLineageId),
257
+ objectSubjectStateKey: `${semanticStateKey("reference", referenceLineageId)}#message`,
258
+ predicateObjectStateKey: `comment#${semanticStateKey("reference", referenceLineageId)}`,
259
+ subjectVersionKey: semanticVersionKey("message", messageId),
260
+ objectVersionKey: semanticVersionKey("reference", referenceId),
261
+ score: 1,
262
+ confidence: null,
263
+ rank: 1,
264
+ classifierId: null,
265
+ modelVersion: null,
266
+ reviewRecommended: false,
267
+ sourceSnapshotId: null,
268
+ importRunId: null,
269
+ importedAt: now,
270
+ createdAt: now,
271
+ updatedAt: now,
272
+ newsroomFeedKey: "semanticRelations",
273
+ metadata: JSON.stringify({
274
+ action,
275
+ curationStatus: nextStatus,
276
+ reasonCode,
277
+ messageKind: "reference_curation",
278
+ }),
279
+ }),
280
+ "create Message SemanticRelation",
281
+ );
282
+
283
+ await updateNewsroomSummaryForReferenceCuration(client, {
284
+ corpusId: normalizeOptionalString(reference.corpusId),
285
+ previousStatus: normalizeOptionalString(reference.curationStatus) ?? "pending",
286
+ nextStatus,
287
+ now,
288
+ });
289
+
290
+ return {
291
+ ok: true,
292
+ action,
293
+ referenceId,
294
+ status: nextStatus,
295
+ reasonCode,
296
+ messageId,
297
+ relationId,
298
+ };
299
+ }
300
+
301
+ async function setReferenceQualityRating(event: Parameters<ReferenceQualityHandler>[0]) {
302
+ const client = await getDataClient();
303
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
304
+ const rating = normalizeReferenceQualityRating(event.arguments.rating);
305
+ const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
306
+ const now = new Date().toISOString();
307
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
308
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
309
+ const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
310
+ const note = normalizeOptionalString(event.arguments.note);
311
+ const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
312
+ const nextStatus = rating >= 3 ? "accepted" : "rejected";
313
+ const existingRelations = await listCurrentSemanticRelationsBySubject(
314
+ client,
315
+ "quality_rating_is",
316
+ semanticStateKey("reference", referenceLineageId),
317
+ );
318
+ const existingRating = existingRelations.length === 1 ? qualityRatingFromRelation(existingRelations[0]) : null;
319
+ const currentStatus = normalizeOptionalString(reference.curationStatus) ?? "pending";
320
+ const alreadyAcceptedAtRating = nextStatus === "accepted"
321
+ && currentStatus === "accepted"
322
+ && existingRelations.length === 1
323
+ && existingRating === rating;
324
+ const alreadyRejectedWithoutQuality = nextStatus === "rejected"
325
+ && currentStatus === "rejected"
326
+ && existingRelations.length === 0;
327
+
328
+ if (alreadyAcceptedAtRating || alreadyRejectedWithoutQuality) {
329
+ return {
330
+ ok: true,
331
+ referenceId,
332
+ rating,
333
+ status: nextStatus,
334
+ relationId: alreadyAcceptedAtRating ? existingRelations[0]?.id ?? null : null,
335
+ };
336
+ }
337
+
338
+ for (const relation of existingRelations) {
339
+ await requireDataResult(
340
+ client.models.SemanticRelation.update({
341
+ id: relation.id,
342
+ relationState: "superseded",
343
+ updatedAt: now,
344
+ metadata: JSON.stringify({
345
+ ...parseMetadataObject(relation.metadata),
346
+ supersededAt: now,
347
+ supersededBy: actor,
348
+ supersededByRating: rating,
349
+ }),
350
+ }),
351
+ "supersede quality_rating_is",
352
+ );
353
+ }
354
+
355
+ let relationId: string | null = null;
356
+ if (rating >= 3) {
357
+ const qualityNode = await getRequiredRecord(client.models.SemanticNode, qualityNodeId(rating), "SemanticNode");
358
+ const relation = buildReferenceQualityRelation({
359
+ actor,
360
+ note,
361
+ now,
362
+ qualityNode,
363
+ rating,
364
+ reference,
365
+ });
366
+ await requireDataResult(client.models.SemanticRelation.create(relation), "create quality_rating_is");
367
+ await updateNewsroomSummaryForSemanticRelationDelta(client, relation, 1, now);
368
+ relationId = relation.id;
369
+ }
370
+
371
+ await requireDataResult(
372
+ client.models.Reference.update({
373
+ id: referenceId,
374
+ curationStatus: nextStatus,
375
+ curationStatusKey: `${normalizeRequiredString(reference.corpusId, "reference.corpusId")}#${nextStatus}`,
376
+ curationStatusUpdatedAt: now,
377
+ curationStatusUpdatedBy: actor,
378
+ curationStatusReason: note,
379
+ newsroomFeedKey: reference.newsroomFeedKey ?? "references",
380
+ reviewedFeedKey: reviewedFeedKeyForStatus(nextStatus),
381
+ updatedAt: now,
382
+ }),
383
+ "update Reference quality status",
384
+ );
385
+
386
+ if (currentStatus !== nextStatus) {
387
+ await updateNewsroomSummaryForReferenceStatus(client, {
388
+ corpusId: normalizeOptionalString(reference.corpusId),
389
+ previousStatus: currentStatus,
390
+ nextStatus,
391
+ now,
392
+ });
393
+ }
394
+
395
+ return {
396
+ ok: true,
397
+ referenceId,
398
+ rating,
399
+ status: nextStatus,
400
+ relationId,
401
+ };
402
+ }
403
+
404
+ async function createReferenceInsight(event: Parameters<ReferenceInsightHandler>[0]) {
405
+ const client = await getDataClient();
406
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
407
+ const summary = normalizeRequiredString(event.arguments.summary, "summary");
408
+ const body = normalizeRequiredString(event.arguments.body, "body");
409
+ const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
410
+ const now = new Date().toISOString();
411
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
412
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
413
+ const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
414
+ const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
415
+ const messageId = `message-reference-insight-${safeId(referenceLineageId)}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
416
+ const relationId = `semantic-relation-${hashStable([
417
+ `message#${messageId}`,
418
+ "insight_about",
419
+ `reference#${referenceId}`,
420
+ ]).slice(0, 24)}`;
421
+
422
+ await requireDataResult(
423
+ client.models.Message.create({
424
+ ...buildCanonicalMessageInput({
425
+ id: messageId,
426
+ messageKind: "insight",
427
+ messageDomain: "knowledge",
428
+ summary,
429
+ source: "newsroom",
430
+ authorSub: actorSub,
431
+ authorLabel: actor,
432
+ now,
433
+ }),
434
+ threadId: messageId,
435
+ sequenceNumber: 1,
436
+ }),
437
+ "create insight Message",
438
+ );
439
+ await putTextModelPayload(
440
+ client as any,
441
+ { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
442
+ "message_body",
443
+ "message",
444
+ body,
445
+ { filename: "message.md", mediaType: "text/markdown", now },
446
+ );
447
+ await putJsonModelPayload(
448
+ client as any,
449
+ { ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
450
+ "metadata",
451
+ "metadata",
452
+ {
453
+ kind: "reference.insight.created",
454
+ targetKind: "reference",
455
+ targetId: referenceId,
456
+ targetLineageId: referenceLineageId,
457
+ },
458
+ { filename: "metadata.json", now },
459
+ );
460
+ await requireDataResult(
461
+ client.models.SemanticRelation.create({
462
+ id: relationId,
463
+ relationState: "current",
464
+ predicate: "insight_about",
465
+ ...semanticRelationTypeFieldsForPredicate("insight_about"),
466
+ subjectKind: "message",
467
+ subjectId: messageId,
468
+ subjectLineageId: messageId,
469
+ subjectVersionNumber: 1,
470
+ objectKind: "reference",
471
+ objectId: referenceId,
472
+ objectLineageId: referenceLineageId,
473
+ objectVersionNumber: typeof reference.versionNumber === "number" ? reference.versionNumber : null,
474
+ subjectStateKey: semanticStateKey("message", messageId),
475
+ objectStateKey: semanticStateKey("reference", referenceLineageId),
476
+ objectSubjectStateKey: `${semanticStateKey("reference", referenceLineageId)}#message`,
477
+ predicateObjectStateKey: `insight_about#${semanticStateKey("reference", referenceLineageId)}`,
478
+ subjectVersionKey: semanticVersionKey("message", messageId),
479
+ objectVersionKey: semanticVersionKey("reference", referenceId),
480
+ score: 1,
481
+ confidence: null,
482
+ rank: 1,
483
+ classifierId: null,
484
+ modelVersion: null,
485
+ reviewRecommended: false,
486
+ sourceSnapshotId: null,
487
+ importRunId: null,
488
+ importedAt: now,
489
+ createdAt: now,
490
+ updatedAt: now,
491
+ newsroomFeedKey: "semanticRelations",
492
+ metadata: JSON.stringify({
493
+ kind: "reference.insight",
494
+ summary,
495
+ actor,
496
+ }),
497
+ }),
498
+ "create insight_about relation",
499
+ );
500
+ await updateNewsroomSummaryForSemanticRelationDelta(
501
+ client,
502
+ {
503
+ relationTypeKey: "insight_about",
504
+ relationDomain: "commentary",
505
+ subjectKind: "message",
506
+ objectKind: "reference",
507
+ },
508
+ 1,
509
+ now,
510
+ );
511
+ return {
512
+ ok: true,
513
+ referenceId,
514
+ messageId,
515
+ relationId,
516
+ status: "created",
517
+ };
518
+ }
519
+
520
+ async function moveReferenceCorpus(event: Parameters<MoveReferenceCorpusHandler>[0]) {
521
+ const client = await getDataClient();
522
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
523
+ const targetCorpusId = normalizeRequiredString(event.arguments.corpusId, "corpusId");
524
+ const requested = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
525
+ const referenceLineageId = normalizeOptionalString(requested.lineageId) ?? referenceId;
526
+ const lineageVersions = await listReferenceVersionsByLineage(client, referenceLineageId);
527
+ const current = selectCurrentReferenceVersion(lineageVersions) ?? requested;
528
+ const previousCorpusId = normalizeRequiredString(current.corpusId, "reference.corpusId");
529
+ if (previousCorpusId === targetCorpusId) {
530
+ return {
531
+ ok: true,
532
+ referenceId: normalizeRequiredString(current.id, "reference.id"),
533
+ referenceLineageId,
534
+ previousReferenceId: normalizeRequiredString(current.id, "reference.id"),
535
+ previousCorpusId,
536
+ corpusId: targetCorpusId,
537
+ status: "unchanged",
538
+ };
539
+ }
540
+
541
+ const now = new Date().toISOString();
542
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
543
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
544
+ const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
545
+ const nextVersionNumber = Math.max(
546
+ ...lineageVersions.map((entry) => normalizeOptionalPositiveInteger(entry.versionNumber) ?? 0),
547
+ normalizeOptionalPositiveInteger(current.versionNumber) ?? 0,
548
+ ) + 1;
549
+ const nextReferenceId = `reference-${safeId(referenceLineageId)}-v${nextVersionNumber}-${randomUUID().slice(0, 8)}`;
550
+ const curationStatus = normalizeOptionalString(current.curationStatus) ?? "pending";
551
+
552
+ if (normalizeOptionalString(current.versionState) === "current") {
553
+ await requireDataResult(
554
+ client.models.Reference.update({
555
+ id: normalizeRequiredString(current.id, "reference.id"),
556
+ versionState: "superseded",
557
+ updatedAt: now,
558
+ }),
559
+ "supersede current Reference version",
560
+ );
561
+ }
562
+
563
+ await requireDataResult(
564
+ client.models.Reference.create({
565
+ id: nextReferenceId,
566
+ lineageId: referenceLineageId,
567
+ versionNumber: nextVersionNumber,
568
+ previousVersionId: normalizeRequiredString(current.id, "reference.id"),
569
+ versionState: "current",
570
+ versionCreatedAt: now,
571
+ versionCreatedBy: actor,
572
+ changeReason: normalizeOptionalString(event.arguments.note) ?? `move-corpus:${previousCorpusId}->${targetCorpusId}`,
573
+ contentHash: normalizeOptionalString(current.contentHash),
574
+ corpusId: targetCorpusId,
575
+ externalItemId: normalizeRequiredString(current.externalItemId, "reference.externalItemId"),
576
+ title: normalizeOptionalString(current.title),
577
+ authors: compactStringArray(current.authors) ?? [],
578
+ sourceUri: normalizeOptionalString(current.sourceUri),
579
+ storagePath: normalizeOptionalString(current.storagePath),
580
+ mediaType: normalizeOptionalString(current.mediaType),
581
+ byteSize: normalizeOptionalPositiveInteger(current.byteSize),
582
+ sha256: normalizeOptionalString(current.sha256),
583
+ sourcePublishedAt: normalizeOptionalString(current.sourcePublishedAt),
584
+ sourceUpdatedAt: normalizeOptionalString(current.sourceUpdatedAt),
585
+ retrievedAt: normalizeOptionalString(current.retrievedAt),
586
+ importRunId: normalizeOptionalString(current.importRunId),
587
+ importedAt: normalizeOptionalString(current.importedAt),
588
+ createdAt: normalizeOptionalString(current.createdAt) ?? now,
589
+ curationStatus,
590
+ curationStatusKey: `${targetCorpusId}#${curationStatus}`,
591
+ curationStatusUpdatedAt: normalizeOptionalString(current.curationStatusUpdatedAt) ?? now,
592
+ curationStatusUpdatedBy: normalizeOptionalString(current.curationStatusUpdatedBy) ?? actor,
593
+ curationStatusReason: normalizeOptionalString(current.curationStatusReason),
594
+ newsroomFeedKey: normalizeOptionalString(current.newsroomFeedKey) ?? "references",
595
+ reviewedFeedKey: normalizeOptionalString(current.reviewedFeedKey) ?? reviewedFeedKeyForStatus(curationStatus),
596
+ updatedAt: now,
597
+ }),
598
+ "create moved Reference version",
599
+ );
600
+
601
+ return {
602
+ ok: true,
603
+ referenceId: nextReferenceId,
604
+ referenceLineageId,
605
+ previousReferenceId: normalizeRequiredString(current.id, "reference.id"),
606
+ previousCorpusId,
607
+ corpusId: targetCorpusId,
608
+ status: "moved",
609
+ };
610
+ }
611
+
612
+ async function startReferenceCuration(event: Parameters<StartReferenceCurationHandler>[0]) {
613
+ const client = await getDataClient();
614
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
615
+ const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
616
+ const referenceLineageId = normalizeOptionalString(reference.lineageId) ?? referenceId;
617
+ const queueKey = `curation:reference-refresh:${referenceLineageId}`;
618
+ const existing = [
619
+ ...(await listAssignmentsByQueueStatus(client, `${queueKey}#open`)),
620
+ ...(await listAssignmentsByQueueStatus(client, `${queueKey}#claimed`)),
621
+ ].sort((left, right) => assignmentSortKey(left).localeCompare(assignmentSortKey(right)))[0];
622
+ if (existing?.id) {
623
+ return {
624
+ ok: true,
625
+ referenceId: normalizeRequiredString(reference.id, "reference.id"),
626
+ assignmentId: normalizeRequiredString(existing.id, "assignment.id"),
627
+ status: assignmentLifecycleStatusFromAssignmentStatus(normalizeOptionalString(existing.status) ?? "open"),
628
+ runId: normalizeRequiredString(existing.id, "assignment.id"),
629
+ };
630
+ }
631
+
632
+ const now = new Date().toISOString();
633
+ const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
634
+ const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
635
+ const actor = actorLabel ?? actorSub ?? "Papyrus newsroom";
636
+ const policy = normalizeReferenceCurationPolicy(event.arguments.curationPolicy);
637
+ const assignmentId = `assignment-reference-curation-${safeId(referenceLineageId)}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
638
+ const runId = assignmentId;
639
+ const assignmentTypeKey = REFERENCE_CURATION_ASSIGNMENT_TYPE;
640
+ const relationId = `semantic-relation-${hashStable([
641
+ `assignment#${assignmentId}`,
642
+ "requests_work_on",
643
+ `reference#${reference.id}`,
644
+ ]).slice(0, 24)}`;
645
+
646
+ await requireDataResult(
647
+ client.models.Assignment.create({
648
+ id: assignmentId,
649
+ assignmentTypeKey,
650
+ queueKey,
651
+ queueStatusKey: `${queueKey}#open`,
652
+ status: "open",
653
+ priority: 50,
654
+ title: `Re-curate reference: ${normalizeOptionalString(reference.title) ?? normalizeOptionalString(reference.externalItemId) ?? referenceId}`,
655
+ summary: "Recompute identifier, publication date, title/subtitle, summary, and topic predictions.",
656
+ assigneeType: null,
657
+ assigneeId: null,
658
+ assigneeKey: null,
659
+ claimedAt: null,
660
+ claimExpiresAt: null,
661
+ completedAt: null,
662
+ canceledAt: null,
663
+ corpusId: normalizeRequiredString(reference.corpusId, "reference.corpusId"),
664
+ categorySetId: null,
665
+ classifierId: null,
666
+ sectionId: null,
667
+ sectionKey: null,
668
+ sectionType: null,
669
+ sectionStatusKey: null,
670
+ sectionQueueStatusKey: null,
671
+ primaryFocusCategoryKey: null,
672
+ topicScopeCategoryKeys: [],
673
+ sourceSnapshotId: null,
674
+ importRunId: null,
675
+ createdBy: actor,
676
+ createdAt: now,
677
+ updatedAt: now,
678
+ newsroomFeedKey: "assignments",
679
+ }),
680
+ "create curation Assignment",
681
+ );
682
+ await requireDataResult(
683
+ client.models.AssignmentEvent.create({
684
+ id: `assignment-event-${assignmentId}-created`,
685
+ assignmentId,
686
+ assignmentTypeKey,
687
+ queueKey,
688
+ eventType: "created",
689
+ fromStatus: null,
690
+ toStatus: "open",
691
+ actorSub,
692
+ actorLabel: actor,
693
+ note: "Queued reference re-curation assignment.",
694
+ createdAt: now,
695
+ }),
696
+ "create curation AssignmentEvent",
697
+ );
698
+ await requireDataResult(
699
+ client.models.SemanticRelation.create({
700
+ id: relationId,
701
+ relationState: "current",
702
+ predicate: "requests_work_on",
703
+ ...semanticRelationTypeFieldsForPredicate("requests_work_on"),
704
+ subjectKind: "assignment",
705
+ subjectId: assignmentId,
706
+ subjectLineageId: assignmentId,
707
+ subjectVersionNumber: 1,
708
+ objectKind: "reference",
709
+ objectId: normalizeRequiredString(reference.id, "reference.id"),
710
+ objectLineageId: referenceLineageId,
711
+ objectVersionNumber: typeof reference.versionNumber === "number" ? reference.versionNumber : null,
712
+ subjectStateKey: semanticStateKey("assignment", assignmentId),
713
+ objectStateKey: semanticStateKey("reference", referenceLineageId),
714
+ objectSubjectStateKey: `${semanticStateKey("reference", referenceLineageId)}#assignment`,
715
+ predicateObjectStateKey: `requests_work_on#${semanticStateKey("reference", referenceLineageId)}`,
716
+ subjectVersionKey: semanticVersionKey("assignment", assignmentId),
717
+ objectVersionKey: semanticVersionKey("reference", normalizeRequiredString(reference.id, "reference.id")),
718
+ score: 1,
719
+ confidence: null,
720
+ rank: 1,
721
+ classifierId: null,
722
+ modelVersion: null,
723
+ reviewRecommended: false,
724
+ sourceSnapshotId: null,
725
+ importRunId: null,
726
+ importedAt: now,
727
+ createdAt: now,
728
+ updatedAt: now,
729
+ newsroomFeedKey: "semanticRelations",
730
+ metadata: JSON.stringify({
731
+ kind: "reference.curation.assignment",
732
+ runId,
733
+ curationPolicy: policy,
734
+ }),
735
+ }),
736
+ "create requests_work_on relation",
737
+ );
738
+ await putJsonModelPayload(
739
+ client as any,
740
+ { ownerKind: "assignment", ownerId: assignmentId, ownerLineageId: assignmentId },
741
+ "metadata",
742
+ "reference_curation_status",
743
+ {
744
+ kind: "reference.curation.status",
745
+ runId,
746
+ assignmentId,
747
+ referenceId: normalizeRequiredString(reference.id, "reference.id"),
748
+ referenceLineageId,
749
+ corpusId: normalizeRequiredString(reference.corpusId, "reference.corpusId"),
750
+ curationPolicy: policy,
751
+ lifecycleStatus: "queued",
752
+ status: "queued",
753
+ stageStatuses: defaultReferenceCurationStageStatuses("queued"),
754
+ changedOutputs: {},
755
+ error: null,
756
+ updatedAt: now,
757
+ },
758
+ { filename: "reference-curation-status.json", now },
759
+ );
760
+ return {
761
+ ok: true,
762
+ referenceId: normalizeRequiredString(reference.id, "reference.id"),
763
+ assignmentId,
764
+ status: "queued",
765
+ runId,
766
+ };
767
+ }
768
+
769
+ async function getReferenceCurationStatus(event: Parameters<GetReferenceCurationStatusHandler>[0]) {
770
+ const client = await getDataClient();
771
+ const assignmentId = normalizeRequiredString(event.arguments.assignmentId, "assignmentId");
772
+ const assignment = await getRequiredRecord(client.models.Assignment, assignmentId, "Assignment");
773
+ const payload = await readJsonModelPayload(
774
+ client as any,
775
+ "assignment",
776
+ assignmentId,
777
+ "metadata",
778
+ "reference_curation_status",
779
+ );
780
+ const assignmentStatus = normalizeOptionalString(assignment.status) ?? "open";
781
+ const lifecycleStatus = normalizeOptionalString(payload?.lifecycleStatus)
782
+ ?? assignmentLifecycleStatusFromAssignmentStatus(assignmentStatus);
783
+ const stageStatuses = (payload?.stageStatuses && typeof payload.stageStatuses === "object" && !Array.isArray(payload.stageStatuses))
784
+ ? payload.stageStatuses
785
+ : defaultReferenceCurationStageStatuses(lifecycleStatus);
786
+
787
+ return {
788
+ ok: true,
789
+ referenceId: normalizeOptionalString(payload?.referenceId),
790
+ assignmentId,
791
+ status: lifecycleStatus,
792
+ runId: normalizeOptionalString(payload?.runId) ?? assignmentId,
793
+ lifecycleStatus,
794
+ stageStatuses,
795
+ changedOutputs: (payload?.changedOutputs && typeof payload.changedOutputs === "object" && !Array.isArray(payload.changedOutputs))
796
+ ? payload.changedOutputs
797
+ : {},
798
+ error: payload?.error ?? null,
799
+ };
800
+ }
801
+
802
+ async function createCategorySetDraft(event: Parameters<CreateCategorySetDraftHandler>[0]) {
803
+ const client = await getDataClient();
804
+ const sourceCategorySetId = normalizeRequiredString(event.arguments.sourceCategorySetId, "sourceCategorySetId");
805
+ const source = await getRequiredRecord(client.models.CategorySet, sourceCategorySetId, "CategorySet");
806
+ if (source.versionState !== "current" || source.status !== "accepted") {
807
+ throw new Error(`CategorySet ${sourceCategorySetId} is ${source.versionState}/${source.status}; create drafts from the accepted current set.`);
808
+ }
809
+ const sourceCategories = (await listCategoriesForSet(client, sourceCategorySetId))
810
+ .filter((category) => category.versionState !== "superseded" && category.status !== "archived" && category.status !== "deprecated")
811
+ .sort(compareCategoryRank);
812
+ const now = new Date().toISOString();
813
+ const actor = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event) ?? getIdentitySub(event) ?? "Papyrus newsroom";
814
+ const lineageId = normalizeOptionalString(source.lineageId) ?? source.id;
815
+ const versionNumber = Number(source.versionNumber ?? 0) + 1;
816
+ const draftId = `category-set-${safeId(lineageId)}-draft-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`;
817
+ const displayName = normalizeOptionalString(event.arguments.displayName) ?? `${source.displayName} Draft`;
818
+ const draftCategoryIdBySourceId = new Map<string, string>();
819
+ for (const category of sourceCategories) {
820
+ draftCategoryIdBySourceId.set(
821
+ normalizeRequiredString(category.id, "category.id"),
822
+ `category-${safeId(draftId)}-${safeId(category.categoryKey)}-${randomUUID().slice(0, 8)}`,
823
+ );
824
+ }
825
+ const draftSet = {
826
+ id: draftId,
827
+ lineageId,
828
+ versionNumber,
829
+ previousVersionId: source.id,
830
+ versionState: "draft",
831
+ versionCreatedAt: now,
832
+ versionCreatedBy: actor,
833
+ changeReason: normalizeOptionalString(event.arguments.note) ?? `draft from ${source.id}`,
834
+ contentHash: "",
835
+ corpusId: source.corpusId,
836
+ classifierId: source.classifierId,
837
+ displayName,
838
+ description: source.description ?? null,
839
+ status: "draft",
840
+ generatedAt: now,
841
+ categoryCount: sourceCategories.length,
842
+ importRunId: source.importRunId ?? null,
843
+ };
844
+ await requireDataResult(
845
+ client.models.CategorySet.create({ ...draftSet, contentHash: hashStable({ ...draftSet, contentHash: undefined }) }),
846
+ "create CategorySet draft",
847
+ );
848
+ for (const category of sourceCategories) {
849
+ const categoryId = normalizeRequiredString(category.id, "category.id");
850
+ const parentCategoryId = normalizeOptionalString(category.parentCategoryId);
851
+ const draftCategory = {
852
+ id: draftCategoryIdBySourceId.get(categoryId) ?? `category-${safeId(draftId)}-${safeId(category.categoryKey)}-${randomUUID().slice(0, 8)}`,
853
+ lineageId: normalizeOptionalString(category.lineageId) ?? `category-${safeId(draftId)}-${safeId(category.categoryKey)}`,
854
+ previousVersionId: categoryId,
855
+ versionNumber: Number(category.versionNumber ?? 0) + 1,
856
+ versionState: "draft",
857
+ versionCreatedAt: now,
858
+ versionCreatedBy: actor,
859
+ changeReason: `draft from ${category.id}`,
860
+ categorySetId: draftId,
861
+ corpusId: category.corpusId,
862
+ categoryKey: category.categoryKey,
863
+ parentCategoryId: parentCategoryId ? draftCategoryIdBySourceId.get(parentCategoryId) ?? null : null,
864
+ parentCategoryKey: category.parentCategoryKey ?? null,
865
+ displayName: category.displayName,
866
+ shortTitle: category.shortTitle ?? null,
867
+ subtitle: category.subtitle ?? null,
868
+ description: category.description ?? null,
869
+ aliases: compactStringArray(category.aliases) ?? [],
870
+ status: category.status,
871
+ seedItemIds: compactStringArray(category.seedItemIds) ?? [],
872
+ holdoutItemIds: compactStringArray(category.holdoutItemIds) ?? [],
873
+ rank: category.rank ?? null,
874
+ depth: category.depth ?? null,
875
+ isPinned: Boolean(category.isPinned),
876
+ importRunId: category.importRunId ?? null,
877
+ contentHash: "",
878
+ updatedAt: now,
879
+ };
880
+ await requireDataResult(
881
+ client.models.Category.create({
882
+ ...draftCategory,
883
+ contentHash: hashStable({ ...draftCategory, contentHash: undefined }),
884
+ }),
885
+ "create draft Category",
886
+ );
887
+ }
888
+ return {
889
+ ok: true,
890
+ action: "create_draft",
891
+ categorySetId: draftId,
892
+ sourceCategorySetId,
893
+ categoryCount: sourceCategories.length,
894
+ status: "draft",
895
+ };
896
+ }
897
+
898
+ async function promoteCategorySetDraft(event: Parameters<PromoteCategorySetDraftHandler>[0]) {
899
+ const client = await getDataClient();
900
+ const categorySetId = normalizeRequiredString(event.arguments.categorySetId, "categorySetId");
901
+ const draft = await getRequiredRecord(client.models.CategorySet, categorySetId, "CategorySet");
902
+ if (draft.versionState !== "draft") throw new Error(`CategorySet ${categorySetId} is ${draft.versionState}; only drafts can be promoted.`);
903
+ const now = new Date().toISOString();
904
+ const actor = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event) ?? getIdentitySub(event) ?? "Papyrus newsroom";
905
+ const currentSets = await listCategorySetsByLineage(client, normalizeRequiredString(draft.lineageId, "draft.lineageId"));
906
+ for (const categorySet of currentSets.filter((entry) => entry.id !== draft.id && entry.versionState === "current")) {
907
+ await requireDataResult(
908
+ client.models.CategorySet.update({
909
+ id: categorySet.id,
910
+ versionState: "superseded",
911
+ status: "superseded",
912
+ changeReason: `superseded by ${draft.id}`,
913
+ }),
914
+ "supersede CategorySet",
915
+ );
916
+ }
917
+ const categories = await listCategoriesForSet(client, categorySetId);
918
+ const activeCount = categories.filter((category) => category.status !== "archived" && category.status !== "deprecated").length;
919
+ await requireDataResult(
920
+ client.models.CategorySet.update({
921
+ id: draft.id,
922
+ versionState: "current",
923
+ status: "accepted",
924
+ versionCreatedBy: actor,
925
+ changeReason: normalizeOptionalString(event.arguments.note) ?? "draft promoted",
926
+ generatedAt: now,
927
+ categoryCount: activeCount,
928
+ }),
929
+ "promote CategorySet",
930
+ );
931
+ for (const category of categories) {
932
+ await requireDataResult(
933
+ client.models.Category.update({
934
+ id: category.id,
935
+ versionState: "current",
936
+ updatedAt: now,
937
+ }),
938
+ "promote Category",
939
+ );
940
+ }
941
+ return {
942
+ ok: true,
943
+ action: "promote_draft",
944
+ categorySetId,
945
+ sourceCategorySetId: draft.previousVersionId ?? null,
946
+ categoryCount: activeCount,
947
+ status: "accepted",
948
+ };
949
+ }
950
+
951
+ async function discardCategorySetDraft(event: Parameters<DiscardCategorySetDraftHandler>[0]) {
952
+ const client = await getDataClient();
953
+ const categorySetId = normalizeRequiredString(event.arguments.categorySetId, "categorySetId");
954
+ normalizeRequiredString(event.arguments.note, "note");
955
+ const draft = await requireDraftCategorySet(client, categorySetId);
956
+ const categories = await listCategoriesForSet(client, categorySetId);
957
+ const semanticRelations = await listSemanticRelationsForDraftCategories(client, categories);
958
+ const categoryKeywords = await listCategoryKeywordsForSet(client, categorySetId);
959
+ const lexicalSteeringRules = await listLexicalSteeringRulesForSet(client, categorySetId);
960
+
961
+ for (const relation of semanticRelations) {
962
+ await requireDataResult((client.models.SemanticRelation as any).delete({ id: relation.id }), "delete draft SemanticRelation");
963
+ }
964
+ for (const keyword of categoryKeywords) {
965
+ await requireDataResult((client.models.CategoryKeyword as any).delete({ id: keyword.id }), "delete draft CategoryKeyword");
966
+ }
967
+ for (const rule of lexicalSteeringRules) {
968
+ await requireDataResult((client.models.LexicalSteeringRule as any).delete({ id: rule.id }), "delete draft LexicalSteeringRule");
969
+ }
970
+ for (const category of categories) {
971
+ await requireDataResult(client.models.Category.delete({ id: category.id }), "delete draft Category");
972
+ }
973
+ await requireDataResult(client.models.CategorySet.delete({ id: draft.id }), "delete draft CategorySet");
974
+
975
+ return {
976
+ ok: true,
977
+ action: "discard_draft",
978
+ categorySetId,
979
+ sourceCategorySetId: draft.previousVersionId ?? null,
980
+ categoryCount: categories.length,
981
+ status: "deleted",
982
+ };
983
+ }
984
+
985
+ async function createDraftCategory(event: Parameters<CreateDraftCategoryHandler>[0]) {
986
+ const client = await getDataClient();
987
+ const categorySetId = normalizeRequiredString(event.arguments.categorySetId, "categorySetId");
988
+ const categorySet = await requireDraftCategorySet(client, categorySetId);
989
+ const now = new Date().toISOString();
990
+ const actor = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event) ?? getIdentitySub(event) ?? "Papyrus newsroom";
991
+ const displayName = normalizeRequiredString(event.arguments.displayName, "displayName");
992
+ const categoryKey = await uniqueCategoryKey(client, categorySetId, deriveCategoryKey(displayName));
993
+ const parentCategoryKey = normalizeOptionalString(event.arguments.parentCategoryKey);
994
+ const depth = await categoryDepthForParent(client, categorySetId, parentCategoryKey);
995
+ const lineageId = `category-${safeId(categorySetId)}-${safeId(categoryKey)}`;
996
+ const category = {
997
+ id: `${lineageId}-v1`,
998
+ lineageId,
999
+ versionNumber: 1,
1000
+ previousVersionId: null,
1001
+ versionState: "draft",
1002
+ versionCreatedAt: now,
1003
+ versionCreatedBy: actor,
1004
+ changeReason: normalizeOptionalString(event.arguments.note) ?? "manual draft topic",
1005
+ contentHash: "",
1006
+ categorySetId,
1007
+ corpusId: categorySet.corpusId,
1008
+ categoryKey,
1009
+ parentCategoryId: null,
1010
+ parentCategoryKey,
1011
+ displayName,
1012
+ shortTitle: normalizeOptionalString(event.arguments.shortTitle) ?? deriveShortTitle(displayName),
1013
+ subtitle: normalizeOptionalString(event.arguments.subtitle),
1014
+ description: normalizeOptionalString(event.arguments.description),
1015
+ aliases: [],
1016
+ status: "accepted",
1017
+ seedItemIds: [],
1018
+ holdoutItemIds: [],
1019
+ rank: null,
1020
+ depth,
1021
+ isPinned: false,
1022
+ importRunId: categorySet.importRunId ?? null,
1023
+ updatedAt: now,
1024
+ };
1025
+ await requireDataResult(
1026
+ client.models.Category.create({ ...category, contentHash: hashStable({ ...category, contentHash: undefined }) }),
1027
+ "create draft Category",
1028
+ );
1029
+ await refreshCategorySetCount(client, categorySetId);
1030
+ return {
1031
+ ok: true,
1032
+ action: "create_draft_category",
1033
+ proposalId: null,
1034
+ categorySetId,
1035
+ categoryId: category.id,
1036
+ decisionId: null,
1037
+ status: "draft",
1038
+ };
1039
+ }
1040
+
1041
+ async function updateDraftCategory(event: Parameters<UpdateDraftCategoryHandler>[0]) {
1042
+ const client = await getDataClient();
1043
+ const categoryId = normalizeRequiredString(event.arguments.categoryId, "categoryId");
1044
+ const category = await getRequiredRecord(client.models.Category, categoryId, "Category");
1045
+ await requireDraftCategorySet(client, normalizeRequiredString(category.categorySetId, "category.categorySetId"));
1046
+ if (category.versionState !== "draft") throw new Error(`Category ${categoryId} is ${category.versionState}; only draft categories can be edited.`);
1047
+ const now = new Date().toISOString();
1048
+ const displayName = normalizeOptionalString(event.arguments.displayName) ?? category.displayName;
1049
+ const parentCategoryKey = normalizeOptionalString(event.arguments.parentCategoryKey);
1050
+ const parentChanged = parentCategoryKey !== normalizeOptionalString(category.parentCategoryKey);
1051
+ const nextParentCategoryKey = parentChanged ? parentCategoryKey : category.parentCategoryKey ?? null;
1052
+ const next = {
1053
+ ...category,
1054
+ displayName,
1055
+ shortTitle: normalizeOptionalString(event.arguments.shortTitle) ?? category.shortTitle ?? deriveShortTitle(displayName),
1056
+ subtitle: normalizeOptionalString(event.arguments.subtitle),
1057
+ description: normalizeOptionalString(event.arguments.description),
1058
+ parentCategoryKey: nextParentCategoryKey,
1059
+ depth: await categoryDepthForParent(client, category.categorySetId, nextParentCategoryKey),
1060
+ changeReason: normalizeOptionalString(event.arguments.note) ?? "manual draft topic edit",
1061
+ updatedAt: now,
1062
+ };
1063
+ await requireDataResult(
1064
+ client.models.Category.update({
1065
+ id: categoryId,
1066
+ displayName: next.displayName,
1067
+ shortTitle: next.shortTitle,
1068
+ subtitle: next.subtitle,
1069
+ description: next.description,
1070
+ parentCategoryKey: next.parentCategoryKey,
1071
+ depth: next.depth,
1072
+ changeReason: next.changeReason,
1073
+ contentHash: hashStable({ ...next, contentHash: undefined }),
1074
+ updatedAt: now,
1075
+ }),
1076
+ "update draft Category",
1077
+ );
1078
+ return {
1079
+ ok: true,
1080
+ action: "update_draft_category",
1081
+ proposalId: null,
1082
+ categorySetId: category.categorySetId,
1083
+ categoryId,
1084
+ decisionId: null,
1085
+ status: "draft",
1086
+ };
1087
+ }
1088
+
1089
+ async function archiveDraftCategory(event: Parameters<ArchiveDraftCategoryHandler>[0]) {
1090
+ const client = await getDataClient();
1091
+ const categoryId = normalizeRequiredString(event.arguments.categoryId, "categoryId");
1092
+ const category = await getRequiredRecord(client.models.Category, categoryId, "Category");
1093
+ await requireDraftCategorySet(client, normalizeRequiredString(category.categorySetId, "category.categorySetId"));
1094
+ if (category.versionState !== "draft") throw new Error(`Category ${categoryId} is ${category.versionState}; only draft categories can be archived.`);
1095
+ const now = new Date().toISOString();
1096
+ await requireDataResult(
1097
+ client.models.Category.update({
1098
+ id: categoryId,
1099
+ status: "deprecated",
1100
+ changeReason: normalizeOptionalString(event.arguments.note) ?? "manual draft topic archive",
1101
+ updatedAt: now,
1102
+ }),
1103
+ "archive draft Category",
1104
+ );
1105
+ await refreshCategorySetCount(client, category.categorySetId);
1106
+ return {
1107
+ ok: true,
1108
+ action: "archive_draft_category",
1109
+ proposalId: null,
1110
+ categorySetId: category.categorySetId,
1111
+ categoryId,
1112
+ decisionId: null,
1113
+ status: "deprecated",
1114
+ };
1115
+ }
1116
+
1117
+ async function reviewReferenceTopicLabel(event: Parameters<ReviewReferenceTopicLabelHandler>[0]) {
1118
+ const client = await getDataClient();
1119
+ const action = normalizeTopicLabelAction(event.arguments.action);
1120
+ const referenceId = normalizeRequiredString(event.arguments.referenceId, "referenceId");
1121
+ const categoryId = normalizeRequiredString(event.arguments.categoryId, "categoryId");
1122
+ const sourceRelationId = normalizeOptionalString(event.arguments.sourceRelationId);
1123
+ const now = new Date().toISOString();
1124
+ const actor = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event) ?? getIdentitySub(event) ?? "Papyrus newsroom";
1125
+ const note = normalizeOptionalString(event.arguments.note);
1126
+ const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
1127
+ const category = await getRequiredRecord(client.models.Category, categoryId, "Category");
1128
+ if (action === "reject_prediction") {
1129
+ if (!sourceRelationId) throw new Error("sourceRelationId is required to reject a prediction.");
1130
+ const source = await getRequiredSemanticRelation(client, sourceRelationId, "classified_as");
1131
+ await requireDataResult((client.models.SemanticRelation as any).delete({ id: source.id }), "delete classified_as prediction");
1132
+ await updateNewsroomSummaryForSemanticRelationDelta(client, source, -1, now);
1133
+ return { ok: true, action, referenceId, categoryId, relationId: null, sourceRelationId, status: "deleted_prediction" };
1134
+ }
1135
+ if (action === "unlabel") {
1136
+ if (!sourceRelationId) throw new Error("sourceRelationId is required to remove an authoritative label.");
1137
+ const source = await getRequiredSemanticRelation(client, sourceRelationId, "authoritative_label");
1138
+ await requireDataResult((client.models.SemanticRelation as any).delete({ id: source.id }), "delete authoritative_label");
1139
+ await updateNewsroomSummaryForSemanticRelationDelta(client, source, -1, now);
1140
+ return { ok: true, action, referenceId, categoryId, relationId: null, sourceRelationId, status: "deleted_authoritative_label" };
1141
+ }
1142
+ if (!isAcceptedCurrentReference(reference)) {
1143
+ throw new Error(`Reference ${referenceId} is ${reference.versionState}/${reference.curationStatus}; authoritative labels require a current accepted Reference.`);
1144
+ }
1145
+ let sourcePrediction: any | null = null;
1146
+ if (action === "accept_prediction") {
1147
+ if (!sourceRelationId) throw new Error("sourceRelationId is required to accept a prediction.");
1148
+ sourcePrediction = await getRequiredSemanticRelation(client, sourceRelationId, "classified_as");
1149
+ }
1150
+ const relation = buildAuthoritativeLabelRelation({
1151
+ reference,
1152
+ category,
1153
+ now,
1154
+ actor,
1155
+ note,
1156
+ sourceMode: action === "accept_prediction" ? "accepted_prediction" : "manual",
1157
+ sourceRelationId: sourcePrediction?.id ?? null,
1158
+ });
1159
+ const existing = await findCurrentSemanticRelationByState(client, "authoritative_label", relation.subjectStateKey, relation.objectStateKey);
1160
+ if (!existing) {
1161
+ await requireDataResult(client.models.SemanticRelation.create(relation), "create authoritative_label");
1162
+ await updateNewsroomSummaryForSemanticRelationDelta(client, relation, 1, now);
1163
+ }
1164
+ return {
1165
+ ok: true,
1166
+ action,
1167
+ referenceId,
1168
+ categoryId,
1169
+ relationId: existing?.id ?? relation.id,
1170
+ sourceRelationId: sourcePrediction?.id ?? sourceRelationId ?? null,
1171
+ status: existing ? "idempotent" : "created_authoritative_label",
1172
+ };
1173
+ }
1174
+
1175
+ function shouldApplySteeringProposalToCategory(proposal: any): boolean {
1176
+ const proposalKind = normalizeOptionalString(proposal.proposalKind);
1177
+ return Boolean(
1178
+ proposal.categorySetId
1179
+ && proposal.categoryKey
1180
+ && proposalKind
1181
+ && CATEGORY_PROPOSAL_KINDS.has(proposalKind),
1182
+ );
1183
+ }
1184
+
1185
+ async function createCategoryVersionFromProposal(
1186
+ client: DataClient,
1187
+ proposal: any,
1188
+ args: Parameters<ReviewHandler>[0]["arguments"],
1189
+ options: { actorLabel: string; now: string; reason: string },
1190
+ ): Promise<string> {
1191
+ const categorySetId = normalizeRequiredString(proposal.categorySetId, "proposal.categorySetId");
1192
+ const categoryKey = normalizeRequiredString(proposal.categoryKey, "proposal.categoryKey");
1193
+ const proposalKind = normalizeOptionalString(proposal.proposalKind);
1194
+ const current = await findCurrentCategory(client, categorySetId, categoryKey);
1195
+ const lineageId = current?.lineageId ?? `category-${safeId(categorySetId)}-${safeId(categoryKey)}`;
1196
+ const versionNumber = Number(current?.versionNumber ?? 0) + 1;
1197
+ const categoryId = `${lineageId}-v${versionNumber}`;
1198
+ const nextStatus = proposalKind === "archive-category" || proposalKind === "deprecate-category" ? "archived" : "accepted";
1199
+ const nextParentCategoryKey = proposalKind === "move-category" || proposalKind === "create-category"
1200
+ ? normalizeOptionalString(proposal.targetCategoryKey)
1201
+ : current?.parentCategoryKey ?? normalizeOptionalString(proposal.targetCategoryKey);
1202
+ const depth = await categoryDepthForParent(client, categorySetId, nextParentCategoryKey);
1203
+ const input = {
1204
+ id: categoryId,
1205
+ lineageId,
1206
+ versionNumber,
1207
+ previousVersionId: current?.id ?? null,
1208
+ versionState: "current",
1209
+ versionCreatedAt: options.now,
1210
+ versionCreatedBy: options.actorLabel,
1211
+ changeReason: options.reason,
1212
+ contentHash: "",
1213
+ categorySetId,
1214
+ corpusId: proposal.corpusId,
1215
+ categoryKey,
1216
+ parentCategoryId: null,
1217
+ parentCategoryKey: nextParentCategoryKey,
1218
+ displayName: normalizeOptionalString(args.displayName) ?? proposal.displayName ?? current?.displayName ?? proposal.title,
1219
+ shortTitle: normalizeOptionalString(args.shortTitle) ?? proposal.shortTitle ?? current?.shortTitle ?? deriveShortTitle(proposal.displayName ?? current?.displayName ?? proposal.title),
1220
+ subtitle: normalizeOptionalString(args.subtitle) ?? proposal.subtitle ?? current?.subtitle ?? null,
1221
+ description: normalizeOptionalString(args.description) ?? proposal.description ?? proposal.summary ?? current?.description ?? null,
1222
+ aliases: normalizeStringArray(args.aliases) ?? current?.aliases ?? [],
1223
+ status: nextStatus,
1224
+ seedItemIds: normalizeStringArray(args.seedItemIds) ?? compactStringArray(proposal.suggestedSeedItemIds) ?? current?.seedItemIds ?? [],
1225
+ holdoutItemIds: normalizeStringArray(args.holdoutItemIds) ?? compactStringArray(proposal.suggestedHoldoutItemIds) ?? current?.holdoutItemIds ?? [],
1226
+ rank: current?.rank ?? null,
1227
+ depth,
1228
+ isPinned: current?.isPinned ?? false,
1229
+ importRunId: proposal.importRunId ?? current?.importRunId ?? null,
1230
+ updatedAt: options.now,
1231
+ };
1232
+ const contentHash = hashStable({ ...input, contentHash: undefined });
1233
+
1234
+ if (current?.id) {
1235
+ await requireDataResult(
1236
+ client.models.Category.update({ id: current.id, versionState: "superseded", updatedAt: options.now }),
1237
+ "supersede Category",
1238
+ );
1239
+ }
1240
+ await requireDataResult(client.models.Category.create({ ...input, contentHash }), "create Category version");
1241
+ return categoryId;
1242
+ }
1243
+
1244
+ async function findCurrentCategory(client: DataClient, categorySetId: string, categoryKey: string): Promise<any | null> {
1245
+ const records = await listCategoriesForSet(client, categorySetId);
1246
+ return records
1247
+ .filter((category) => category.categoryKey === categoryKey && category.versionState === "current")
1248
+ .sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0))[0]
1249
+ ?? records
1250
+ .filter((category) => category.categoryKey === categoryKey)
1251
+ .sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0))[0]
1252
+ ?? null;
1253
+ }
1254
+
1255
+ async function listCategoriesForSet(client: DataClient, categorySetId: string): Promise<any[]> {
1256
+ const model = client.models.Category as any;
1257
+ let nextToken: string | null | undefined;
1258
+ const records: any[] = [];
1259
+ do {
1260
+ const page = await model.listCategoriesBySetAndKey(
1261
+ { categorySetId },
1262
+ { limit: 100, nextToken },
1263
+ );
1264
+ assertNoDataErrors(page.errors, "list Category");
1265
+ records.push(...(page.data ?? []));
1266
+ nextToken = page.nextToken;
1267
+ } while (nextToken);
1268
+ return records;
1269
+ }
1270
+
1271
+ async function listCategoryKeywordsForSet(client: DataClient, categorySetId: string): Promise<any[]> {
1272
+ const model = client.models.CategoryKeyword as any;
1273
+ let nextToken: string | null | undefined;
1274
+ const records: any[] = [];
1275
+ do {
1276
+ const page = await model.listCategoryKeywordsBySetKeyAndRank(
1277
+ { categorySetId },
1278
+ { limit: 100, nextToken },
1279
+ );
1280
+ assertNoDataErrors(page.errors, "list CategoryKeyword");
1281
+ records.push(...(page.data ?? []));
1282
+ nextToken = page.nextToken;
1283
+ } while (nextToken);
1284
+ return records;
1285
+ }
1286
+
1287
+ async function listLexicalSteeringRulesForSet(client: DataClient, categorySetId: string): Promise<any[]> {
1288
+ const model = client.models.LexicalSteeringRule as any;
1289
+ let nextToken: string | null | undefined;
1290
+ const records: any[] = [];
1291
+ do {
1292
+ const page = await model.listLexicalSteeringRulesBySetAndTerm(
1293
+ { categorySetId },
1294
+ { limit: 100, nextToken },
1295
+ );
1296
+ assertNoDataErrors(page.errors, "list LexicalSteeringRule");
1297
+ records.push(...(page.data ?? []));
1298
+ nextToken = page.nextToken;
1299
+ } while (nextToken);
1300
+ return records;
1301
+ }
1302
+
1303
+ async function listSemanticRelationsForDraftCategories(client: DataClient, categories: any[]): Promise<any[]> {
1304
+ const model = client.models.SemanticRelation as any;
1305
+ const categoryIds = new Set(categories.map((category) => normalizeOptionalString(category.id)).filter(Boolean));
1306
+ const relationById = new Map<string, any>();
1307
+ for (const category of categories) {
1308
+ const lineageId = normalizeOptionalString(category.lineageId);
1309
+ if (!lineageId) continue;
1310
+ let nextToken: string | null | undefined;
1311
+ do {
1312
+ const page = await model.listSemanticRelationsByObjectState(
1313
+ { objectStateKey: semanticStateKey("category", lineageId) },
1314
+ { limit: 100, nextToken },
1315
+ );
1316
+ assertNoDataErrors(page.errors, "list SemanticRelation");
1317
+ for (const relation of page.data ?? []) {
1318
+ if (relation?.objectKind === "category" && categoryIds.has(normalizeOptionalString(relation.objectId))) {
1319
+ relationById.set(relation.id, relation);
1320
+ }
1321
+ }
1322
+ nextToken = page.nextToken;
1323
+ } while (nextToken);
1324
+ }
1325
+ return Array.from(relationById.values());
1326
+ }
1327
+
1328
+ async function listCategorySetsByLineage(client: DataClient, lineageId: string): Promise<any[]> {
1329
+ const model = client.models.CategorySet as any;
1330
+ let nextToken: string | null | undefined;
1331
+ const records: any[] = [];
1332
+ do {
1333
+ const page = await model.listCategorySetsByLineageAndVersion(
1334
+ { lineageId },
1335
+ { limit: 100, nextToken },
1336
+ );
1337
+ assertNoDataErrors(page.errors, "list CategorySet");
1338
+ records.push(...(page.data ?? []));
1339
+ nextToken = page.nextToken;
1340
+ } while (nextToken);
1341
+ return records;
1342
+ }
1343
+
1344
+ async function requireDraftCategorySet(client: DataClient, categorySetId: string): Promise<any> {
1345
+ const categorySet = await getRequiredRecord(client.models.CategorySet, categorySetId, "CategorySet");
1346
+ if (categorySet.versionState !== "draft" || categorySet.status !== "draft") {
1347
+ throw new Error(`CategorySet ${categorySetId} is ${categorySet.versionState}/${categorySet.status}; this operation requires a draft CategorySet.`);
1348
+ }
1349
+ return categorySet;
1350
+ }
1351
+
1352
+ async function categoryDepthForParent(client: DataClient, categorySetId: string, parentCategoryKey: string | null): Promise<number> {
1353
+ if (!parentCategoryKey) return 0;
1354
+ const parent = await findCurrentCategory(client, categorySetId, parentCategoryKey);
1355
+ return (typeof parent?.depth === "number" ? parent.depth : 0) + 1;
1356
+ }
1357
+
1358
+ function compareCategoryRank(left: any, right: any): number {
1359
+ const depthDiff = Number(left.depth ?? 0) - Number(right.depth ?? 0);
1360
+ if (depthDiff !== 0) return depthDiff;
1361
+ const rankDiff = Number(left.rank ?? 999999) - Number(right.rank ?? 999999);
1362
+ if (rankDiff !== 0) return rankDiff;
1363
+ return String(left.categoryKey ?? left.id).localeCompare(String(right.categoryKey ?? right.id));
1364
+ }
1365
+
1366
+ function deriveCategoryKey(value: unknown): string {
1367
+ return safeId(value).replace(/-/g, "_") || "topic";
1368
+ }
1369
+
1370
+ async function uniqueCategoryKey(client: DataClient, categorySetId: string, baseKey: string): Promise<string> {
1371
+ const existing = new Set((await listCategoriesForSet(client, categorySetId)).map((category) => normalizeOptionalString(category.categoryKey)).filter(Boolean));
1372
+ let candidate = baseKey || "topic";
1373
+ let suffix = 2;
1374
+ while (existing.has(candidate)) {
1375
+ candidate = `${baseKey || "topic"}_${suffix}`;
1376
+ suffix += 1;
1377
+ }
1378
+ return candidate;
1379
+ }
1380
+
1381
+ async function refreshCategorySetCount(client: DataClient, categorySetId: string): Promise<void> {
1382
+ const categories = await listCategoriesForSet(client, categorySetId);
1383
+ const categoryCount = categories.filter((category) => (
1384
+ category.versionState !== "superseded"
1385
+ && category.status !== "archived"
1386
+ && category.status !== "deprecated"
1387
+ )).length;
1388
+ await requireDataResult(
1389
+ client.models.CategorySet.update({ id: categorySetId, categoryCount }),
1390
+ "update CategorySet count",
1391
+ );
1392
+ }
1393
+
1394
+ type TopicLabelAction = "manual_label" | "accept_prediction" | "reject_prediction" | "unlabel";
1395
+
1396
+ function normalizeTopicLabelAction(value: unknown): TopicLabelAction {
1397
+ const action = normalizeRequiredString(value, "action").toLowerCase();
1398
+ if (action === "manual_label" || action === "accept_prediction" || action === "reject_prediction" || action === "unlabel") {
1399
+ return action;
1400
+ }
1401
+ throw new Error(`Unsupported topic label action ${action}.`);
1402
+ }
1403
+
1404
+ function isAcceptedCurrentReference(reference: any): boolean {
1405
+ return reference.versionState === "current" && reference.curationStatus === "accepted";
1406
+ }
1407
+
1408
+ async function getRequiredSemanticRelation(client: DataClient, relationId: string, relationTypeKey: string): Promise<any> {
1409
+ const relation = await getRequiredRecord(client.models.SemanticRelation, relationId, "SemanticRelation");
1410
+ const key = normalizeOptionalString(relation.relationTypeKey) ?? normalizeOptionalString(relation.predicate);
1411
+ if (key !== relationTypeKey) {
1412
+ throw new Error(`SemanticRelation ${relationId} is ${key ?? "unknown"}, not ${relationTypeKey}.`);
1413
+ }
1414
+ if (relation.relationState && relation.relationState !== "current") {
1415
+ throw new Error(`SemanticRelation ${relationId} is ${relation.relationState}; only current relations can be reviewed.`);
1416
+ }
1417
+ return relation;
1418
+ }
1419
+
1420
+ async function findCurrentSemanticRelationByState(
1421
+ client: DataClient,
1422
+ relationTypeKey: string,
1423
+ subjectStateKey: string,
1424
+ objectStateKey: string,
1425
+ ): Promise<any | null> {
1426
+ const model = client.models.SemanticRelation as any;
1427
+ let nextToken: string | null | undefined;
1428
+ do {
1429
+ const page = await model.listSemanticRelationsBySubjectState(
1430
+ { subjectStateKey },
1431
+ { limit: 100, nextToken },
1432
+ );
1433
+ assertNoDataErrors(page.errors, "list SemanticRelation by subject");
1434
+ const match = (page.data ?? []).find((relation: any) => (
1435
+ relation.relationState === "current"
1436
+ && relation.objectStateKey === objectStateKey
1437
+ && ((relation.relationTypeKey ?? relation.predicate) === relationTypeKey)
1438
+ ));
1439
+ if (match) return match;
1440
+ nextToken = page.nextToken;
1441
+ } while (nextToken);
1442
+ return null;
1443
+ }
1444
+
1445
+ async function listCurrentSemanticRelationsBySubject(
1446
+ client: DataClient,
1447
+ relationTypeKey: string,
1448
+ subjectStateKey: string,
1449
+ ): Promise<any[]> {
1450
+ const model = client.models.SemanticRelation as any;
1451
+ let nextToken: string | null | undefined;
1452
+ const matches: any[] = [];
1453
+ do {
1454
+ const page = await model.listSemanticRelationsBySubjectState(
1455
+ { subjectStateKey },
1456
+ { limit: 100, nextToken },
1457
+ );
1458
+ assertNoDataErrors(page.errors, "list SemanticRelation by subject");
1459
+ for (const relation of page.data ?? []) {
1460
+ if (
1461
+ relation?.relationState === "current"
1462
+ && ((relation.relationTypeKey ?? relation.predicate) === relationTypeKey)
1463
+ ) {
1464
+ matches.push(relation);
1465
+ }
1466
+ }
1467
+ nextToken = page.nextToken;
1468
+ } while (nextToken);
1469
+ return matches;
1470
+ }
1471
+
1472
+ function semanticRelationTypeFieldsForPredicate(predicate: string): {
1473
+ relationTypeId: string;
1474
+ relationTypeKey: string;
1475
+ relationDomain: string;
1476
+ } {
1477
+ if (predicate === "authoritative_label") {
1478
+ return {
1479
+ relationTypeId: "semantic-relation-type-authoritative-label",
1480
+ relationTypeKey: "authoritative_label",
1481
+ relationDomain: "taxonomy",
1482
+ };
1483
+ }
1484
+ if (predicate === "classified_as") {
1485
+ return {
1486
+ relationTypeId: "semantic-relation-type-classified-as",
1487
+ relationTypeKey: "classified_as",
1488
+ relationDomain: "taxonomy",
1489
+ };
1490
+ }
1491
+ if (predicate === "quality_rating_is") {
1492
+ return {
1493
+ relationTypeId: "semantic-relation-type-quality-rating-is",
1494
+ relationTypeKey: "quality_rating_is",
1495
+ relationDomain: "curation",
1496
+ };
1497
+ }
1498
+ if (predicate === "insight_about") {
1499
+ return {
1500
+ relationTypeId: "semantic-relation-type-insight-about",
1501
+ relationTypeKey: "insight_about",
1502
+ relationDomain: "knowledge",
1503
+ };
1504
+ }
1505
+ if (predicate === "requests_work_on") {
1506
+ return {
1507
+ relationTypeId: "semantic-relation-type-requests-work-on",
1508
+ relationTypeKey: "requests_work_on",
1509
+ relationDomain: "workflow",
1510
+ };
1511
+ }
1512
+ return {
1513
+ relationTypeId: `semantic-relation-type-${safeId(predicate)}`,
1514
+ relationTypeKey: predicate,
1515
+ relationDomain: "semantic",
1516
+ };
1517
+ }
1518
+
1519
+ async function listAssignmentsByQueueStatus(client: DataClient, queueStatusKey: string): Promise<any[]> {
1520
+ const model = client.models.Assignment as any;
1521
+ let nextToken: string | null | undefined;
1522
+ const rows: any[] = [];
1523
+ do {
1524
+ const page = await model.listAssignmentsByQueueStatusAndPriority(
1525
+ { queueStatusKey },
1526
+ { limit: 100, nextToken },
1527
+ );
1528
+ assertNoDataErrors(page.errors, "list Assignment by queue status");
1529
+ rows.push(...(page.data ?? []));
1530
+ nextToken = page.nextToken;
1531
+ } while (nextToken);
1532
+ return rows;
1533
+ }
1534
+
1535
+ function assignmentSortKey(assignment: any): string {
1536
+ const priority = typeof assignment?.priority === "number"
1537
+ ? assignment.priority
1538
+ : Number.parseInt(String(assignment?.priority ?? "50"), 10) || 50;
1539
+ const createdAt = normalizeOptionalString(assignment?.createdAt) ?? "";
1540
+ const id = normalizeOptionalString(assignment?.id) ?? "";
1541
+ return `${String(priority).padStart(6, "0")}#${createdAt}#${id}`;
1542
+ }
1543
+
1544
+ async function listReferenceVersionsByLineage(client: DataClient, lineageId: string): Promise<any[]> {
1545
+ const model = client.models.Reference as any;
1546
+ let nextToken: string | null | undefined;
1547
+ const rows: any[] = [];
1548
+ do {
1549
+ const page = await model.listReferencesByLineageAndVersion(
1550
+ { lineageId },
1551
+ { limit: 100, nextToken },
1552
+ );
1553
+ assertNoDataErrors(page.errors, "list Reference by lineage");
1554
+ rows.push(...(page.data ?? []));
1555
+ nextToken = page.nextToken;
1556
+ } while (nextToken);
1557
+ return rows;
1558
+ }
1559
+
1560
+ function selectCurrentReferenceVersion(versions: any[]): any | null {
1561
+ if (!versions.length) return null;
1562
+ const sorted = [...versions].sort((left, right) => {
1563
+ const leftCurrent = normalizeOptionalString(left.versionState) === "current" ? 1 : 0;
1564
+ const rightCurrent = normalizeOptionalString(right.versionState) === "current" ? 1 : 0;
1565
+ if (leftCurrent !== rightCurrent) return rightCurrent - leftCurrent;
1566
+ const leftVersion = normalizeOptionalPositiveInteger(left.versionNumber) ?? 0;
1567
+ const rightVersion = normalizeOptionalPositiveInteger(right.versionNumber) ?? 0;
1568
+ if (leftVersion !== rightVersion) return rightVersion - leftVersion;
1569
+ const leftTime = normalizeOptionalString(left.versionCreatedAt)
1570
+ ?? normalizeOptionalString(left.updatedAt)
1571
+ ?? "";
1572
+ const rightTime = normalizeOptionalString(right.versionCreatedAt)
1573
+ ?? normalizeOptionalString(right.updatedAt)
1574
+ ?? "";
1575
+ if (leftTime !== rightTime) return rightTime.localeCompare(leftTime);
1576
+ const leftId = normalizeOptionalString(left.id) ?? "";
1577
+ const rightId = normalizeOptionalString(right.id) ?? "";
1578
+ return rightId.localeCompare(leftId);
1579
+ });
1580
+ return sorted[0] ?? null;
1581
+ }
1582
+
1583
+ function normalizeReferenceCurationPolicy(value: unknown): typeof DEFAULT_REFERENCE_CURATION_POLICY {
1584
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1585
+ return { ...DEFAULT_REFERENCE_CURATION_POLICY };
1586
+ }
1587
+ const record = value as Record<string, unknown>;
1588
+ return {
1589
+ recomputeIdentifier: normalizeOptionalBoolean(record.recomputeIdentifier) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeIdentifier,
1590
+ recomputePublicationDate: normalizeOptionalBoolean(record.recomputePublicationDate) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputePublicationDate,
1591
+ recomputeTitle: normalizeOptionalBoolean(record.recomputeTitle) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeTitle,
1592
+ recomputeSubtitle: normalizeOptionalBoolean(record.recomputeSubtitle) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeSubtitle,
1593
+ recomputeSummary: normalizeOptionalBoolean(record.recomputeSummary) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeSummary,
1594
+ recomputeTopicPredictions: normalizeOptionalBoolean(record.recomputeTopicPredictions) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeTopicPredictions,
1595
+ recomputeQuality: normalizeOptionalBoolean(record.recomputeQuality) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeQuality,
1596
+ recomputeCorpus: normalizeOptionalBoolean(record.recomputeCorpus) ?? DEFAULT_REFERENCE_CURATION_POLICY.recomputeCorpus,
1597
+ };
1598
+ }
1599
+
1600
+ function assignmentLifecycleStatusFromAssignmentStatus(status: string): "queued" | "running" | "completed" | "failed" | "degraded" {
1601
+ const normalized = status.trim().toLowerCase();
1602
+ if (normalized === "open") return "queued";
1603
+ if (normalized === "claimed") return "running";
1604
+ if (normalized === "completed") return "completed";
1605
+ if (normalized === "degraded") return "degraded";
1606
+ return "failed";
1607
+ }
1608
+
1609
+ function defaultReferenceCurationStageStatuses(status: string): Record<(typeof REFERENCE_CURATION_STAGE_KEYS)[number], { status: string }> {
1610
+ const normalized = status.trim().toLowerCase();
1611
+ const next = normalized === "running" ? "running" : normalized === "queued" ? "queued" : "pending";
1612
+ return {
1613
+ identifier: { status: next },
1614
+ publicationDate: { status: next },
1615
+ titleSubtitle: { status: next },
1616
+ summary: { status: next },
1617
+ topicPredictions: { status: next },
1618
+ };
1619
+ }
1620
+
1621
+ function buildAuthoritativeLabelRelation({
1622
+ actor,
1623
+ category,
1624
+ note,
1625
+ now,
1626
+ reference,
1627
+ sourceMode,
1628
+ sourceRelationId,
1629
+ }: {
1630
+ actor: string;
1631
+ category: any;
1632
+ note: string | null;
1633
+ now: string;
1634
+ reference: any;
1635
+ sourceMode: "manual" | "accepted_prediction";
1636
+ sourceRelationId: string | null;
1637
+ }): any {
1638
+ const subjectLineageId = normalizeOptionalString(reference.lineageId) ?? normalizeRequiredString(reference.id, "reference.id");
1639
+ const objectLineageId = normalizeOptionalString(category.lineageId) ?? normalizeRequiredString(category.id, "category.id");
1640
+ const subjectStateKey = semanticStateKey("reference", subjectLineageId);
1641
+ const objectStateKey = semanticStateKey("category", objectLineageId);
1642
+ const subjectVersionKey = semanticVersionKey("reference", normalizeRequiredString(reference.id, "reference.id"));
1643
+ const objectVersionKey = semanticVersionKey("category", normalizeRequiredString(category.id, "category.id"));
1644
+ return {
1645
+ id: `semantic-relation-${hashStable([subjectStateKey, "authoritative_label", objectStateKey]).slice(0, 24)}`,
1646
+ relationState: "current",
1647
+ predicate: "authoritative_label",
1648
+ ...semanticRelationTypeFieldsForPredicate("authoritative_label"),
1649
+ subjectKind: "reference",
1650
+ subjectId: reference.id,
1651
+ subjectLineageId,
1652
+ subjectVersionNumber: typeof reference.versionNumber === "number" ? reference.versionNumber : null,
1653
+ objectKind: "category",
1654
+ objectId: category.id,
1655
+ objectLineageId,
1656
+ objectVersionNumber: typeof category.versionNumber === "number" ? category.versionNumber : null,
1657
+ subjectStateKey,
1658
+ objectStateKey,
1659
+ objectSubjectStateKey: `${objectStateKey}#reference`,
1660
+ predicateObjectStateKey: `authoritative_label#${objectStateKey}`,
1661
+ subjectVersionKey,
1662
+ objectVersionKey,
1663
+ score: 1,
1664
+ confidence: null,
1665
+ rank: 1,
1666
+ classifierId: normalizeOptionalString(category.classifierId),
1667
+ modelVersion: null,
1668
+ reviewRecommended: false,
1669
+ sourceSnapshotId: null,
1670
+ importRunId: null,
1671
+ importedAt: now,
1672
+ createdAt: now,
1673
+ updatedAt: now,
1674
+ newsroomFeedKey: "semanticRelations",
1675
+ metadata: JSON.stringify({
1676
+ kind: sourceMode === "accepted_prediction"
1677
+ ? "classification.authoritative_label.accepted_from_prediction"
1678
+ : "classification.authoritative_label.manual",
1679
+ actor,
1680
+ note,
1681
+ sourceMode,
1682
+ sourcePredictionId: sourceRelationId,
1683
+ categorySetId: normalizeOptionalString(category.categorySetId),
1684
+ categoryKey: normalizeOptionalString(category.categoryKey),
1685
+ }),
1686
+ };
1687
+ }
1688
+
1689
+ function buildReferenceQualityRelation({
1690
+ actor,
1691
+ note,
1692
+ now,
1693
+ qualityNode,
1694
+ rating,
1695
+ reference,
1696
+ }: {
1697
+ actor: string;
1698
+ note: string | null;
1699
+ now: string;
1700
+ qualityNode: any;
1701
+ rating: number;
1702
+ reference: any;
1703
+ }): any {
1704
+ const subjectLineageId = normalizeOptionalString(reference.lineageId) ?? normalizeRequiredString(reference.id, "reference.id");
1705
+ const objectLineageId = normalizeOptionalString(qualityNode.lineageId) ?? normalizeRequiredString(qualityNode.id, "qualityNode.id");
1706
+ const subjectStateKey = semanticStateKey("reference", subjectLineageId);
1707
+ const objectStateKey = semanticStateKey("semanticNode", objectLineageId);
1708
+ const subjectVersionKey = semanticVersionKey("reference", normalizeRequiredString(reference.id, "reference.id"));
1709
+ const objectVersionKey = semanticVersionKey("semanticNode", normalizeRequiredString(qualityNode.id, "qualityNode.id"));
1710
+ return {
1711
+ id: `semantic-relation-quality-${safeId(subjectLineageId)}-${rating}-${now.replace(/[^0-9TZ]/g, "")}-${randomUUID().slice(0, 8)}`,
1712
+ relationState: "current",
1713
+ predicate: "quality_rating_is",
1714
+ ...semanticRelationTypeFieldsForPredicate("quality_rating_is"),
1715
+ subjectKind: "reference",
1716
+ subjectId: reference.id,
1717
+ subjectLineageId,
1718
+ subjectVersionNumber: typeof reference.versionNumber === "number" ? reference.versionNumber : null,
1719
+ objectKind: "semanticNode",
1720
+ objectId: qualityNode.id,
1721
+ objectLineageId,
1722
+ objectVersionNumber: typeof qualityNode.versionNumber === "number" ? qualityNode.versionNumber : 1,
1723
+ subjectStateKey,
1724
+ objectStateKey,
1725
+ objectSubjectStateKey: `${objectStateKey}#reference`,
1726
+ predicateObjectStateKey: `quality_rating_is#${objectStateKey}`,
1727
+ subjectVersionKey,
1728
+ objectVersionKey,
1729
+ score: rating,
1730
+ confidence: null,
1731
+ rank: 1,
1732
+ classifierId: null,
1733
+ modelVersion: null,
1734
+ reviewRecommended: false,
1735
+ sourceSnapshotId: null,
1736
+ importRunId: null,
1737
+ importedAt: now,
1738
+ createdAt: now,
1739
+ updatedAt: now,
1740
+ newsroomFeedKey: "semanticRelations",
1741
+ metadata: JSON.stringify({
1742
+ kind: "reference.quality-rating.manual",
1743
+ actor,
1744
+ note,
1745
+ qualityRating: rating,
1746
+ ratedAt: now,
1747
+ }),
1748
+ };
1749
+ }
1750
+
1751
+ async function updateNewsroomSummaryForSemanticRelationDelta(
1752
+ client: DataClient,
1753
+ relation: any,
1754
+ amount: number,
1755
+ now: string,
1756
+ ): Promise<void> {
1757
+ const response = await client.models.KnowledgeRawPayload.get({ id: NEWSROOM_SUMMARY_PAYLOAD_ID });
1758
+ assertNoDataErrors(response.errors, "get Newsroom summary snapshot");
1759
+ const payload = normalizeSummaryPayload(await readNewsroomSummaryPayload(client), now);
1760
+ payload.generatedAt = now;
1761
+ payload.staleAt = new Date(Date.parse(now) + SUMMARY_STALE_AFTER_MS).toISOString();
1762
+ payload.source = "incremental";
1763
+ payload.counts.semanticRelations = Math.max(0, (payload.counts.semanticRelations ?? 0) + amount);
1764
+ increment(payload.facets.semanticRelations.byRelationTypeKey, normalizeOptionalString(relation.relationTypeKey) ?? normalizeOptionalString(relation.predicate) ?? "unknown", amount);
1765
+ increment(payload.facets.semanticRelations.byRelationDomain, normalizeOptionalString(relation.relationDomain) ?? "unknown", amount);
1766
+ increment(payload.facets.semanticRelations.bySubjectKind, normalizeOptionalString(relation.subjectKind) ?? "unknown", amount);
1767
+ increment(payload.facets.semanticRelations.byObjectKind, normalizeOptionalString(relation.objectKind) ?? "unknown", amount);
1768
+ await upsertNewsroomSummaryPayload(client, payload, response.data, now);
1769
+ }
1770
+
1771
+ async function getDataClient(): Promise<DataClient> {
1772
+ if (!clientPromise) {
1773
+ clientPromise = (async () => {
1774
+ const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
1775
+ Amplify.configure(resourceConfig, libraryOptions);
1776
+ return generateClient<Schema>();
1777
+ })();
1778
+ }
1779
+ return clientPromise;
1780
+ }
1781
+
1782
+ async function getRequiredRecord(
1783
+ model: { get(input: { id: string }): Promise<{ data?: unknown | null; errors?: DataClientErrors }> },
1784
+ id: string,
1785
+ modelName: string,
1786
+ ): Promise<any> {
1787
+ const response = await model.get({ id });
1788
+ assertNoDataErrors(response.errors, `get ${modelName}`);
1789
+ if (!response.data) throw new Error(`${modelName} ${id} was not found.`);
1790
+ return response.data;
1791
+ }
1792
+
1793
+ async function requireDataResult<T>(promise: Promise<DataClientResult<T>>, operation: string): Promise<T> {
1794
+ const response = await promise;
1795
+ assertNoDataErrors(response.errors, operation);
1796
+ if (!response.data) throw new Error(`${operation} returned no data.`);
1797
+ return response.data;
1798
+ }
1799
+
1800
+ function assertNoDataErrors(errors: DataClientErrors, operation: string): void {
1801
+ if (!errors?.length) return;
1802
+ throw new Error(`${operation} failed: ${errors.map(formatDataError).join("; ")}`);
1803
+ }
1804
+
1805
+ function formatDataError(error: { message?: string | null } | string | null): string {
1806
+ if (typeof error === "string") return error;
1807
+ return error?.message ?? "GraphQL data operation failed.";
1808
+ }
1809
+
1810
+ function normalizeReviewAction(value: unknown): "accept" | "reject" | "defer" | "edit" {
1811
+ const action = normalizeRequiredString(value, "action").toLowerCase();
1812
+ if (action === "accept" || action === "reject" || action === "defer" || action === "edit") return action;
1813
+ throw new Error(`Unsupported proposal action ${action}.`);
1814
+ }
1815
+
1816
+ function normalizeReferenceCurationAction(value: unknown): "accept" | "reject" | "reopen" | "archive" {
1817
+ const action = normalizeRequiredString(value, "action").toLowerCase();
1818
+ if (action === "accept" || action === "reject" || action === "reopen" || action === "archive") return action;
1819
+ throw new Error(`Unsupported reference curation action ${action}.`);
1820
+ }
1821
+
1822
+ function reviewedFeedKeyForStatus(status: string): string | null {
1823
+ return status === "pending" ? null : REFERENCE_REVIEWED_FEED_KEY;
1824
+ }
1825
+
1826
+ function referenceCurationStatusForAction(action: "accept" | "reject" | "reopen" | "archive"): "accepted" | "rejected" | "pending" | "archived" {
1827
+ if (action === "accept") return "accepted";
1828
+ if (action === "reject") return "rejected";
1829
+ if (action === "archive") return "archived";
1830
+ return "pending";
1831
+ }
1832
+
1833
+ const REFERENCE_REJECTION_REASON_CODES = new Set([
1834
+ "out_of_scope",
1835
+ "policy_exclusion",
1836
+ "duplicate",
1837
+ "low_quality",
1838
+ "unavailable",
1839
+ "provenance",
1840
+ "other",
1841
+ ]);
1842
+
1843
+ function normalizeReferenceRejectionReasonCode(value: unknown, required: boolean): string | null {
1844
+ const normalized = normalizePolicyToken(value);
1845
+ if (!normalized) {
1846
+ if (required) throw new Error("reasonCode is required when rejecting a reference.");
1847
+ return null;
1848
+ }
1849
+ if (!REFERENCE_REJECTION_REASON_CODES.has(normalized)) {
1850
+ throw new Error(`Unsupported reference rejection reason code ${normalized}.`);
1851
+ }
1852
+ return normalized;
1853
+ }
1854
+
1855
+ function normalizeReferenceQualityRating(value: unknown): number {
1856
+ const numeric = typeof value === "number" ? value : Number(value);
1857
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= 5) return numeric;
1858
+ throw new Error("rating must be an integer from 1 to 5.");
1859
+ }
1860
+
1861
+ function qualityNodeId(rating: number): string {
1862
+ return `${qualityNodeLineageId(rating)}-v1`;
1863
+ }
1864
+
1865
+ function qualityNodeLineageId(rating: number): string {
1866
+ return `semantic-node-${safeId(`quality.rating.${rating}_star`)}`;
1867
+ }
1868
+
1869
+ function qualityRatingFromRelation(relation: any): number | null {
1870
+ const score = typeof relation?.score === "number" ? relation.score : Number(relation?.score);
1871
+ if (Number.isFinite(score) && Number.isInteger(score) && score >= 1 && score <= 5) return score;
1872
+ return qualityRatingFromNodeKey(normalizeOptionalString(relation?.objectLineageId) ?? normalizeOptionalString(relation?.objectId));
1873
+ }
1874
+
1875
+ function qualityRatingFromNodeKey(value: string | null): number | null {
1876
+ const match = /(?:quality[-_.]?rating[-_.]?)?([1-5])[-_.]?star/i.exec(value ?? "");
1877
+ return match ? Number(match[1]) : null;
1878
+ }
1879
+
1880
+ function parseMetadataObject(value: unknown): Record<string, unknown> {
1881
+ if (!value) return {};
1882
+ if (typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
1883
+ if (typeof value !== "string") return {};
1884
+ try {
1885
+ const parsed = JSON.parse(value);
1886
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
1887
+ } catch {
1888
+ return {};
1889
+ }
1890
+ }
1891
+
1892
+ function normalizePolicyToken(value: unknown): string {
1893
+ return String(value ?? "")
1894
+ .trim()
1895
+ .toLowerCase()
1896
+ .replace(/[^a-z0-9]+/g, "_")
1897
+ .replace(/^_|_$/g, "");
1898
+ }
1899
+
1900
+ async function updateNewsroomSummaryForReferenceCuration(
1901
+ client: DataClient,
1902
+ input: { corpusId: string | null; previousStatus: string; nextStatus: string; now: string },
1903
+ ): Promise<void> {
1904
+ const response = await client.models.KnowledgeRawPayload.get({ id: NEWSROOM_SUMMARY_PAYLOAD_ID });
1905
+ assertNoDataErrors(response.errors, "get Newsroom summary snapshot");
1906
+ const payload = normalizeSummaryPayload(await readNewsroomSummaryPayload(client), input.now);
1907
+ payload.generatedAt = input.now;
1908
+ payload.staleAt = new Date(Date.parse(input.now) + SUMMARY_STALE_AFTER_MS).toISOString();
1909
+ payload.source = "incremental";
1910
+ payload.counts.messages = Math.max(0, (payload.counts.messages ?? 0) + 1);
1911
+ payload.counts.modelAttachments = Math.max(0, (payload.counts.modelAttachments ?? 0) + 2);
1912
+ payload.counts.semanticRelations = Math.max(0, (payload.counts.semanticRelations ?? 0) + 1);
1913
+ if (input.previousStatus !== input.nextStatus) {
1914
+ increment(payload.referenceStatusCounts, input.previousStatus, -1);
1915
+ increment(payload.referenceStatusCounts, input.nextStatus, 1);
1916
+ increment(payload.facets.references.byCurationStatus, input.previousStatus, -1);
1917
+ increment(payload.facets.references.byCurationStatus, input.nextStatus, 1);
1918
+ if (input.corpusId) {
1919
+ if (!payload.facets.references.statusByCorpus[input.corpusId]) payload.facets.references.statusByCorpus[input.corpusId] = {};
1920
+ increment(payload.facets.references.statusByCorpus[input.corpusId], input.previousStatus, -1);
1921
+ increment(payload.facets.references.statusByCorpus[input.corpusId], input.nextStatus, 1);
1922
+ }
1923
+ }
1924
+ increment(payload.messageKindCounts, "reference_curation", 1);
1925
+ increment(payload.messageDomainCounts, "commentary", 1);
1926
+ increment(payload.facets.messages.byKind, "reference_curation", 1);
1927
+ increment(payload.facets.messages.byDomain, "commentary", 1);
1928
+ increment(payload.facets.messages.byStatus, "active", 1);
1929
+ if (!payload.facets.messages.domainByKind.reference_curation) payload.facets.messages.domainByKind.reference_curation = {};
1930
+ increment(payload.facets.messages.domainByKind.reference_curation, "commentary", 1);
1931
+ increment(payload.facets.modelAttachments.byOwnerKind, "message", 2);
1932
+ increment(payload.facets.modelAttachments.byRole, "message_body", 1);
1933
+ increment(payload.facets.modelAttachments.byRole, "metadata", 1);
1934
+ increment(payload.facets.modelAttachments.byMediaType, "text/plain", 1);
1935
+ increment(payload.facets.modelAttachments.byMediaType, "application/json", 1);
1936
+ increment(payload.facets.modelAttachments.byStatus, "active", 2);
1937
+ increment(payload.facets.semanticRelations.byRelationTypeKey, "comment", 1);
1938
+ increment(payload.facets.semanticRelations.byRelationDomain, "workflow", 1);
1939
+ increment(payload.facets.semanticRelations.bySubjectKind, "message", 1);
1940
+ increment(payload.facets.semanticRelations.byObjectKind, "reference", 1);
1941
+ await upsertNewsroomSummaryPayload(client, payload, response.data, input.now);
1942
+ }
1943
+
1944
+ async function updateNewsroomSummaryForReferenceStatus(
1945
+ client: DataClient,
1946
+ input: { corpusId: string | null; previousStatus: string; nextStatus: string; now: string },
1947
+ ): Promise<void> {
1948
+ const response = await client.models.KnowledgeRawPayload.get({ id: NEWSROOM_SUMMARY_PAYLOAD_ID });
1949
+ assertNoDataErrors(response.errors, "get Newsroom summary snapshot");
1950
+ const payload = normalizeSummaryPayload(await readNewsroomSummaryPayload(client), input.now);
1951
+ payload.generatedAt = input.now;
1952
+ payload.staleAt = new Date(Date.parse(input.now) + SUMMARY_STALE_AFTER_MS).toISOString();
1953
+ payload.source = "incremental";
1954
+ increment(payload.referenceStatusCounts, input.previousStatus, -1);
1955
+ increment(payload.referenceStatusCounts, input.nextStatus, 1);
1956
+ increment(payload.facets.references.byCurationStatus, input.previousStatus, -1);
1957
+ increment(payload.facets.references.byCurationStatus, input.nextStatus, 1);
1958
+ if (input.corpusId) {
1959
+ if (!payload.facets.references.statusByCorpus[input.corpusId]) payload.facets.references.statusByCorpus[input.corpusId] = {};
1960
+ increment(payload.facets.references.statusByCorpus[input.corpusId], input.previousStatus, -1);
1961
+ increment(payload.facets.references.statusByCorpus[input.corpusId], input.nextStatus, 1);
1962
+ }
1963
+ await upsertNewsroomSummaryPayload(client, payload, response.data, input.now);
1964
+ }
1965
+
1966
+ function normalizeSummaryPayload(value: unknown, now: string): {
1967
+ generatedAt: string;
1968
+ staleAt: string;
1969
+ source: string;
1970
+ latestImportRun: Record<string, unknown> | null;
1971
+ counts: Record<string, number>;
1972
+ assignmentStatusCounts: Record<string, number>;
1973
+ assignmentTypeCounts: Record<string, number>;
1974
+ referenceStatusCounts: Record<string, number>;
1975
+ messageKindCounts: Record<string, number>;
1976
+ messageDomainCounts: Record<string, number>;
1977
+ facets: {
1978
+ assignments: {
1979
+ byStatus: Record<string, number>;
1980
+ byType: Record<string, number>;
1981
+ bySection: Record<string, number>;
1982
+ statusByType: Record<string, Record<string, number>>;
1983
+ statusBySection: Record<string, Record<string, number>>;
1984
+ typeBySection: Record<string, Record<string, number>>;
1985
+ };
1986
+ messages: { byKind: Record<string, number>; byDomain: Record<string, number>; byStatus: Record<string, number>; domainByKind: Record<string, Record<string, number>> };
1987
+ modelAttachments: { byOwnerKind: Record<string, number>; byRole: Record<string, number>; byMediaType: Record<string, number>; byStatus: Record<string, number> };
1988
+ references: { byCurationStatus: Record<string, number>; byCorpus: Record<string, number>; statusByCorpus: Record<string, Record<string, number>> };
1989
+ semanticNodes: { byNodeKind: Record<string, number>; byStatus: Record<string, number>; byCorpus: Record<string, number>; byCategorySet: Record<string, number> };
1990
+ semanticRelations: { byRelationTypeKey: Record<string, number>; byRelationDomain: Record<string, number>; bySubjectKind: Record<string, number>; byObjectKind: Record<string, number> };
1991
+ imports: { byCorpus: Record<string, number> };
1992
+ };
1993
+ } {
1994
+ const parsed = parseJsonObject(value);
1995
+ const facets = normalizeFacets(parsed);
1996
+ return {
1997
+ generatedAt: normalizeOptionalString(parsed.generatedAt) ?? now,
1998
+ staleAt: normalizeOptionalString(parsed.staleAt) ?? now,
1999
+ source: normalizeOptionalString(parsed.source) ?? "missing",
2000
+ latestImportRun: parseJsonObject(parsed.latestImportRun),
2001
+ counts: numberRecord(parsed.counts),
2002
+ assignmentStatusCounts: numberRecord(parsed.assignmentStatusCounts),
2003
+ assignmentTypeCounts: numberRecord(parsed.assignmentTypeCounts),
2004
+ referenceStatusCounts: numberRecord(parsed.referenceStatusCounts),
2005
+ messageKindCounts: numberRecord(parsed.messageKindCounts),
2006
+ messageDomainCounts: numberRecord(parsed.messageDomainCounts),
2007
+ facets,
2008
+ };
2009
+ }
2010
+
2011
+ function normalizeFacets(payload: Record<string, unknown>): ReturnType<typeof createEmptyFacets> {
2012
+ const facets = createEmptyFacets();
2013
+ const parsed = parseJsonObject(payload.facets);
2014
+ const assignments = parseJsonObject(parsed.assignments);
2015
+ const messages = parseJsonObject(parsed.messages);
2016
+ const modelAttachments = parseJsonObject(parsed.modelAttachments);
2017
+ const references = parseJsonObject(parsed.references);
2018
+ const semanticNodes = parseJsonObject(parsed.semanticNodes);
2019
+ const semanticRelations = parseJsonObject(parsed.semanticRelations);
2020
+ const imports = parseJsonObject(parsed.imports);
2021
+ facets.assignments.byStatus = { ...numberRecord(payload.assignmentStatusCounts), ...numberRecord(assignments.byStatus) };
2022
+ facets.assignments.byType = { ...numberRecord(payload.assignmentTypeCounts), ...numberRecord(assignments.byType) };
2023
+ facets.assignments.bySection = numberRecord(assignments.bySection);
2024
+ facets.assignments.statusByType = nestedNumberRecord(assignments.statusByType);
2025
+ facets.assignments.statusBySection = nestedNumberRecord(assignments.statusBySection);
2026
+ facets.assignments.typeBySection = nestedNumberRecord(assignments.typeBySection);
2027
+ facets.messages.byKind = { ...numberRecord(payload.messageKindCounts), ...numberRecord(messages.byKind) };
2028
+ facets.messages.byDomain = { ...numberRecord(payload.messageDomainCounts), ...numberRecord(messages.byDomain) };
2029
+ facets.messages.byStatus = numberRecord(messages.byStatus);
2030
+ facets.messages.domainByKind = nestedNumberRecord(messages.domainByKind);
2031
+ facets.modelAttachments.byOwnerKind = numberRecord(modelAttachments.byOwnerKind);
2032
+ facets.modelAttachments.byRole = numberRecord(modelAttachments.byRole);
2033
+ facets.modelAttachments.byMediaType = numberRecord(modelAttachments.byMediaType);
2034
+ facets.modelAttachments.byStatus = numberRecord(modelAttachments.byStatus);
2035
+ facets.references.byCurationStatus = { ...numberRecord(payload.referenceStatusCounts), ...numberRecord(references.byCurationStatus) };
2036
+ facets.references.byCorpus = numberRecord(references.byCorpus);
2037
+ facets.references.statusByCorpus = nestedNumberRecord(references.statusByCorpus);
2038
+ facets.semanticNodes.byNodeKind = numberRecord(semanticNodes.byNodeKind);
2039
+ facets.semanticNodes.byStatus = numberRecord(semanticNodes.byStatus);
2040
+ facets.semanticNodes.byCorpus = numberRecord(semanticNodes.byCorpus);
2041
+ facets.semanticNodes.byCategorySet = numberRecord(semanticNodes.byCategorySet);
2042
+ facets.semanticRelations.byRelationTypeKey = numberRecord(semanticRelations.byRelationTypeKey);
2043
+ facets.semanticRelations.byRelationDomain = numberRecord(semanticRelations.byRelationDomain);
2044
+ facets.semanticRelations.bySubjectKind = numberRecord(semanticRelations.bySubjectKind);
2045
+ facets.semanticRelations.byObjectKind = numberRecord(semanticRelations.byObjectKind);
2046
+ facets.imports.byCorpus = numberRecord(imports.byCorpus);
2047
+ return facets;
2048
+ }
2049
+
2050
+ function createEmptyFacets() {
2051
+ return {
2052
+ assignments: { byStatus: {}, byType: {}, bySection: {}, statusByType: {}, statusBySection: {}, typeBySection: {} },
2053
+ messages: { byKind: {}, byDomain: {}, byStatus: {}, domainByKind: {} },
2054
+ modelAttachments: { byOwnerKind: {}, byRole: {}, byMediaType: {}, byStatus: {} },
2055
+ references: { byCurationStatus: {}, byCorpus: {}, statusByCorpus: {} },
2056
+ semanticNodes: { byNodeKind: {}, byStatus: {}, byCorpus: {}, byCategorySet: {} },
2057
+ semanticRelations: { byRelationTypeKey: {}, byRelationDomain: {}, bySubjectKind: {}, byObjectKind: {} },
2058
+ imports: { byCorpus: {} },
2059
+ };
2060
+ }
2061
+
2062
+ function nestedNumberRecord(value: unknown): Record<string, Record<string, number>> {
2063
+ const parsed = parseJsonObject(value);
2064
+ const result: Record<string, Record<string, number>> = {};
2065
+ for (const [key, entry] of Object.entries(parsed)) result[key] = numberRecord(entry);
2066
+ return result;
2067
+ }
2068
+
2069
+ async function upsertNewsroomSummaryPayload(
2070
+ client: DataClient,
2071
+ payload: Record<string, unknown>,
2072
+ current: any,
2073
+ now: string,
2074
+ ): Promise<void> {
2075
+ const input = {
2076
+ id: NEWSROOM_SUMMARY_PAYLOAD_ID,
2077
+ ownerType: "newsroom",
2078
+ ownerId: "newsroom",
2079
+ payloadKind: "summary-snapshot",
2080
+ importRunId: normalizeOptionalString(parseJsonObject(payload.latestImportRun).id),
2081
+ createdAt: current?.createdAt ?? now,
2082
+ updatedAt: now,
2083
+ };
2084
+ if (current) {
2085
+ await requireDataResult(client.models.KnowledgeRawPayload.update(input), "update Newsroom summary snapshot");
2086
+ } else {
2087
+ await requireDataResult(client.models.KnowledgeRawPayload.create(input), "create Newsroom summary snapshot");
2088
+ }
2089
+ await putJsonModelPayload(
2090
+ client as any,
2091
+ { ownerKind: NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND, ownerId: NEWSROOM_SUMMARY_PAYLOAD_ID, importRunId: input.importRunId },
2092
+ "raw_payload",
2093
+ "summary-snapshot",
2094
+ payload,
2095
+ { filename: "summary-snapshot.json", now },
2096
+ );
2097
+ }
2098
+
2099
+ async function readNewsroomSummaryPayload(client: DataClient): Promise<Record<string, unknown> | null> {
2100
+ return readJsonModelPayload(
2101
+ client as any,
2102
+ NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND,
2103
+ NEWSROOM_SUMMARY_PAYLOAD_ID,
2104
+ "raw_payload",
2105
+ "summary-snapshot",
2106
+ );
2107
+ }
2108
+
2109
+ function parseJsonObject(value: unknown): Record<string, unknown> {
2110
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
2111
+ if (typeof value !== "string") return {};
2112
+ try {
2113
+ const parsed = JSON.parse(value);
2114
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
2115
+ } catch {
2116
+ return {};
2117
+ }
2118
+ }
2119
+
2120
+ function numberRecord(value: unknown): Record<string, number> {
2121
+ const parsed = parseJsonObject(value);
2122
+ const result: Record<string, number> = {};
2123
+ for (const [key, entry] of Object.entries(parsed)) {
2124
+ const numeric = Number(entry);
2125
+ if (Number.isFinite(numeric)) result[key] = numeric;
2126
+ }
2127
+ return result;
2128
+ }
2129
+
2130
+ function increment(target: Record<string, number>, key: string, delta: number): void {
2131
+ target[key] = Math.max(0, (target[key] ?? 0) + delta);
2132
+ }
2133
+
2134
+ function buildCanonicalMessageInput(input: {
2135
+ id: string;
2136
+ messageKind: string;
2137
+ messageDomain: string;
2138
+ summary?: string | null;
2139
+ source?: string | null;
2140
+ authorSub?: string | null;
2141
+ authorLabel?: string | null;
2142
+ now: string;
2143
+ }) {
2144
+ return {
2145
+ id: input.id,
2146
+ messageKind: input.messageKind,
2147
+ messageDomain: input.messageDomain,
2148
+ status: "active",
2149
+ summary: input.summary ?? null,
2150
+ source: input.source ?? "newsroom",
2151
+ importRunId: null,
2152
+ authorSub: input.authorSub ?? null,
2153
+ authorUserProfileId: null,
2154
+ authorLabel: input.authorLabel ?? null,
2155
+ responseTarget: "none",
2156
+ responseStatus: "COMPLETED",
2157
+ responseOwner: "papyrus-category-action",
2158
+ responseStartedAt: input.now,
2159
+ responseCompletedAt: input.now,
2160
+ responseError: null,
2161
+ createdAt: input.now,
2162
+ updatedAt: input.now,
2163
+ newsroomFeedKey: "messages",
2164
+ };
2165
+ }
2166
+
2167
+ function normalizeRequiredString(value: unknown, fieldName: string): string {
2168
+ if (typeof value !== "string" || value.trim().length === 0) {
2169
+ throw new Error(`${fieldName} is required.`);
2170
+ }
2171
+ return value.trim();
2172
+ }
2173
+
2174
+ function normalizeOptionalString(value: unknown): string | null {
2175
+ if (typeof value !== "string") return null;
2176
+ const normalized = value.trim();
2177
+ return normalized.length > 0 ? normalized : null;
2178
+ }
2179
+
2180
+ function normalizeOptionalBoolean(value: unknown): boolean | null {
2181
+ if (typeof value === "boolean") return value;
2182
+ if (typeof value === "string") {
2183
+ const normalized = value.trim().toLowerCase();
2184
+ if (normalized === "true") return true;
2185
+ if (normalized === "false") return false;
2186
+ }
2187
+ return null;
2188
+ }
2189
+
2190
+ function normalizeOptionalPositiveInteger(value: unknown): number | null {
2191
+ if (value == null || value === "") return null;
2192
+ const numeric = typeof value === "number" ? value : Number(value);
2193
+ if (Number.isInteger(numeric) && numeric > 0) return numeric;
2194
+ return null;
2195
+ }
2196
+
2197
+ function normalizeStringArray(value: unknown): string[] | null {
2198
+ if (!Array.isArray(value)) return null;
2199
+ return compactStringArray(value);
2200
+ }
2201
+
2202
+ function compactStringArray(value: unknown): string[] {
2203
+ if (!Array.isArray(value)) return [];
2204
+ return value
2205
+ .map((entry) => typeof entry === "string" ? entry.trim() : "")
2206
+ .filter(Boolean);
2207
+ }
2208
+
2209
+ function deriveShortTitle(value: unknown): string {
2210
+ const words = String(value ?? "")
2211
+ .replace(/[_/|]+/g, " ")
2212
+ .replace(/[^\p{L}\p{N}\s&+-]/gu, "")
2213
+ .split(/\s+/)
2214
+ .map((word) => word.trim())
2215
+ .filter(Boolean);
2216
+ return words.length ? words.slice(0, 3).join(" ") : "Topic";
2217
+ }
2218
+
2219
+ function getIdentitySub(event: CategoryActionEvent): string | null {
2220
+ const identity = event.identity as { sub?: unknown; username?: unknown } | null | undefined;
2221
+ return normalizeOptionalString(identity?.sub) ?? normalizeOptionalString(identity?.username);
2222
+ }
2223
+
2224
+ function getIdentityLabel(event: CategoryActionEvent): string | null {
2225
+ const identity = event.identity as { claims?: Record<string, unknown>; username?: unknown } | null | undefined;
2226
+ const claims = identity?.claims ?? {};
2227
+ return normalizeOptionalString(claims.email)
2228
+ ?? normalizeOptionalString(claims.name)
2229
+ ?? normalizeOptionalString(identity?.username);
2230
+ }
2231
+
2232
+ function semanticStateKey(kind: string, lineageId: string): string {
2233
+ return `${kind}#${lineageId}#current`;
2234
+ }
2235
+
2236
+ function semanticVersionKey(kind: string, id: string): string {
2237
+ return `${kind}#${id}`;
2238
+ }
2239
+
2240
+ function hashStable(value: unknown): string {
2241
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
2242
+ }
2243
+
2244
+ function stableStringify(value: unknown): string {
2245
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2246
+ if (!value || typeof value !== "object") return JSON.stringify(value);
2247
+ return `{${Object.keys(value as Record<string, unknown>)
2248
+ .sort()
2249
+ .map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`)
2250
+ .join(",")}}`;
2251
+ }
2252
+
2253
+ function safeId(value: unknown): string {
2254
+ return String(value ?? "unknown")
2255
+ .toLowerCase()
2256
+ .replace(/[^a-z0-9]+/g, "-")
2257
+ .replace(/^-|-$/g, "")
2258
+ .slice(0, 80) || "unknown";
2259
+ }