@anthusai/papyrus 5.57.0 → 5.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +28 -3
  9. package/amplify/data/resource.ts +28 -3
  10. package/amplify/data/schema.js +34 -5
  11. package/amplify/data/schema.ts +34 -5
  12. package/amplify/functions/category-action/handler.ts +51 -125
  13. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  14. package/components/conversation-context.tsx +29 -2
  15. package/components/conversation-provider.tsx +5 -1
  16. package/components/conversation-thread.tsx +154 -25
  17. package/components/conversations-memory-source.ts +83 -0
  18. package/components/conversations-network-source.ts +52 -0
  19. package/components/conversations-page.tsx +14 -0
  20. package/components/conversations-source.ts +36 -0
  21. package/components/conversations-view.tsx +680 -0
  22. package/components/decision-control.tsx +307 -0
  23. package/components/list-detail-layout.tsx +44 -0
  24. package/components/needs-me-bodies.tsx +18 -17
  25. package/components/needs-me-feed.tsx +16 -32
  26. package/components/news-desk-taxonomy-client.ts +216 -62
  27. package/components/newsroom-article-editor.tsx +10 -4
  28. package/components/newsroom-organizer.css +34 -1
  29. package/components/newsroom-references-view.tsx +41 -100
  30. package/components/notification-cards.tsx +9 -8
  31. package/components/notification-center.tsx +5 -7
  32. package/components/proposals-view.tsx +4 -4
  33. package/components/reference-conversation.tsx +5 -0
  34. package/components/reference-feed.tsx +129 -127
  35. package/components/reference-review-card.tsx +9 -135
  36. package/components/references-page-loading.tsx +12 -6
  37. package/components/story-reporting-trail-client.ts +4 -3
  38. package/components/story-reporting-trail.tsx +8 -0
  39. package/components/topic-steering-workspace.tsx +44 -786
  40. package/components/use-conversation.ts +36 -2
  41. package/components/use-feed-preferences.ts +33 -0
  42. package/components/use-reference-ratings.ts +11 -7
  43. package/lib/category-repository.ts +14 -38
  44. package/lib/console-chat-client.ts +8 -178
  45. package/lib/conversation-feed.ts +96 -0
  46. package/lib/conversation.ts +73 -2
  47. package/lib/cyclotron-suggestions.ts +18 -36
  48. package/lib/local-preview-seed.ts +16 -27
  49. package/lib/needs-me-items.ts +4 -4
  50. package/lib/needs-me-ranking.ts +9 -22
  51. package/lib/newsroom-board.ts +10 -1
  52. package/lib/newsroom-demo-dashboard.ts +0 -7
  53. package/lib/newsroom-organizer-nav.ts +2 -2
  54. package/lib/post-message.ts +54 -0
  55. package/lib/proposals-admin.ts +2 -2
  56. package/lib/reference-feed-keys.ts +69 -0
  57. package/lib/reference-feed-ranking.ts +77 -160
  58. package/lib/reference-policy.ts +83 -11
  59. package/lib/reference-review-details.ts +21 -57
  60. package/lib/relevance-decisions.ts +6 -2
  61. package/lib/review-drafts.ts +2 -2
  62. package/lib/review-fixture.ts +1 -1
  63. package/lib/review-requests.ts +4 -4
  64. package/lib/semantic-graph.ts +1 -1
  65. package/lib/story-reporting-trail.ts +28 -2
  66. package/package.json +12 -2
  67. package/routes.manifest.json +1 -1
  68. package/components/review-rating-grid.tsx +0 -190
@@ -1,4 +1,4 @@
1
- import type { MessageRecord } from "./category-repository";
1
+ import type { ReferenceRecord } from "./category-repository";
2
2
  import type { RelevanceCuration, RelevanceDecisionView } from "./relevance-decisions";
3
3
  import { safeId } from "./relevance-status";
4
4
 
@@ -149,22 +149,12 @@ function parseMetadata(value: unknown): Record<string, unknown> {
149
149
  }
150
150
 
151
151
  /**
152
- * Reference lineage ids whose latest curation Message was made by a signed-in
153
- * person. Agent and classifier status changes never appear here, and neither
154
- * does a reference a person decided before a later non-human change.
152
+ * Reference lineage ids whose latest vote was made by a signed-in person
153
+ * (`curationActorKind` is `human`). Agent, Cyclotron and rule votes never appear
154
+ * here, and neither does a reference a person decided before a later non-human change.
155
155
  */
