@anthusai/papyrus 5.56.1 → 5.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/amplify/data/resource.js +25 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +25 -3
  4. package/amplify/data/schema.ts +25 -3
  5. package/amplify/functions/category-action/handler.ts +51 -125
  6. package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
  7. package/components/conversation-context.tsx +52 -0
  8. package/components/conversation-provider.tsx +20 -0
  9. package/components/conversation-thread.tsx +188 -0
  10. package/components/conversations-memory-source.ts +46 -0
  11. package/components/conversations-network-source.ts +40 -0
  12. package/components/conversations-page.tsx +14 -0
  13. package/components/conversations-source.ts +24 -0
  14. package/components/conversations-view.tsx +319 -0
  15. package/components/decision-control.tsx +307 -0
  16. package/components/needs-me-bodies.tsx +4 -3
  17. package/components/needs-me-feed.tsx +8 -14
  18. package/components/needs-me-reference-detail.tsx +2 -0
  19. package/components/news-desk-taxonomy-client.ts +322 -37
  20. package/components/newsroom-organizer.css +24 -0
  21. package/components/newsroom-references-view.tsx +9 -0
  22. package/components/notification-cards.tsx +5 -4
  23. package/components/reference-conversation.tsx +43 -0
  24. package/components/reference-feed.tsx +69 -37
  25. package/components/reference-review-card.tsx +8 -134
  26. package/components/story-reporting-trail-client.ts +4 -3
  27. package/components/story-reporting-trail.tsx +8 -0
  28. package/components/topic-steering-workspace.tsx +43 -784
  29. package/components/use-conversation.ts +84 -0
  30. package/components/use-reference-ratings.ts +11 -7
  31. package/lib/category-repository.ts +7 -0
  32. package/lib/conversation-feed.ts +96 -0
  33. package/lib/conversation.ts +154 -0
  34. package/lib/cyclotron-suggestions.ts +18 -36
  35. package/lib/local-preview-seed.ts +16 -27
  36. package/lib/reference-feed-keys.ts +69 -0
  37. package/lib/reference-policy.ts +83 -11
  38. package/lib/reference-review-details.ts +21 -57
  39. package/lib/relevance-decisions.ts +6 -2
  40. package/lib/story-reporting-trail.ts +28 -2
  41. package/package.json +15 -2
  42. package/routes.manifest.json +1 -1
  43. package/components/review-rating-grid.tsx +0 -190
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
5
+ import { useConversationStore } from "./conversation-context";
6
+
7
+ export type ConversationState = "loading" | "ready" | "error";
8
+
9
+ /**
10
+ * One conversation's Messages. Posting shows the Message at once (marked pending) and swaps in the saved one; a failed save removes
11
+ * it again and rejects, so the caller can give the typed text back.
12
+ */
13
+ export function useConversation(anchor: ConversationAnchor) {
14
+ const store = useConversationStore();
15
+ const [messages, setMessages] = useState<ConversationMessage[]>([]);
16
+ const [state, setState] = useState<ConversationState>("loading");
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [attempt, setAttempt] = useState(0);
19
+ const pendingCount = useRef(0);
20
+ const anchorRef = useRef(anchor);
21
+ anchorRef.current = anchor;
22
+ const anchorKey = `${anchor.kind}#${anchor.lineageId ?? ""}`;
23
+
24
+ useEffect(() => {
25
+ if (!store) return;
26
+ let active = true;
27
+ setState("loading");
28
+ setError(null);
29
+ setMessages([]);
30
+ store.load(anchorRef.current).then((loaded) => {
31
+ if (!active) return;
32
+ setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
33
+ setState("ready");
34
+ }).catch((failure) => {
35
+ if (!active) return;
36
+ setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
37
+ setState("error");
38
+ });
39
+ return () => { active = false; };
40
+ }, [store, anchorKey, attempt]);
41
+
42
+ const retry = useCallback(() => setAttempt((current) => current + 1), []);
43
+
44
+ const post = useCallback(async (body: string, parentMessageId: string | null) => {
45
+ if (!store) throw new Error("Messages are not available here.");
46
+ pendingCount.current += 1;
47
+ const pendingId = `pending-${pendingCount.current}`;
48
+ const pending: ConversationMessage = {
49
+ id: pendingId,
50
+ parentMessageId,
51
+ authorLabel: store.authorLabel,
52
+ content: body.trim(),
53
+ createdAt: new Date().toISOString(),
54
+ sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + pendingCount.current,
55
+ pending: true,
56
+ };
57
+ setMessages((current) => [...current, pending]);
58
+ try {
59
+ const saved = await store.append({ anchor: anchorRef.current, body, parentMessageId });
60
+ setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
61
+ } catch (failure) {
62
+ setMessages((current) => current.filter((message) => message.id !== pendingId));
63
+ throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
64
+ }
65
+ }, [store]);
66
+
67
+ return { available: Boolean(store), messages, state, error, retry, post };
68
+ }
69
+
70
+ /** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
71
+ export function useReferenceMessageCounts(enabled: boolean) {
72
+ const store = useConversationStore();
73
+ const [counts, setCounts] = useState<ReadonlyMap<string, number>>(() => new Map());
74
+ useEffect(() => {
75
+ if (!store || !enabled) return;
76
+ let active = true;
77
+ store.counts().then((loaded) => { if (active) setCounts((current) => new Map([...loaded, ...current])); }).catch(() => undefined);
78
+ return () => { active = false; };
79
+ }, [store, enabled]);
80
+ const setCount = useCallback((lineageId: string, count: number) => {
81
+ setCounts((current) => (current.get(lineageId) === count || (count === 0 && !current.has(lineageId)) ? current : new Map(current).set(lineageId, count)));
82
+ }, []);
83
+ return { counts, setCount };
84
+ }
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
5
5
  import type { ReviewChoice } from "./reference-review-card";
6
- import type { RatingFeedback } from "./review-rating-grid";
6
+ import type { RatingFeedback } from "./decision-control";
7
7
 
8
8
  /** How long after the last keystroke a typed reason is saved on its own. */
