@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,638 @@
1
+ import type {
2
+ AssignmentEventRecord,
3
+ AssignmentRecord,
4
+ EditionSlotRecord,
5
+ MessageRecord,
6
+ NewsroomSectionRecord,
7
+ SemanticRelationRecord,
8
+ } from "./category-repository";
9
+
10
+ export type ReportingStoryBudgetDecision = "select" | "merge" | "brief" | "hold" | "kill";
11
+ export type ReportingStoryBudgetPhase = "plan" | "research" | "reporting" | "review" | "copywriting" | "draft";
12
+
13
+ export type ReportingStoryBudgetCandidate = {
14
+ assignment: AssignmentRecord;
15
+ assignmentId: string;
16
+ title: string;
17
+ editionId: string;
18
+ editionLabel: string;
19
+ sectionKey: string;
20
+ sectionTitle: string;
21
+ topicKey: string | null;
22
+ coverageThemeRunId: string | null;
23
+ phase: ReportingStoryBudgetPhase;
24
+ coverageConceptId: string | null;
25
+ coverageConceptTitle: string | null;
26
+ slotId: string | null;
27
+ slotRank: number | null;
28
+ candidateRank: number | null;
29
+ slotCount: number | null;
30
+ hasReportingPacket: boolean;
31
+ reportingPacketId: string | null;
32
+ researchPacketCount: number;
33
+ summary: string | null;
34
+ editorRecommendation: string | null;
35
+ recommendedAngle: string | null;
36
+ riskFlags: string[];
37
+ coverageGaps: string[];
38
+ openQuestions: string[];
39
+ acceptedReferenceCount: number;
40
+ proposedReferenceCount: number;
41
+ decision: ReportingStoryBudgetDecision | null;
42
+ decisionEventId: string | null;
43
+ degraded: boolean;
44
+ fallbackReason: string | null;
45
+ agentExitStatus: number | null;
46
+ copywritingAssignmentId: string | null;
47
+ copywritingStatus: string | null;
48
+ producedDraftItemId: string | null;
49
+ draftItemId: string | null;
50
+ targetItemId: string | null;
51
+ };
52
+
53
+ export type ReportingStoryBudgetSlot = {
54
+ slotId: string;
55
+ slotRank: number | null;
56
+ targetType: string;
57
+ targetLengthBand: string | null;
58
+ minImageAssets: number | null;
59
+ status: string;
60
+ selectedAssignmentId: string | null;
61
+ candidateCount: number;
62
+ filled: boolean;
63
+ candidates: ReportingStoryBudgetCandidate[];
64
+ };
65
+
66
+ export type ReportingStoryBudgetSection = {
67
+ key: string;
68
+ title: string;
69
+ editionId: string;
70
+ editionLabel: string;
71
+ slotCount: number;
72
+ dispatchedCount: number;
73
+ selectedCount: number;
74
+ briefedCount: number;
75
+ mergedCount: number;
76
+ heldCount: number;
77
+ killedCount: number;
78
+ undecidedCount: number;
79
+ filledCount: number;
80
+ delta: number;
81
+ state: "needs" | "full" | "over";
82
+ filledSlotCount: number;
83
+ unresolvedSlotCount: number;
84
+ slots: ReportingStoryBudgetSlot[];
85
+ candidates: ReportingStoryBudgetCandidate[];
86
+ phase: ReportingStoryBudgetPhase;
87
+ researchPacketCount: number;
88
+ reportingPacketCount: number;
89
+ copywritingAssignmentCount: number;
90
+ draftItemCount: number;
91
+ degradedCount: number;
92
+ };
93
+
94
+ export type ReportingStoryBudget = {
95
+ sections: ReportingStoryBudgetSection[];
96
+ totals: Omit<ReportingStoryBudgetSection, "key" | "title" | "editionId" | "editionLabel" | "candidates" | "slots">;
97
+ };
98
+
99
+ export type ReportingStoryBudgetInput = {
100
+ assignments: AssignmentRecord[];
101
+ messages: MessageRecord[];
102
+ assignmentEvents: AssignmentEventRecord[];
103
+ semanticRelations: SemanticRelationRecord[];
104
+ editionSlots?: EditionSlotRecord[];
105
+ newsroomSections?: NewsroomSectionRecord[];
106
+ messagePayloads?: Record<string, unknown>;
107
+ };
108
+
109
+ const REPORTING_ASSIGNMENT_TYPE = "reporting.edition-candidate";
110
+ const REPORTING_PACKET_KIND = "reporting_context_packet";
111
+ const RESEARCH_PACKET_KIND = "research_packet";
112
+
113
+ export function buildReportingStoryBudget(input: ReportingStoryBudgetInput): ReportingStoryBudget {
114
+ const sectionsByKey = new Map((input.newsroomSections ?? []).map((section) => [section.id, section]));
115
+ const editionSlots = input.editionSlots ?? [];
116
+ const editionSlotById = new Map(editionSlots.map((slot) => [slot.id, slot]));
117
+ const linkedMessageIdsByAssignment = buildLinkedMessageIdsByAssignment(input.semanticRelations ?? []);
118
+ const relationsByAssignment = groupRelationsByAssignment(input.semanticRelations ?? []);
119
+ const researchPacketIdsByReportingAssignment = buildResearchPacketIdsByReportingAssignment(input.assignments ?? [], input.messages ?? [], input.semanticRelations ?? []);
120
+ const copywritingByReportingAssignment = buildCopywritingByReportingAssignment(input.assignments ?? [], input.semanticRelations ?? []);
121
+ const producedDraftItemByCopywritingAssignment = buildProducedItemByAssignment(input.semanticRelations ?? []);
122
+ const messagesById = new Map((input.messages ?? []).map((message) => [message.id, message]));
123
+ const candidates = (input.assignments ?? [])
124
+ .filter((assignment) => assignment.assignmentTypeKey === REPORTING_ASSIGNMENT_TYPE)
125
+ .map((assignment) => {
126
+ const linkedMessages = (linkedMessageIdsByAssignment.get(assignment.id) ?? [])
127
+ .map((id) => messagesById.get(id))
128
+ .filter((message): message is MessageRecord => Boolean(message));
129
+ const fallbackMessages = linkedMessages.length
130
+ ? linkedMessages
131
+ : (input.messages ?? []).filter((message) => metadataAssignmentId(message.metadata) === assignment.id);
132
+ const reportingMessages = fallbackMessages
133
+ .filter((message) => message.messageKind === REPORTING_PACKET_KIND)
134
+ .sort(compareCreatedAtDesc);
135
+ const reportingMessage = reportingMessages[0] ?? null;
136
+ const packet = reportingMessage ? reportingPacketMetadata(reportingMessage, input.messagePayloads?.[reportingMessage.id]) : {};
137
+ const assignmentMetadata = parseObject(assignment.metadata);
138
+ const coverageThemeRunId = normalizeString(packet.coverageThemeRunId)
139
+ ?? normalizeString(packet.storyCycleRunId)
140
+ ?? normalizeString(assignmentMetadata.coverageThemeRunId)
141
+ ?? normalizeString(assignmentMetadata.storyCycleRunId);
142
+ const slotTarget = parseObject(packet.slotTarget ?? assignmentMetadata.slotTarget);
143
+ const relations = relationsByAssignment.get(assignment.id) ?? [];
144
+ const slotRelation = relationByType(relations, "targets_slot", "editionSlot");
145
+ const slotId = normalizeString(slotTarget.slotId)
146
+ ?? normalizeString(slotRelation?.objectId)
147
+ ?? normalizeString(slotRelation?.objectLineageId);
148
+ const slot = slotId ? editionSlotById.get(slotId) : undefined;
149
+ const topicRelation = relationByType(relations, "targets_topic", "category");
150
+ const topicMetadata = parseObject(topicRelation?.metadata);
151
+ const conceptRelation = relationByType(relations, "requests_work_on", "semanticNode")
152
+ ?? relationByType(relations, "uses_signal", "semanticNode");
153
+ const conceptMetadata = parseObject(conceptRelation?.metadata);
154
+ const sectionKey = normalizeString(packet.sectionKey)
155
+ ?? normalizeString(assignment.sectionKey)
156
+ ?? normalizeString(assignment.sectionId)
157
+ ?? normalizeString(assignmentMetadata.sectionKey)
158
+ ?? normalizeString(assignmentMetadata.sectionId)
159
+ ?? relationObjectId(relations, "targets_section")
160
+ ?? "unsectioned";
161
+ const section = sectionsByKey.get(sectionKey);
162
+ const editionId = normalizeString(packet.editionId)
163
+ ?? normalizeString(assignmentMetadata.editionId)
164
+ ?? relationObjectId(relations, "planned_for_edition")
165
+ ?? normalizeString(assignmentMetadata.editionSlug)
166
+ ?? "unplanned";
167
+ const decision = latestReportingDecision(input.assignmentEvents ?? [], assignment.id);
168
+ const copywritingAssignment = decision?.copywritingAssignmentId
169
+ ? (input.assignments ?? []).find((entry) => entry.id === decision.copywritingAssignmentId) ?? null
170
+ : copywritingByReportingAssignment.get(assignment.id) ?? null;
171
+ const copywritingAssignmentId = copywritingAssignment?.id ?? decision?.copywritingAssignmentId ?? null;
172
+ const producedDraftItemId = copywritingAssignmentId
173
+ ? producedDraftItemByCopywritingAssignment.get(copywritingAssignmentId) ?? null
174
+ : null;
175
+ const researchPacketCount = Math.max(
176
+ fallbackMessages.filter((message) => message.messageKind === RESEARCH_PACKET_KIND).length,
177
+ researchPacketIdsByReportingAssignment.get(assignment.id)?.size ?? 0,
178
+ );
179
+ const phase = reportingBudgetPhase({
180
+ hasReportingPacket: Boolean(reportingMessage),
181
+ researchPacketCount,
182
+ decision: decision?.decision ?? null,
183
+ copywritingAssignmentId,
184
+ producedDraftItemId,
185
+ });
186
+ return {
187
+ assignment,
188
+ assignmentId: assignment.id,
189
+ title: assignment.title,
190
+ editionId,
191
+ editionLabel: normalizeString(assignmentMetadata.editionSlug) ?? editionId,
192
+ sectionKey,
193
+ sectionTitle: section?.title ?? normalizeTitle(sectionKey),
194
+ coverageThemeRunId,
195
+ phase,
196
+ topicKey: normalizeString(packet.topicKey)
197
+ ?? normalizeString(assignmentMetadata.focusCategoryKey)
198
+ ?? normalizeString(topicMetadata.categoryKey)
199
+ ?? normalizeString(assignment.primaryFocusCategoryKey),
200
+ coverageConceptId: normalizeString(packet.coverageConceptLineageId)
201
+ ?? normalizeString(assignmentMetadata.coverageConceptLineageId)
202
+ ?? normalizeString(conceptRelation?.objectLineageId),
203
+ coverageConceptTitle: normalizeString(packet.coverageConceptTitle)
204
+ ?? normalizeString(assignmentMetadata.coverageConceptTitle)
205
+ ?? normalizeString(conceptMetadata.coverageConceptTitle)
206
+ ?? normalizeString(conceptMetadata.nodeKey)
207
+ ?? normalizeString(conceptRelation?.objectId),
208
+ slotId: slotId ?? null,
209
+ slotRank: normalizeNumber(slotTarget.slotRank ?? slot?.slotRank),
210
+ candidateRank: normalizeNumber(packet.candidateRank ?? assignmentMetadata.candidateRank ?? slotTarget.candidateRank),
211
+ slotCount: normalizeNumber(slotTarget.slots ?? assignmentMetadata.publicationSlots ?? section?.defaultPageBudget),
212
+ hasReportingPacket: Boolean(reportingMessage),
213
+ reportingPacketId: reportingMessage?.id ?? null,
214
+ researchPacketCount,
215
+ summary: normalizeString(packet.summary) ?? reportingMessage?.summary ?? assignment.summary ?? null,
216
+ editorRecommendation: normalizeString(packet.editorRecommendation),
217
+ recommendedAngle: normalizeString(packet.recommendedAngle),
218
+ riskFlags: stringList(packet.riskFlags),
219
+ coverageGaps: stringList(packet.coverageGaps),
220
+ openQuestions: stringList(packet.openQuestions),
221
+ acceptedReferenceCount: stringList(packet.acceptedReferenceIds).length,
222
+ proposedReferenceCount: arrayLength(packet.proposedReferences),
223
+ decision: decision?.decision ?? null,
224
+ decisionEventId: decision?.eventId ?? null,
225
+ degraded: Boolean(packet.degraded ?? packet.fallback),
226
+ fallbackReason: normalizeString(packet.fallbackReason) ?? normalizeString(parseObject(packet.fallback).reason),
227
+ agentExitStatus: normalizeNumberAllowZero(packet.agentExitStatus ?? packet.exitStatus),
228
+ copywritingAssignmentId,
229
+ copywritingStatus: copywritingAssignment?.status ?? null,
230
+ producedDraftItemId,
231
+ draftItemId: producedDraftItemId ?? decision?.draftItemId ?? null,
232
+ targetItemId: decision?.targetItemId ?? null,
233
+ };
234
+ })
235
+ .sort(compareCandidates);
236
+
237
+ const grouped = new Map<string, ReportingStoryBudgetCandidate[]>();
238
+ for (const candidate of candidates) {
239
+ const key = `${candidate.editionId}::${candidate.sectionKey}`;
240
+ grouped.set(key, [...(grouped.get(key) ?? []), candidate]);
241
+ }
242
+
243
+ const sections = Array.from(grouped.values())
244
+ .map((sectionCandidates) => {
245
+ const first = sectionCandidates[0];
246
+ const sectionSlots = editionSlots.filter((slot) => (
247
+ slot.sectionKey === first.sectionKey
248
+ && slot.editionId === first.editionId
249
+ ));
250
+ return buildSectionBudget(sectionCandidates, sectionSlots);
251
+ })
252
+ .sort((left, right) => (
253
+ left.editionLabel.localeCompare(right.editionLabel)
254
+ || left.title.localeCompare(right.title)
255
+ ));
256
+ return {
257
+ sections,
258
+ totals: totalBudget(sections),
259
+ };
260
+ }
261
+
262
+ function buildLinkedMessageIdsByAssignment(relations: SemanticRelationRecord[]): Map<string, string[]> {
263
+ const byAssignment = new Map<string, string[]>();
264
+ for (const relation of relations) {
265
+ if (relation.relationState === "superseded") continue;
266
+ const relationType = relation.relationTypeKey ?? relation.predicate;
267
+ if (relationType === "produces" && relation.subjectKind === "assignment" && relation.objectKind === "message") {
268
+ byAssignment.set(relation.subjectId, [...(byAssignment.get(relation.subjectId) ?? []), relation.objectId]);
269
+ continue;
270
+ }
271
+ if (relationType === "comment" && relation.subjectKind === "message" && relation.objectKind === "assignment") {
272
+ byAssignment.set(relation.objectId, [...(byAssignment.get(relation.objectId) ?? []), relation.subjectId]);
273
+ }
274
+ }
275
+ return byAssignment;
276
+ }
277
+
278
+ function groupRelationsByAssignment(relations: SemanticRelationRecord[]): Map<string, SemanticRelationRecord[]> {
279
+ const byAssignment = new Map<string, SemanticRelationRecord[]>();
280
+ for (const relation of relations) {
281
+ if (relation.subjectKind !== "assignment") continue;
282
+ if (relation.relationState === "superseded") continue;
283
+ byAssignment.set(relation.subjectId, [...(byAssignment.get(relation.subjectId) ?? []), relation]);
284
+ }
285
+ return byAssignment;
286
+ }
287
+
288
+ function buildCopywritingByReportingAssignment(assignments: AssignmentRecord[], relations: SemanticRelationRecord[]): Map<string, AssignmentRecord> {
289
+ const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
290
+ const result = new Map<string, AssignmentRecord>();
291
+ for (const assignment of assignments) {
292
+ if (!assignment.assignmentTypeKey.startsWith("copywriting.")) continue;
293
+ const metadata = parseObject(assignment.metadata);
294
+ const sourceReportingAssignmentId = normalizeString(metadata.sourceReportingAssignmentId);
295
+ if (sourceReportingAssignmentId) result.set(sourceReportingAssignmentId, assignment);
296
+ }
297
+ for (const relation of relations) {
298
+ if (relation.relationState === "superseded") continue;
299
+ if ((relation.relationTypeKey ?? relation.predicate) !== "derived_from") continue;
300
+ if (relation.subjectKind !== "assignment" || relation.objectKind !== "assignment") continue;
301
+ const copywriting = assignmentById.get(relation.subjectId);
302
+ const reporting = assignmentById.get(relation.objectId);
303
+ if (!copywriting?.assignmentTypeKey.startsWith("copywriting.")) continue;
304
+ if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE) continue;
305
+ result.set(reporting.id, copywriting);
306
+ }
307
+ return result;
308
+ }
309
+
310
+ function buildResearchPacketIdsByReportingAssignment(assignments: AssignmentRecord[], messages: MessageRecord[], relations: SemanticRelationRecord[]): Map<string, Set<string>> {
311
+ const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
312
+ const messageById = new Map(messages.map((message) => [message.id, message]));
313
+ const producedResearchPacketsByAssignment = new Map<string, Set<string>>();
314
+ const reportingPacketIdsByAssignment = new Map<string, Set<string>>();
315
+ const result = new Map<string, Set<string>>();
316
+ for (const relation of relations) {
317
+ if (relation.relationState === "superseded") continue;
318
+ if ((relation.relationTypeKey ?? relation.predicate) !== "produces") continue;
319
+ if (relation.subjectKind !== "assignment" || relation.objectKind !== "message") continue;
320
+ const message = messageById.get(relation.objectId);
321
+ if (message?.messageKind === RESEARCH_PACKET_KIND) {
322
+ producedResearchPacketsByAssignment.set(relation.subjectId, addToSet(producedResearchPacketsByAssignment.get(relation.subjectId), relation.objectId));
323
+ }
324
+ if (message?.messageKind === REPORTING_PACKET_KIND) {
325
+ reportingPacketIdsByAssignment.set(relation.subjectId, addToSet(reportingPacketIdsByAssignment.get(relation.subjectId), relation.objectId));
326
+ }
327
+ }
328
+ for (const relation of relations) {
329
+ if (relation.relationState === "superseded") continue;
330
+ if ((relation.relationTypeKey ?? relation.predicate) !== "derived_from") continue;
331
+ if (relation.subjectKind === "assignment" && relation.objectKind === "assignment") {
332
+ const reporting = assignmentById.get(relation.subjectId);
333
+ const research = assignmentById.get(relation.objectId);
334
+ if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.edition-candidate") continue;
335
+ for (const packetId of producedResearchPacketsByAssignment.get(research.id) ?? []) {
336
+ result.set(reporting.id, addToSet(result.get(reporting.id), packetId));
337
+ }
338
+ }
339
+ if (relation.subjectKind === "message" && relation.objectKind === "message") {
340
+ const researchMessage = messageById.get(relation.objectId);
341
+ if (researchMessage?.messageKind !== RESEARCH_PACKET_KIND) continue;
342
+ for (const [reportingAssignmentId, reportingPacketIds] of reportingPacketIdsByAssignment.entries()) {
343
+ if (reportingPacketIds.has(relation.subjectId)) {
344
+ result.set(reportingAssignmentId, addToSet(result.get(reportingAssignmentId), relation.objectId));
345
+ }
346
+ }
347
+ }
348
+ }
349
+ return result;
350
+ }
351
+
352
+ function addToSet<T>(set: Set<T> | undefined, value: T): Set<T> {
353
+ const next = set ?? new Set<T>();
354
+ next.add(value);
355
+ return next;
356
+ }
357
+
358
+ function buildProducedItemByAssignment(relations: SemanticRelationRecord[]): Map<string, string> {
359
+ const result = new Map<string, string>();
360
+ for (const relation of relations) {
361
+ if (relation.relationState === "superseded") continue;
362
+ if ((relation.relationTypeKey ?? relation.predicate) !== "produces") continue;
363
+ if (relation.subjectKind !== "assignment" || relation.objectKind !== "item") continue;
364
+ result.set(relation.subjectId, relation.objectId);
365
+ }
366
+ return result;
367
+ }
368
+
369
+ function relationByType(relations: SemanticRelationRecord[], predicate: string, objectKind?: string): SemanticRelationRecord | null {
370
+ return relations.find((relation) => (
371
+ (relation.relationTypeKey ?? relation.predicate) === predicate
372
+ && (!objectKind || relation.objectKind === objectKind)
373
+ )) ?? null;
374
+ }
375
+
376
+ function buildSectionBudget(candidates: ReportingStoryBudgetCandidate[], sectionSlots: EditionSlotRecord[]): ReportingStoryBudgetSection {
377
+ const first = candidates[0];
378
+ const inferredSlotCount = Math.max(1, ...candidates.map((candidate) => candidate.slotCount ?? 0));
379
+ const normalizedSlots = sectionSlots.length
380
+ ? sectionSlots
381
+ : Array.from({ length: inferredSlotCount }, (_, index) => ({
382
+ id: `synthetic-slot-${first.editionId}-${first.sectionKey}-${index + 1}`,
383
+ editionId: first.editionId,
384
+ sectionKey: first.sectionKey,
385
+ slotRank: index + 1,
386
+ targetType: "article",
387
+ targetLengthBand: "standard",
388
+ minImageAssets: null,
389
+ status: "assigned",
390
+ selectedAssignmentId: null,
391
+ createdAt: first.assignment.createdAt,
392
+ updatedAt: first.assignment.updatedAt,
393
+ } as EditionSlotRecord));
394
+ const candidateBySlotId = new Map<string, ReportingStoryBudgetCandidate[]>();
395
+ for (const candidate of candidates) {
396
+ const key = candidate.slotId ?? `synthetic-slot-${first.editionId}-${first.sectionKey}-${candidate.slotRank ?? 0}`;
397
+ candidateBySlotId.set(key, [...(candidateBySlotId.get(key) ?? []), candidate]);
398
+ }
399
+ const slots: ReportingStoryBudgetSlot[] = normalizedSlots
400
+ .map((slot) => {
401
+ const slotCandidates = [...(candidateBySlotId.get(slot.id) ?? [])].sort(compareCandidates);
402
+ let selectedAssignmentId = normalizeString(slot.selectedAssignmentId);
403
+ if (!selectedAssignmentId) {
404
+ selectedAssignmentId = slotCandidates.find((candidate) => candidate.decision === "select" || candidate.decision === "brief")?.assignmentId ?? null;
405
+ }
406
+ const status = String(slot.status || "open").trim().toLowerCase();
407
+ const filled = status === "filled" || status === "selected" || status === "briefed" || Boolean(selectedAssignmentId);
408
+ return {
409
+ slotId: slot.id,
410
+ slotRank: slot.slotRank ?? null,
411
+ targetType: slot.targetType ?? "article",
412
+ targetLengthBand: normalizeString(slot.targetLengthBand) ?? null,
413
+ minImageAssets: typeof slot.minImageAssets === "number" ? slot.minImageAssets : null,
414
+ status: status || "open",
415
+ selectedAssignmentId,
416
+ candidateCount: slotCandidates.length,
417
+ filled,
418
+ candidates: slotCandidates,
419
+ };
420
+ })
421
+ .sort((left, right) => (left.slotRank ?? 9999) - (right.slotRank ?? 9999) || left.slotId.localeCompare(right.slotId));
422
+ const slotCount = slots.length || inferredSlotCount;
423
+ const selectedCount = candidates.filter((candidate) => candidate.decision === "select").length;
424
+ const briefedCount = candidates.filter((candidate) => candidate.decision === "brief").length;
425
+ const mergedCount = candidates.filter((candidate) => candidate.decision === "merge").length;
426
+ const heldCount = candidates.filter((candidate) => candidate.decision === "hold").length;
427
+ const killedCount = candidates.filter((candidate) => candidate.decision === "kill").length;
428
+ const undecidedCount = candidates.filter((candidate) => !candidate.decision).length;
429
+ const filledCount = slots.length ? slots.filter((slot) => slot.filled).length : selectedCount + briefedCount;
430
+ const delta = filledCount - slotCount;
431
+ const researchPacketCount = candidates.reduce((sum, candidate) => sum + candidate.researchPacketCount, 0);
432
+ const reportingPacketCount = candidates.filter((candidate) => candidate.hasReportingPacket).length;
433
+ const copywritingAssignmentCount = candidates.filter((candidate) => candidate.copywritingAssignmentId).length;
434
+ const draftItemCount = candidates.filter((candidate) => candidate.draftItemId).length;
435
+ const degradedCount = candidates.filter((candidate) => candidate.degraded).length;
436
+ return {
437
+ key: first.sectionKey,
438
+ title: first.sectionTitle,
439
+ editionId: first.editionId,
440
+ editionLabel: first.editionLabel,
441
+ slotCount,
442
+ dispatchedCount: candidates.length,
443
+ selectedCount,
444
+ briefedCount,
445
+ mergedCount,
446
+ heldCount,
447
+ killedCount,
448
+ undecidedCount,
449
+ filledCount,
450
+ delta,
451
+ state: delta > 0 ? "over" : delta === 0 ? "full" : "needs",
452
+ filledSlotCount: filledCount,
453
+ unresolvedSlotCount: Math.max(slotCount - filledCount, 0),
454
+ slots,
455
+ candidates,
456
+ phase: highestReportingBudgetPhase(candidates.map((candidate) => candidate.phase)),
457
+ researchPacketCount,
458
+ reportingPacketCount,
459
+ copywritingAssignmentCount,
460
+ draftItemCount,
461
+ degradedCount,
462
+ };
463
+ }
464
+
465
+ function totalBudget(sections: ReportingStoryBudgetSection[]): ReportingStoryBudget["totals"] {
466
+ const totals = sections.reduce((memo, section) => ({
467
+ slotCount: memo.slotCount + section.slotCount,
468
+ dispatchedCount: memo.dispatchedCount + section.dispatchedCount,
469
+ selectedCount: memo.selectedCount + section.selectedCount,
470
+ briefedCount: memo.briefedCount + section.briefedCount,
471
+ mergedCount: memo.mergedCount + section.mergedCount,
472
+ heldCount: memo.heldCount + section.heldCount,
473
+ killedCount: memo.killedCount + section.killedCount,
474
+ undecidedCount: memo.undecidedCount + section.undecidedCount,
475
+ filledCount: memo.filledCount + section.filledCount,
476
+ filledSlotCount: memo.filledSlotCount + section.filledSlotCount,
477
+ unresolvedSlotCount: memo.unresolvedSlotCount + section.unresolvedSlotCount,
478
+ delta: memo.delta + section.delta,
479
+ researchPacketCount: memo.researchPacketCount + section.researchPacketCount,
480
+ reportingPacketCount: memo.reportingPacketCount + section.reportingPacketCount,
481
+ copywritingAssignmentCount: memo.copywritingAssignmentCount + section.copywritingAssignmentCount,
482
+ draftItemCount: memo.draftItemCount + section.draftItemCount,
483
+ degradedCount: memo.degradedCount + section.degradedCount,
484
+ }), {
485
+ slotCount: 0,
486
+ dispatchedCount: 0,
487
+ selectedCount: 0,
488
+ briefedCount: 0,
489
+ mergedCount: 0,
490
+ heldCount: 0,
491
+ killedCount: 0,
492
+ undecidedCount: 0,
493
+ filledCount: 0,
494
+ filledSlotCount: 0,
495
+ unresolvedSlotCount: 0,
496
+ delta: 0,
497
+ researchPacketCount: 0,
498
+ reportingPacketCount: 0,
499
+ copywritingAssignmentCount: 0,
500
+ draftItemCount: 0,
501
+ degradedCount: 0,
502
+ });
503
+ return {
504
+ ...totals,
505
+ state: totals.delta > 0 ? "over" : totals.delta === 0 ? "full" : "needs",
506
+ phase: highestReportingBudgetPhase(sections.map((section) => section.phase)),
507
+ };
508
+ }
509
+
510
+ function reportingBudgetPhase(input: {
511
+ hasReportingPacket: boolean;
512
+ researchPacketCount: number;
513
+ decision: ReportingStoryBudgetDecision | null;
514
+ copywritingAssignmentId: string | null;
515
+ producedDraftItemId: string | null;
516
+ }): ReportingStoryBudgetPhase {
517
+ if (input.producedDraftItemId) return "draft";
518
+ if (input.copywritingAssignmentId) return "copywriting";
519
+ if (input.decision) return "review";
520
+ if (input.hasReportingPacket) return "reporting";
521
+ if (input.researchPacketCount > 0) return "research";
522
+ return "plan";
523
+ }
524
+
525
+ function highestReportingBudgetPhase(phases: ReportingStoryBudgetPhase[]): ReportingStoryBudgetPhase {
526
+ const order: ReportingStoryBudgetPhase[] = ["plan", "research", "reporting", "review", "copywriting", "draft"];
527
+ return phases.reduce((highest, phase) => (
528
+ order.indexOf(phase) > order.indexOf(highest) ? phase : highest
529
+ ), "plan" as ReportingStoryBudgetPhase);
530
+ }
531
+
532
+ function latestReportingDecision(events: AssignmentEventRecord[], assignmentId: string) {
533
+ const event = events
534
+ .filter((entry) => entry.assignmentId === assignmentId && entry.eventType.startsWith("reporting_"))
535
+ .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)))[0];
536
+ if (!event) return null;
537
+ const metadata = parseObject(event.metadata);
538
+ const decision = normalizeDecision(metadata.decision ?? event.eventType.replace(/^reporting_/, ""));
539
+ if (!decision) return null;
540
+ return {
541
+ decision,
542
+ eventId: event.id,
543
+ copywritingAssignmentId: normalizeString(metadata.copywritingAssignmentId),
544
+ draftItemId: normalizeString(metadata.draftItemId),
545
+ targetItemId: normalizeString(metadata.targetItemId),
546
+ };
547
+ }
548
+
549
+ function reportingPacketMetadata(message: MessageRecord, payload: unknown): Record<string, unknown> {
550
+ const fromPayload = reportingObjectFromPayload(payload);
551
+ if (fromPayload) return fromPayload;
552
+ const metadata = parseObject(message.metadata);
553
+ const reporting = parseObject(metadata.reporting);
554
+ return Object.keys(reporting).length ? reporting : metadata;
555
+ }
556
+
557
+ function reportingObjectFromPayload(payload: unknown): Record<string, unknown> | null {
558
+ const parsed = parseObject(payload);
559
+ if (!Object.keys(parsed).length) return null;
560
+ const reporting = parseObject(parsed.reporting);
561
+ return Object.keys(reporting).length ? reporting : parsed;
562
+ }
563
+
564
+ function metadataAssignmentId(value: unknown): string | null {
565
+ const metadata = parseObject(value);
566
+ return normalizeString(metadata.assignmentId);
567
+ }
568
+
569
+ function relationObjectId(relations: SemanticRelationRecord[], predicate: string): string | null {
570
+ const relation = relations.find((entry) => (entry.relationTypeKey ?? entry.predicate) === predicate);
571
+ return relation ? normalizeString(relation.objectLineageId) ?? normalizeString(relation.objectId) : null;
572
+ }
573
+
574
+ function compareCandidates(left: ReportingStoryBudgetCandidate, right: ReportingStoryBudgetCandidate): number {
575
+ return (
576
+ left.editionLabel.localeCompare(right.editionLabel)
577
+ || left.sectionTitle.localeCompare(right.sectionTitle)
578
+ || (left.slotRank ?? 9999) - (right.slotRank ?? 9999)
579
+ || (left.candidateRank ?? 9999) - (right.candidateRank ?? 9999)
580
+ || left.title.localeCompare(right.title)
581
+ );
582
+ }
583
+
584
+ function compareCreatedAtDesc(left: MessageRecord, right: MessageRecord): number {
585
+ return String(right.createdAt).localeCompare(String(left.createdAt));
586
+ }
587
+
588
+ function parseObject(value: unknown): Record<string, unknown> {
589
+ if (!value) return {};
590
+ if (typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
591
+ if (typeof value !== "string") return {};
592
+ try {
593
+ const parsed = JSON.parse(value);
594
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
595
+ } catch {
596
+ return {};
597
+ }
598
+ }
599
+
600
+ function stringList(value: unknown): string[] {
601
+ if (!Array.isArray(value)) return [];
602
+ return value
603
+ .map((entry) => typeof entry === "string" ? entry.trim() : "")
604
+ .filter(Boolean);
605
+ }
606
+
607
+ function arrayLength(value: unknown): number {
608
+ return Array.isArray(value) ? value.length : 0;
609
+ }
610
+
611
+ function normalizeString(value: unknown): string | null {
612
+ return typeof value === "string" && value.trim() ? value.trim() : null;
613
+ }
614
+
615
+ function normalizeNumber(value: unknown): number | null {
616
+ const parsed = Number(value);
617
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
618
+ }
619
+
620
+ function normalizeNumberAllowZero(value: unknown): number | null {
621
+ const parsed = Number(value);
622
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
623
+ }
624
+
625
+ function normalizeDecision(value: unknown): ReportingStoryBudgetDecision | null {
626
+ const decision = String(value ?? "").trim().toLowerCase().replace(/^reporting_/, "") as ReportingStoryBudgetDecision;
627
+ return decision === "select" || decision === "merge" || decision === "brief" || decision === "hold" || decision === "kill"
628
+ ? decision
629
+ : null;
630
+ }
631
+
632
+ function normalizeTitle(value: string): string {
633
+ return value
634
+ .split(/[-_\s]+/)
635
+ .filter(Boolean)
636
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
637
+ .join(" ") || "Unsectioned";
638
+ }