156
- export function referencesWithHumanLatestCuration(messages: MessageRecord[]): Set<string> {
157
- const latestByReference = new Map<string, { createdAt: string; human: boolean }>();
158
- for (const message of messages) {
159
- if (message.messageKind !== "reference_curation") continue;
160
- const metadata = parseMetadata(message.metadata);
161
- const lineageId = typeof metadata.referenceLineageId === "string" ? metadata.referenceLineageId : "";
162
- if (!lineageId) continue;
163
- const seen = latestByReference.get(lineageId);
164
- if (seen && seen.createdAt >= message.createdAt) continue;
165
- latestByReference.set(lineageId, { createdAt: message.createdAt, human: metadata.actorKind === "human" });
166
- }
167
- return new Set([...latestByReference].filter(([, entry]) => entry.human).map(([lineageId]) => lineageId));
156
+ export function referencesWithHumanLatestCuration(references: readonly ReferenceRecord[]): Set<string> {
157
+ return new Set(references.filter((reference) => reference.curationActorKind === "human").map((reference) => reference.lineageId ?? reference.id));
168
158
  }
169
159
 
170
160
  export type SuggestionChoice = "agree" | "overrule";
@@ -233,30 +223,22 @@ export function delegationShortId(delegationId: string): string {
233
223
  }
234
224
 
235
225
  /**
236
- * Delegated curations by reference lineage, from each reference's latest
237
- * curation Message only. The person is the Message author; the agent is relayedBy.
226
+ * Delegated curations by reference lineage, from each Reference's latest vote only.
227
+ * The person is who set the status (`curationStatusUpdatedBy`); the agent is the delegation's relayedBy.
238
228
  */
239
- export function delegatedCurationsByReference(messages: MessageRecord[]): Map<string, DelegatedCuration> {
240
- const latest = new Map<string, MessageRecord>();
241
- for (const message of messages) {
242
- if (message.messageKind !== "reference_curation") continue;
243
- const lineageId = parseMetadata(message.metadata).referenceLineageId;
244
- if (typeof lineageId !== "string" || !lineageId) continue;
245
- const seen = latest.get(lineageId);
246
- if (!seen || seen.createdAt < message.createdAt) latest.set(lineageId, message);
247
- }
229
+ export function delegatedCurationsByReference(references: readonly ReferenceRecord[]): Map<string, DelegatedCuration> {
248
230
  const delegated = new Map<string, DelegatedCuration>();
249
- for (const [lineageId, message] of latest) {
250
- const metadata = parseMetadata(message.metadata);
251
- if (metadata.attestation !== "delegated") continue;
252
- const delegationId = typeof metadata.delegationId === "string" ? metadata.delegationId : "";
253
- delegated.set(lineageId, {
254
- agent: typeof metadata.relayedBy === "string" && metadata.relayedBy ? metadata.relayedBy : "an agent",
255
- person: message.authorLabel?.trim() || null,
256
- instruction: typeof metadata.instruction === "string" ? metadata.instruction : "",
231
+ for (const reference of references) {
232
+ const delegation = parseMetadata(reference.curationDelegation);
233
+ if (!Object.keys(delegation).length) continue;
234
+ const delegationId = typeof delegation.delegationId === "string" ? delegation.delegationId : "";
235
+ delegated.set(reference.lineageId ?? reference.id, {
236
+ agent: typeof delegation.relayedBy === "string" && delegation.relayedBy ? delegation.relayedBy : "an agent",
237
+ person: reference.curationStatusUpdatedBy?.trim() || null,
238
+ instruction: typeof delegation.instruction === "string" ? delegation.instruction : "",
257
239
  delegationId,
258
240
  delegationShortId: delegationShortId(delegationId),
259
- countsAsTrainingLabel: metadata.actorKind === "human",
241
+ countsAsTrainingLabel: reference.curationActorKind === "human",
260
242
  });
261
243
  }
262
244
  return delegated;
@@ -210,15 +210,16 @@ const PREVIEW_CYCLOTRON_ID = "papyrus-relevance";
210
210
  /**
211
211
  * Fixture data for reviewing the cyclotron suggestion UI without a backend:
212
212
  * suggestions with provenance on the first references, a mix of human and
213
- * non-human curation messages, and a live-agreement payload.
213
+ * non-human votes recorded on the References, and a live-agreement payload.
214
214
  */
215
215
  export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[]): {
216
216
  relations: SemanticRelationRecord[];
217
- messages: MessageRecord[];
217
+ /** The first few References with a latest vote recorded on them, by Reference id. */
218
+ votedReferences: Map<string, ReferenceRecord>;
218
219
  shadowAgreement: ShadowAgreement;
219
220
  } {
220
221
  const relations: SemanticRelationRecord[] = [];
221
- const messages: MessageRecord[] = [];
222
+ const votedReferences = new Map<string, ReferenceRecord>();
222
223
  references.slice(0, PREVIEW_SUGGESTION_PLANS.length).forEach((reference, index) => {
223
224
  const plan = PREVIEW_SUGGESTION_PLANS[index];
224
225
  const lineageId = reference.lineageId ?? reference.id;
@@ -264,35 +265,23 @@ export function createLocalPreviewCyclotronFixtures(references: ReferenceRecord[
264
265
  }),
265
266
  } as SemanticRelationRecord);
266
267
  if (plan.curation !== "none") {
267
- const createdAt = isoTimestampHoursBeforeBase(30 - index);
268
- messages.push({
269
- id: `preview-curation-message-${index}`,
270
- messageKind: "reference_curation",
271
- messageDomain: "commentary",
272
- status: "active",
273
- summary: `${reference.title ?? lineageId}: curated`,
274
- authorLabel: plan.curation === "delegated" ? "Maya Okafor" : null,
275
- source: "newsroom",
276
- createdAt,
277
- updatedAt: createdAt,
278
- metadata: JSON.stringify({
279
- action: reference.curationStatus === "rejected" ? "reject" : "accept",
280
- curationStatus: reference.curationStatus,
281
- referenceLineageId: lineageId,
282
- actorKind: plan.curation === "service" ? "service" : "human",
283
- ...(plan.curation === "delegated" ? {
284
- attestation: "delegated",
285
- relayedBy: "newsroom-reporter",
286
- delegationId: "curation-delegation-9b1f6c24-5d0e-4a7b-8c31-2e6f0a1d7b45",
287
- instruction: "Reject that one, it duplicates the filing from Tuesday.",
288
- } : {}),
289
- }),
268
+ // A Reference keeps its latest vote on itself (no Message): the details sit in its curation* fields.
269
+ votedReferences.set(reference.id, {
270
+ ...reference,
271
+ curationStatusUpdatedAt: isoTimestampHoursBeforeBase(30 - index),
272
+ curationStatusUpdatedBy: plan.curation === "delegated" ? "Maya Okafor" : reference.curationStatusUpdatedBy,
273
+ curationActorKind: plan.curation === "service" ? "agent" : "human",
274
+ curationDelegation: plan.curation === "delegated" ? {
275
+ delegationId: "curation-delegation-9b1f6c24",
276
+ relayedBy: "newsroom-reporter",
277
+ instruction: "Reject that one, it duplicates the filing from Tuesday.",
278
+ } : null,
290
279
  });
291
280
  }
292
281
  });
293
282
  return {
294
283
  relations,
295
- messages,
284
+ votedReferences,
296
285
  shadowAgreement: {
297
286
  schema: SHADOW_AGREEMENT_SCHEMA,
298
287
  comparisons: 23,
@@ -127,9 +127,9 @@ export function draftItem(item: DraftItem, bySource: ReadonlyMap<string, Referen
127
127
  type: "draft",
128
128
  id: item.key,
129
129
  title: view?.headline ?? link?.label ?? "Draft",
130
- dek: view ? view.dek : "Loading the draft…",
130
+ dek: view ? view.dek : "Loading the Proposed Story…",
131
131
  image: picture.imageUrl,
132
- source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "AI draft"),
132
+ source: picture.siteName ?? picture.host ?? (view ? `AI ${view.kindLabel.toLowerCase()}` : "Proposed Story"),
133
133
  host: picture.host,
134
134
  createdAt: timeOf(request.raisedAt),
135
135
  importance: 1,
@@ -273,10 +273,10 @@ function plural(count: number, one: string, many = `${one}s`): string {
273
273
  return `${count} ${count === 1 ? one : many}`;
274
274
  }
275
275
 
276
- /** "3 drafts and 12 references waiting for you"; "All caught up" when nothing waits. */
276
+ /** "3 Proposed Stories and 12 references waiting for you"; "All caught up" when nothing waits. */
277
277
  export function waitingTextFor(counts: NeedsMeCounts): string {
278
278
  const parts = [
279
- counts.drafts ? plural(counts.drafts, "draft") : null,
279
+ counts.drafts ? plural(counts.drafts, "Proposed Story", "Proposed Stories") : null,
280
280
  counts.references ? plural(counts.references, "reference") : null,
281
281
  counts.stories ? plural(counts.stories, "story review") : null,
282
282
  counts.email ? plural(counts.email, "held email", "held emails") : null,
@@ -5,12 +5,10 @@
5
5
  * What they share is one question: where would a person's answer change the most? In plain words, as the page says them:
6
6
  * 1. Hard gates first. Nothing continues until a person decides one, so it leads, oldest first.
7
7
  * 2. Then the interim "value of your input" score:
8
- * value = uncertainty x importance x freshness
8
+ * value = uncertainty x importance
9
9
  * - uncertainty is 1 - |2P - 1|, where P is the Cyclotron's probability that you would Accept. A coin toss (P = 0.5) is 1, a
10
10
  * certainty is 0. An item with no prediction is 0.5: we do not know how unsure we are.
11
11
  * - importance is the item's own priority (a relevance score, or a story or section priority). It is 1 until a source gives one.
12
- * - freshness is exp(-age / tau), the weight the References feed already uses; tau is learned from how old the References you
13
- * accepted were when you accepted them (reference-feed-ranking.ts). With too few examples there is no decay.
14
12
  * 3. Types are interleaved, never more than two of one type in a row while another type is waiting, so a long run of References
15
13
  * does not bury the drafts and emails behind it.
16
14
  * 4. Confident items do not ask for you. An item whose uncertainty is below the threshold goes to "Decided automatically", where it
@@ -25,7 +23,7 @@ import type { ReferenceRecord } from "./category-repository";
25
23
  import { referenceLineageId } from "./newsroom-references";
26
24
  import { normalizeReferenceCurationStatus } from "./reference-policy";
27
25
  import type { RelevanceDecisionView } from "./relevance-decisions";
28
- import { acceptProbability, MINIMUM_FRESHNESS_EXAMPLES, type FeedFreshness } from "./reference-feed-ranking";
26
+ import { acceptProbability } from "./reference-feed-ranking";
29
27
 
30
28
  export type NeedsMeType = "reference" | "draft" | "stage_review" | "held_email" | "incomplete_capture" | "concept_proposal" | "flag";
31
29
 
@@ -35,7 +33,7 @@ export const NEEDS_ME_TYPES: readonly NeedsMeType[] = ["reference", "draft", "st
35
33
  export type NeedsMeFilter = "all" | "references" | "drafts" | "stories" | "email" | "captures" | "other";
36
34
  export const NEEDS_ME_FILTERS: readonly NeedsMeFilter[] = ["all", "references", "drafts", "stories", "email", "captures", "other"];
37
35
  export const NEEDS_ME_FILTER_LABELS: Record<NeedsMeFilter, string> = {
38
- all: "All", references: "References", drafts: "Drafts", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
36
+ all: "All", references: "References", drafts: "Proposed Stories", stories: "Stories", email: "Email", captures: "Captures", other: "Other",
39
37
  };
40
38
 
41
39
  const FILTER_OF: Record<NeedsMeType, Exclude<NeedsMeFilter, "all">> = {
@@ -52,7 +50,7 @@ export function matchesFilter(item: Pick<NeedsMeItem, "type">, filter: NeedsMeFi
52
50
 
53
51
  /** The words for a type, used on the card and in the detail pane. */
54
52
  export const NEEDS_ME_TYPE_LABELS: Record<NeedsMeType, string> = {
55
- reference: "Reference", draft: "AI draft", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
53
+ reference: "Reference", draft: "Proposed Story", stage_review: "Story review", held_email: "Held email", incomplete_capture: "Capture check", concept_proposal: "Concept proposal", flag: "For your information",
56
54
  };
57
55
 
58
56
  export type NeedsMePrediction = {
@@ -109,33 +107,23 @@ export function predictionOf(decision: RelevanceDecisionView | undefined): Needs
109
107
  return { label: decision.label === (decision.positiveLabel ?? "include") ? "accept" : "reject", pAccept, confidence: decision.confidence };
110
108
  }
111
109
 
112
- const DAY_MILLISECONDS = 86_400_000;
113
-
114
- /** exp(-age / tau) at `now`; 1 without a learned tau or an unknown arrival. The References feed's own weight. */
115
- export function freshnessAt(createdAt: number | null, freshness: FeedFreshness, now: number): number {
116
- if (freshness.tauDays === null || createdAt === null) return 1;
117
- return Math.exp(-Math.max(now - createdAt, 0) / DAY_MILLISECONDS / freshness.tauDays);
118
- }
119
-
120
- export type AttentionContext = { now: number; freshness: FeedFreshness };
121
-
122
110
  /**
123
111
  * The value of this editor's input on one item. Higher is earlier. The interim model below is a formula; a Cyclotron "attention"
124
112
  * decision implements the same interface and is passed to `rankNeedsMe` in its place.
125
113
  */
126
114
  export interface AttentionModel {
127
115
  readonly id: string;
128
- value(item: NeedsMeItem, context: AttentionContext): number;
116
+ value(item: NeedsMeItem): number;
129
117
  }
130
118
 
131
119
  export const interimAttention: AttentionModel = {
132
120
  id: "interim-value-of-input",
133
- value: (item, { now, freshness }) => item.uncertainty * item.importance * freshnessAt(item.createdAt, freshness, now),
121
+ value: (item) => item.uncertainty * item.importance,
134
122
  };
135
123
 
136
124
  // --- confident items ----------------------------------------------------------------------------------------------------
137
125
 
138
- export const MINIMUM_CONFIRMED_EXAMPLES = MINIMUM_FRESHNESS_EXAMPLES;
126
+ export const MINIMUM_CONFIRMED_EXAMPLES = 10;
139
127
 
140
128
  export type ConfidenceThreshold = {
141
129
  /** Null with fewer than MINIMUM_CONFIRMED_EXAMPLES confirmed predictions: nothing is guessed, nothing is skipped. */
@@ -217,11 +205,10 @@ function newestFirst(left: NeedsMeItem, right: NeedsMeItem): number {
217
205
 
218
206
  export function rankNeedsMe<P>(
219
207
  items: readonly NeedsMeItem<P>[],
220
- options: { now: number; freshness: FeedFreshness; threshold: ConfidenceThreshold; attention?: AttentionModel },
208
+ options: { threshold: ConfidenceThreshold; attention?: AttentionModel },
221
209
  ): NeedsMeRanking<P> {
222
210
  const attention = options.attention ?? interimAttention;
223
- const context: AttentionContext = { now: options.now, freshness: options.freshness };
224
- const scores = new Map(items.map((item) => [item.id, attention.value(item, context)]));
211
+ const scores = new Map(items.map((item) => [item.id, attention.value(item)]));
225
212
  const score = (item: NeedsMeItem) => scores.get(item.id) ?? 0;
226
213
  const gates = items.filter((item) => item.hardGate).sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0) || left.id.localeCompare(right.id));
227
214
  const rest = items.filter((item) => !item.hardGate);
@@ -1,4 +1,4 @@
1
- import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
1
+ import type { KanbanConfig, KanbanIssue, KanbanSortFieldRule } from "@kanbus/ui";
2
2
  import type { AssignmentRecord } from "./category-repository";
3
3
  import {
4
4
  ASSIGNMENT_PHASE_STAGE_KEYS,
@@ -118,11 +118,19 @@ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[])
118
118
  return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
119
119
  }
120
120
 
121
+ /**
122
+ * Kanbus's default column order is oldest-created first, and our content items carry no `created_at`, so without this every
123
+ * column listed its cards by id. Newest first by `updated_at`: it is the order the stage pages load in, and the only time a
124
+ * paged content item has (each save is a new version, so `createdAt` is the last edit, not the story's start).
125
+ */
126
+ export const NEWEST_FIRST: KanbanSortFieldRule[] = [{ field: "updated_at", direction: "desc" }];
127
+
121
128
  function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
122
129
  const statuses = columns.map(column => ({ ...column, category: "native" }));
123
130
  return {
124
131
  hierarchy: [], types: [type], statuses,
125
132
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
133
+ sort_order: Object.fromEntries(statuses.map(status => [status.key, [...NEWEST_FIRST]])),
126
134
  workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
127
135
  };
128
136
  }
@@ -165,6 +173,7 @@ export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConf
165
173
  statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
166
174
  categories: storiesConfig.categories,
167
175
  workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
176
+ sort_order: { ...assignmentsConfig.sort_order, ...storiesConfig.sort_order },
168
177
  };
169
178
  }
170
179
 
@@ -161,13 +161,6 @@ export function applyPilobolusDemoDashboardContent(
161
161
  }));
162
162
 
163
163
  localized.messages = localized.messages.map((message) => {
164
- if (message.messageKind === "ingestion_rationale") {
165
- return {
166
- ...message,
167
- body: "Imported as a useful holdout against generic platform demo intake.",
168
- summary: "Pilobolus intake rationale",
169
- };
170
- }
171
164
  if (message.messageKind === "reporting_context_packet") {
172
165
  const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
173
166
  ? message.metadata as Record<string, unknown>
@@ -8,10 +8,10 @@ export type OrganizerDestination = {
8
8
  };
9
9
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
10
10
  { title: "Editorial work", items: [
11
- { key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
11
+ { key: "overview", label: "Needs me", description: "See what needs you: Proposed Stories, references and reviews.", path: "/newsroom", primary: true },
12
12
  { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
13
13
  { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
- { key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
14
+ { key: "drafts", label: "Proposed Stories", description: "Read the Proposed Stories that AI suggests, then accept or reject them.", path: "/newsroom/administration/proposals" },
15
15
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
16
16
  ] },
17
17
  { title: "Research & knowledge", items: [
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Messages are created through the `postMessage` mutation, never through `createMessage`: the backend stamps the author from the
3
+ * signed-in identity, so a browser cannot name one. This is the one place that turns a Message record into that mutation and its
4
+ * arguments, shared by the conversation, forum and console chat writers. Authorship and timestamps are left out on purpose.
5
+ */
6
+
7
+ const ARGUMENT_TYPES = {
8
+ id: "ID!",
9
+ threadId: "ID!",
10
+ content: "String",
11
+ messageKind: "String!",
12
+ messageDomain: "String!",
13
+ summary: "String",
14
+ parentMessageId: "ID",
15
+ sequenceNumber: "Int",
16
+ messageType: "String",
17
+ role: "String",
18
+ source: "String",
19
+ authorLabel: "String",
20
+ semanticLayer: "String",
21
+ searchVisibility: "String",
22
+ responseTarget: "String",
23
+ responseStatus: "String",
24
+ responseOwner: "String",
25
+ responseStartedAt: "AWSDateTime",
26
+ responseCompletedAt: "AWSDateTime",
27
+ metadata: "AWSJSON",
28
+ newsroomFeedKey: "String",
29
+ } as const;
30
+
31
+ export type PostMessageArguments = Partial<Record<keyof typeof ARGUMENT_TYPES, string | number | null>>;
32
+
33
+ type MessageLike = Record<string, unknown>;
34
+
35
+ /** The arguments of `postMessage` from a Message record. Anything else on the record (authorSub, authorUserProfileId, createdAt, updatedAt) is dropped. */
36
+ export function postMessageArguments(record: MessageLike): PostMessageArguments {
37
+ const args: PostMessageArguments = {};
38
+ for (const key of Object.keys(ARGUMENT_TYPES) as Array<keyof typeof ARGUMENT_TYPES>) {
39
+ let value = record[key === "content" ? "content" : key];
40
+ if (key === "content") value = record.content ?? record.body;
41
+ if (value === undefined || value === null) continue;
42
+ if (key === "metadata") value = typeof value === "string" ? value : JSON.stringify(value);
43
+ args[key] = value as string | number;
44
+ }
45
+ return args;
46
+ }
47
+
48
+ /** `mutation PostMessage($id: ID!, …) { postMessage(id: $id, …) { <selection> } }` declaring exactly the arguments given. */
49
+ export function postMessageMutation(args: PostMessageArguments, selection: string): string {
50
+ const keys = Object.keys(args) as Array<keyof typeof ARGUMENT_TYPES>;
51
+ const declared = keys.map((key) => `$${key}: ${ARGUMENT_TYPES[key]}`).join(", ");
52
+ const passed = keys.map((key) => `${key}: $${key}`).join(", ");
53
+ return `mutation PostMessage(${declared}) { postMessage(${passed}) { ${selection} } }`;
54
+ }
@@ -22,7 +22,7 @@ export type ProposalFilter = "open" | "draft" | "in_steering" | "editor_review"
22
22
  export type ProposalMove = { label: string; path?: string[]; edit?: "pin" | "unpin" | "hold" | "release"; state?: string };
23
23
 
24
24
  const STATE_LABELS: Record<string, string> = {
25
- draft: "Draft",
25
+ draft: "Proposed",
26
26
  in_steering: "In steering",
27
27
  editor_review: "Editor review",
28
28
  approved: "Approved",
@@ -31,7 +31,7 @@ const STATE_LABELS: Record<string, string> = {
31
31
  };
32
32
 
33
33
  const KIND_LABELS: Record<string, string> = {
34
- story_draft: "Story draft",
34
+ story_draft: "Proposed Story",
35
35
  new_concept: "New concept",
36
36
  rename: "Rename",
37
37
  merge: "Merge",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The keys the References page reads through (docs/reference-feed-indexes.md). The Python twin is
3
+ * src/papyrus_content/reference_feed_keys.py; scripts/fixtures/reference-feed-key-vectors.json holds the vectors both test.
4
+ *
5
+ * `feedStateKey` is "<corpusId>#unrated" (pending) or "<corpusId>#rated" (accepted or rejected); a Reference that is not the current
6
+ * version, or is archived, has none and is in neither feed. `predictionSortKey` is the Cyclotron's ORIGINAL P(accept), then arrival, then
7
+ * id; a Reference nobody predicted sorts as 0.5. A vote changes `feedStateKey` and nothing else here.
8
+ */
9
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
10
+
11
+ export const UNDECIDED_PROBABILITY = 0.5;
12
+ const EPOCH = "1970-01-01T00:00:00.000Z";
13
+ const ARRIVAL_FIELDS = ["createdAt", "importedAt", "retrievedAt", "sourcePublishedAt", "versionCreatedAt"] as const;
14
+
15
+ export type ReferenceFeedKeyInput = {
16
+ id: string;
17
+ corpusId?: string | null;
18
+ curationStatus?: string | null;
19
+ versionState?: string | null;
20
+ predictedAcceptProbability?: number | null;
21
+ createdAt?: string | null;
22
+ importedAt?: string | null;
23
+ retrievedAt?: string | null;
24
+ sourcePublishedAt?: string | null;
25
+ versionCreatedAt?: string | null;
26
+ };
27
+
28
+ export type ReferenceFeedKeys = {
29
+ feedStateKey: string | null;
30
+ feedArrivalAt: string;
31
+ predictionSortKey: string;
32
+ };
33
+
34
+ function instant(value: string | null | undefined): string | null {
35
+ if (!value) return null;
36
+ const parsed = Date.parse(value);
37
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
38
+ }
39
+
40
+ export function referenceFeedArrivalAt(reference: ReferenceFeedKeyInput): string {
41
+ for (const field of ARRIVAL_FIELDS) {
42
+ const found = instant(reference[field]);
43
+ if (found) return found;
44
+ }
45
+ return EPOCH;
46
+ }
47
+
48
+ export function referenceFeedStateKey(reference: ReferenceFeedKeyInput): string | null {
49
+ if (reference.versionState && reference.versionState !== "current") return null;
50
+ if (!reference.corpusId) return null;
51
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
52
+ if (status === "archived") return null;
53
+ return `${reference.corpusId}#${status === "pending" ? "unrated" : "rated"}`;
54
+ }
55
+
56
+ export function predictionSortKeyFor(probability: number | null | undefined, arrival: string, referenceId: string): string {
57
+ const chosen = probability === null || probability === undefined ? UNDECIDED_PROBABILITY : Math.min(1, Math.max(0, probability));
58
+ return `${chosen.toFixed(6)}#${arrival}#${referenceId}`;
59
+ }
60
+
61
+ /** The three feed keys for a Reference as it stands. `probability` overrides the one stored on it. */
62
+ export function referenceFeedKeys(reference: ReferenceFeedKeyInput, probability?: number | null): ReferenceFeedKeys {
63
+ const arrival = referenceFeedArrivalAt(reference);
64
+ return {
65
+ feedStateKey: referenceFeedStateKey(reference),
66
+ feedArrivalAt: arrival,
67
+ predictionSortKey: predictionSortKeyFor(probability ?? reference.predictedAcceptProbability, arrival, reference.id),
68
+ };
69
+ }