9
9
  export const NOTE_AUTOSAVE_MS = 1200;
@@ -38,8 +38,8 @@ export type RatingController = {
38
38
  * shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
39
39
  * vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
40
40
  * reason, then vote -> the vote carries the reason
41
- * A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
42
- * kept in the store until it is saved and is never dropped by a failed save.
41
+ * A Reject needs a reason (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
42
+ * waits as `pending` until one arrives; a typed reason is kept in the store until it is saved and is never dropped by a failed save.
43
43
  */
44
44
  export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
45
45
  save: RatingSave;
@@ -110,13 +110,17 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
110
110
 
111
111
  const commit = useCallback(async (id: string, choice: ReviewChoice) => {
112
112
  if (finalDecision(id)) { void saveNoteOnly(id); return; }
113
- const { note, reasonCode: chosenCode } = values(id);
114
- // A typed reason is a reason: a Reject with words but no reason button is filed under "other" rather than left waiting.
115
- const reasonCode = chosenCode ?? (choice === "reject" && note.trim() ? "other" : null);
116
- if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
113
+ const { note, reasonCode } = values(id);
114
+ // A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
115
+ // code the editor did not pick: the backend reads the words for the code they name, and files words
116
+ // that name none under "other". Only a Reject with neither words nor a code waits for one.
117
+ if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
117
118
  if (inFlight.current.has(id)) { dirty.current.add(id); return; }
118
119
  inFlight.current.add(id);
119
120
  dirty.current.delete(id);
121
+ // The note goes out with this save, so its own pending autosave has nothing left to send.
122
+ dirty.current.delete(`note:${id}`);
123
+ clearTimeout(timers.current[id]);
120
124
  const started = itemsRef.current[id] ?? EMPTY;
121
125
  patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
122
126
  try {
@@ -280,6 +280,13 @@ export type ReferenceRecord = {
280
280
  curationStatusUpdatedAt?: string | null;
281
281
  curationStatusUpdatedBy?: string | null;
282
282
  curationStatusReason?: string | null;
283
+ /** The latest vote's details; a Reference keeps only its latest vote (earlier votes live in the Cyclotron's feedback). */
284
+ curationReasonCode?: string | null;
285
+ curationActorKind?: string | null;
286
+ curationSectionId?: string | null;
287
+ curationShareable?: boolean | null;
288
+ curationDelegation?: unknown;
289
+ curationDecisionRelationId?: string | null;
283
290
  newsroomFeedKey?: string | null;
284
291
  reviewedFeedKey?: string | null;
285
292
  metadata?: unknown;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Messages page: which MessageThreads are conversations, which section each belongs to, and how several newest-first streams
3
+ * (one per thread kind) merge into one page of conversations ordered by last activity. Pure helpers, shared by the browser and the tests.
4
+ */
5
+ import { GENERAL_CONVERSATION_KIND, REFERENCE_CONVERSATION_KIND, STORY_CONVERSATION_KIND } from "./conversation";
6
+
7
+ export type ConversationSection = "all" | "references" | "stories" | "chats" | "other";
8
+ export const CONVERSATION_SECTIONS: ReadonlyArray<{ key: ConversationSection; label: string }> = [
9
+ { key: "all", label: "All" },
10
+ { key: "references", label: "References" },
11
+ { key: "stories", label: "Stories" },
12
+ { key: "chats", label: "Chats" },
13
+ { key: "other", label: "Other" },
14
+ ];
15
+
16
+ export const CHAT_THREAD_KIND = "console";
17
+ export const FORUM_THREAD_KINDS = ["edition_forum", "section_forum"] as const;
18
+
19
+ /** The thread kinds that are conversations, by section. Machine records (provenance, curation, ingestion rationale) are in none of them. */
20
+ const KINDS_BY_SECTION: Record<Exclude<ConversationSection, "all">, ReadonlyArray<string>> = {
21
+ references: [REFERENCE_CONVERSATION_KIND],
22
+ stories: [STORY_CONVERSATION_KIND],
23
+ chats: [CHAT_THREAD_KIND],
24
+ other: [GENERAL_CONVERSATION_KIND, ...FORUM_THREAD_KINDS],
25
+ };
26
+
27
+ export function threadKindsForSection(section: ConversationSection): string[] {
28
+ if (section === "all") return Object.values(KINDS_BY_SECTION).flat();
29
+ return [...KINDS_BY_SECTION[section]];
30
+ }
31
+
32
+ /** The section a thread kind is listed under, or null for a kind that is not a conversation. */
33
+ export function sectionOfThreadKind(kind: string): Exclude<ConversationSection, "all"> | null {
34
+ for (const [section, kinds] of Object.entries(KINDS_BY_SECTION)) if (kinds.includes(kind)) return section as Exclude<ConversationSection, "all">;
35
+ return null;
36
+ }
37
+
38
+ export const SECTION_EMPTY_COPY: Record<ConversationSection, string> = {
39
+ all: "No conversations yet. Write a message above, or on a Reference, to start one.",
40
+ references: "No Reference has a message yet. Open a Reference and write down what it made you think.",
41
+ stories: "No Story has a message yet.",
42
+ chats: "No chats yet.",
43
+ other: "No other conversations yet. Write a message above to start one.",
44
+ };
45
+
46
+ export type PagedThreads<Thread> = { items: Thread[]; nextToken: string | null };
47
+
48
+ type Streamable = { id: string; updatedAt: string };
49
+
50
+ /**
51
+ * One page at a time out of several newest-first streams, merged by `updatedAt` (the order of every thread index). A stream is only
52
+ * asked for another page when its buffered threads have all been shown, so the order across streams is exact, never approximate.
53
+ */
54
+ export function createMergedThreadStream<Thread extends Streamable>(
55
+ kinds: ReadonlyArray<string>,
56
+ loadPage: (kind: string, nextToken: string | null) => Promise<PagedThreads<Thread>>,
57
+ ) {
58
+ const lanes = kinds.map((kind) => ({ kind, buffer: [] as Thread[], nextToken: null as string | null, started: false, done: false }));
59
+ const seen = new Set<string>();
60
+
61
+ async function fill() {
62
+ await Promise.all(lanes.filter((lane) => !lane.done && lane.buffer.length === 0).map(async (lane) => {
63
+ const page = await loadPage(lane.kind, lane.started ? lane.nextToken : null);
64
+ lane.started = true;
65
+ lane.nextToken = page.nextToken;
66
+ lane.done = !page.nextToken;
67
+ lane.buffer = [...page.items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
68
+ }));
69
+ }
70
+
71
+ return {
72
+ /** The next `count` conversations, newest first, and whether more remain. */
73
+ async next(count: number): Promise<{ items: Thread[]; hasMore: boolean }> {
74
+ const items: Thread[] = [];
75
+ while (items.length < count) {
76
+ await fill();
77
+ const live = lanes.filter((lane) => lane.buffer.length > 0);
78
+ if (!live.length) break;
79
+ // A lane that is not finished and has an empty buffer after a fill returned an empty page with more behind it: ask again.
80
+ if (lanes.some((lane) => !lane.done && lane.buffer.length === 0)) continue;
81
+ const best = live.reduce((winner, lane) => (lane.buffer[0].updatedAt.localeCompare(winner.buffer[0].updatedAt) > 0 ? lane : winner));
82
+ const thread = best.buffer.shift()!;
83
+ if (seen.has(thread.id)) continue;
84
+ seen.add(thread.id);
85
+ items.push(thread);
86
+ }
87
+ return { items, hasMore: lanes.some((lane) => lane.buffer.length > 0 || !lane.done) };
88
+ },
89
+ };
90
+ }
91
+
92
+ /** The newest `limit` messages, oldest first, and how many are held back behind "Show all". */
93
+ export function latestMessages<Message>(messages: ReadonlyArray<Message>, limit: number, showAll: boolean): { shown: Message[]; hidden: number } {
94
+ if (showAll || messages.length <= limit) return { shown: [...messages], hidden: 0 };
95
+ return { shown: messages.slice(messages.length - limit), hidden: messages.length - limit };
96
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Conversations: Messages written about a Reference (later a Story, or nothing at all). One conversation per thing, held in one
3
+ * MessageThread found by its anchor key. Pure helpers only, so the browser and the tests share them.
4
+ */
5
+
6
+ export const CONVERSATION_FEED_KEY = "conversations";
7
+ export const REFERENCE_CONVERSATION_KIND = "reference_conversation";
8
+ export const STORY_CONVERSATION_KIND = "story_conversation";
9
+ export const GENERAL_CONVERSATION_KIND = "conversation";
10
+
11
+ /** What a conversation is about. `none` is a conversation that is about nothing but itself. */
12
+ export type ConversationAnchor =
13
+ | { kind: "reference" | "item"; lineageId: string; /** The version id when it differs from the lineage id. */ id?: string; title?: string }
14
+ | { kind: "none"; lineageId?: undefined; title?: string };
15
+
16
+ /** A Message as the conversation shows it. */
17
+ export type ConversationMessage = {
18
+ id: string;
19
+ parentMessageId: string | null;
20
+ authorLabel: string;
21
+ content: string;
22
+ createdAt: string;
23
+ sequenceNumber: number;
24
+ /** True while an optimistic Message waits for the save. */
25
+ pending?: boolean;
26
+ };
27
+
28
+ export type ConversationReply = { message: ConversationMessage; answers: string | null };
29
+ export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
30
+
31
+ function slug(value: string): string {
32
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "thing";
33
+ }
34
+
35
+ /** `reference#<lineageId>`, `item#<lineageId>`, or null when the conversation is anchored to nothing. */
36
+ export function conversationAnchorKey(anchor: ConversationAnchor): string | null {
37
+ return anchor.kind === "none" || !anchor.lineageId?.trim() ? null : `${anchor.kind}#${anchor.lineageId.trim()}`;
38
+ }
39
+
40
+ export function conversationThreadKind(anchor: ConversationAnchor): string {
41
+ return anchor.kind === "reference" ? REFERENCE_CONVERSATION_KIND : anchor.kind === "item" ? STORY_CONVERSATION_KIND : GENERAL_CONVERSATION_KIND;
42
+ }
43
+
44
+ /** The thread id follows the anchor, so two editors starting the same conversation at once collide on create instead of splitting it. */
45
+ export function conversationThreadId(anchor: ConversationAnchor): string {
46
+ const key = conversationAnchorKey(anchor);
47
+ if (!key) throw new Error("A conversation without an anchor needs its own thread id.");
48
+ return `message-thread-conversation-${slug(key)}`;
49
+ }
50
+
51
+ export function conversationMessageId(threadId: string, sequenceNumber: number): string {
52
+ return `message-conversation-${slug(threadId)}-${String(sequenceNumber).padStart(4, "0")}`;
53
+ }
54
+
55
+ /** One more than the highest sequence number in use. */
56
+ export function nextConversationSequence(existing: ReadonlyArray<{ sequenceNumber?: number | null }>): number {
57
+ return Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
58
+ }
59
+
60
+ /** A create that lost a race for its sequence number (the id derives from the number, so the second writer collides). */
61
+ export function isSequenceConflict(error: unknown): boolean {
62
+ const text = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error ?? "");
63
+ return /conditional|already exists|duplicate|ConditionalCheckFailed/i.test(text);
64
+ }
65
+
66
+ /** "ryan@example.test" is shown as "ryan"; a name stays as it is. */
67
+ export function conversationAuthorName(label: string | null | undefined): string {
68
+ const text = String(label ?? "").trim();
69
+ if (!text) return "Someone";
70
+ const at = text.indexOf("@");
71
+ return at > 0 ? text.slice(0, at) : text;
72
+ }
73
+
74
+ /**
75
+ * Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
76
+ * A reply whose parent is missing is shown as a top-level Message rather than lost.
77
+ */
78
+ export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>): ConversationRoot[] {
79
+ const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
80
+ const byId = new Map(sorted.map((message) => [message.id, message]));
81
+ const rootOf = (message: ConversationMessage): string => {
82
+ let current = message;
83
+ const seen = new Set<string>();
84
+ while (current.parentMessageId && byId.has(current.parentMessageId) && !seen.has(current.id)) {
85
+ seen.add(current.id);
86
+ current = byId.get(current.parentMessageId)!;
87
+ }
88
+ return current.id;
89
+ };
90
+ const roots = new Map<string, ConversationRoot>();
91
+ for (const message of sorted) {
92
+ if (rootOf(message) === message.id) roots.set(message.id, { message, replies: [] });
93
+ }
94
+ for (const message of sorted) {
95
+ const root = roots.get(rootOf(message));
96
+ if (!root || root.message.id === message.id) continue;
97
+ const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
98
+ root.replies.push({ message, answers: parent && parent.id !== root.message.id ? conversationAuthorName(parent.authorLabel) : null });
99
+ }
100
+ return [...roots.values()];
101
+ }
102
+
103
+ /** Short relative time: "now", "5m", "3h", "2d". */
104
+ export function conversationAge(createdAt: string, now: number): string {
105
+ const time = Date.parse(createdAt);
106
+ if (!Number.isFinite(time)) return "";
107
+ const minutes = Math.max(0, Math.round((now - time) / 60000));
108
+ if (minutes < 1) return "now";
109
+ if (minutes < 60) return `${minutes}m`;
110
+ const hours = Math.round(minutes / 60);
111
+ if (hours < 24) return `${hours}h`;
112
+ return `${Math.round(hours / 24)}d`;
113
+ }
114
+
115
+ export function messageCountLabel(count: number): string {
116
+ return `${count} ${count === 1 ? "message" : "messages"}`;
117
+ }
118
+
119
+ /**
120
+ * Creates a Message with the next sequence number. The id derives from the number, so when another writer took it the create is
121
+ * refused and this tries the next number, up to `attempts` times. `list` returns the Messages already in the thread.
122
+ */
123
+ export async function createWithSequenceRetry<Message>(input: {
124
+ list: () => Promise<ReadonlyArray<{ sequenceNumber?: number | null }>>;
125
+ create: (sequenceNumber: number) => Promise<Message>;
126
+ attempts?: number;
127
+ }): Promise<{ created: Message; sequenceNumber: number }> {
128
+ const attempts = input.attempts ?? 5;
129
+ let sequenceNumber = nextConversationSequence(await input.list());
130
+ for (let attempt = 1; ; attempt += 1) {
131
+ try {
132
+ return { created: await input.create(sequenceNumber), sequenceNumber };
133
+ } catch (error) {
134
+ if (!isSequenceConflict(error) || attempt >= attempts) throw error;
135
+ sequenceNumber = nextConversationSequence(await input.list());
136
+ }
137
+ }
138
+ }
139
+
140
+ /** Finds the conversation's thread, else creates it; a create that loses a race for the (anchor-derived) id uses the winner's thread. */
141
+ export async function findOrCreateThread<Thread>(input: {
142
+ find: () => Promise<Thread | null>;
143
+ create: () => Promise<Thread>;
144
+ }): Promise<Thread> {
145
+ const existing = await input.find();
146
+ if (existing) return existing;
147
+ try {
148
+ return await input.create();
149
+ } catch (error) {
150
+ const raced = isSequenceConflict(error) ? await input.find() : null;
151
+ if (raced) return raced;
152
+ throw error;
153
+ }
154
+ }
@@ -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,
@@ -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
+ }