@anthusai/papyrus 5.57.0 → 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 (36) 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-thread.tsx +27 -12
  8. package/components/conversations-memory-source.ts +46 -0
  9. package/components/conversations-network-source.ts +40 -0
  10. package/components/conversations-page.tsx +14 -0
  11. package/components/conversations-source.ts +24 -0
  12. package/components/conversations-view.tsx +319 -0
  13. package/components/decision-control.tsx +307 -0
  14. package/components/needs-me-bodies.tsx +4 -3
  15. package/components/needs-me-feed.tsx +8 -14
  16. package/components/news-desk-taxonomy-client.ts +83 -14
  17. package/components/newsroom-organizer.css +24 -0
  18. package/components/notification-cards.tsx +5 -4
  19. package/components/reference-feed.tsx +60 -42
  20. package/components/reference-review-card.tsx +8 -134
  21. package/components/story-reporting-trail-client.ts +4 -3
  22. package/components/story-reporting-trail.tsx +8 -0
  23. package/components/topic-steering-workspace.tsx +40 -782
  24. package/components/use-reference-ratings.ts +11 -7
  25. package/lib/category-repository.ts +7 -0
  26. package/lib/conversation-feed.ts +96 -0
  27. package/lib/cyclotron-suggestions.ts +18 -36
  28. package/lib/local-preview-seed.ts +16 -27
  29. package/lib/reference-feed-keys.ts +69 -0
  30. package/lib/reference-policy.ts +83 -11
  31. package/lib/reference-review-details.ts +21 -57
  32. package/lib/relevance-decisions.ts +6 -2
  33. package/lib/story-reporting-trail.ts +28 -2
  34. package/package.json +9 -2
  35. package/routes.manifest.json +1 -1
  36. package/components/review-rating-grid.tsx +0 -190
@@ -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
+ }
@@ -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
+ }
@@ -22,6 +22,84 @@ export function isScopeTrainingNegativeReason(code: string): boolean {
22
22
  return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
23
23
  }
24
24
 
25
+ /**
26
+ * The phrases an editor writes when they mean one reason code and little else.
27
+ *
28
+ * The order matters, and the two scope-training codes come LAST: a sentence
29
+ * that also names a mechanical reason ("already have it, and off our beat") is
30
+ * filed under that reason instead. A false scope negative teaches the relevance
31
+ * classifier something wrong; a missed one only leaves it untaught.
32
+ *
33
+ * Each phrase is a whole word or wording, never a fragment, so "slopes" is not
34
+ * read as "slop". Words that say none of this yield no code at all rather than
35
+ * the nearest guess.
36
+ */
37
+ const WRITTEN_REASON_PHRASES: ReadonlyArray<readonly [ReferenceRejectionReasonCode, readonly string[]]> = [
38
+ ["duplicate", [
39
+ "duplicate", "duplicates", "duplicated", "duplication", "dupe", "reprint", "reprinted",
40
+ "already have (it|this|that|the)", "already (got|covered|accepted|registered|ingested|indexed)",
41
+ "already in (our|the) (corpus|list|feed|knowledge base|library)", "same (source|piece|story) as",
42
+ ]],
43
+ ["unavailable", [
44
+ "paywall", "paywalled", "unavailable", "not available", "no longer (available|online|up|there|reachable)",
45
+ "behind a (paywall|login|subscription|sign in)", "(dead|broken|404) link", "link is (dead|broken)",
46
+ "(cannot|cant) (open|read|reach|access|download|retrieve)", "requires a (subscription|login|account|sign in)",
47
+ "taken down", "timed out", "page is gone",
48
+ ]],
49
+ ["provenance", [
50
+ "provenance", "unattributed", "unverified", "unverifiable", "untraceable",
51
+ "(cannot|cant) (verify|attribute)", "no (byline|author|citation|attribution|provenance)",
52
+ "(unknown|unclear|anonymous|missing) (author|source|origin|provenance)", "who wrote it",
53
+ ]],
54
+ ["low_quality", [
55
+ "(low|poor) quality", "poorly (written|sourced|argued|researched|edited)", "badly written", "sloppy",
56
+ "content farm", "clickbait", "seo (spam|bait|filler)", "slop", "junk", "spam", "no substance", "churnalism",
57
+ ]],
58
+ ["policy_exclusion", [
59
+ "policy exclusion", "(our|house|editorial) policy", "against (our )?policy",
60
+ "(policy|policies) (excludes?|forbids?|bars?|rules? out|says?)",
61
+ "(excluded|barred|ruled out|forbidden) by (our )?policy",
62
+ "we (dont|do not) (cover|run|publish|cite|take)", "not allowed", "disallowed", "prohibited",
63
+ "blocklist", "blocklisted", "blocked source",
64
+ ]],
65
+ ["out_of_scope", [
66
+ "out of scope", "outside (our|the) (scope|remit|coverage|beat|lane)", "not (in|within) (our|the) scope",
67
+ "off topic", "nothing to do with", "not (our|the) (beat|subject|topic|area|coverage)",
68
+ "not relevant", "no longer relevant", "irrelevant", "unrelated", "wrong (beat|subject|topic)",
69
+ ]],
70
+ ];
71
+
72
+ const WRITTEN_REASON_PATTERNS: ReadonlyArray<readonly [ReferenceRejectionReasonCode, RegExp]> =
73
+ WRITTEN_REASON_PHRASES.map(([code, phrases]) => [code, new RegExp(`\\b(?:${phrases.join("|")})\\b`)] as const);
74
+
75
+ /**
76
+ * The reason code a rejection is filed under when the editor gave none: what
77
+ * their own words plainly say, else "other".
78
+ *
79
+ * Words are a reason — a written sentence is what the editor actually gives
80
+ * now that there are no reason chips — but "other" is the honest answer for
81
+ * words that name no policy code, rather than a guess the sentence cannot
82
+ * support. Blank words are no reason at all, so this yields null and the
83
+ * caller refuses the rejection.
84
+ */
85
+ export function rejectionReasonCodeForWrittenReason(writtenReason: unknown): ReferenceRejectionReasonCode | null {
86
+ const words = normalizeWrittenReason(writtenReason);
87
+ if (!words) return null;
88
+ for (const [code, pattern] of WRITTEN_REASON_PATTERNS) {
89
+ if (pattern.test(words)) return code;
90
+ }
91
+ return "other";
92
+ }
93
+
94
+ /** Lower case, apostrophes dropped ("don't" -> "dont") and every other gap a single space, so phrases match plain prose. */
95
+ function normalizeWrittenReason(value: unknown): string {
96
+ return String(value ?? "")
97
+ .toLowerCase()
98
+ .replace(/['\u2018\u2019]/g, "")
99
+ .replace(/[^a-z0-9]+/g, " ")
100
+ .trim();
101
+ }
102
+
25
103
  export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
26
104
  const normalized = normalizePolicyToken(value ?? defaultStatus);
27
105
  if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";
@@ -61,23 +139,17 @@ export function isEvidenceEligibleReference(reference: Pick<ReferenceRecord, "ve
61
139
  && normalizeReferenceCurationStatus(reference?.curationStatus) === "accepted";
62
140
  }
63
141
 
64
- export function referenceReasonCode(reference: Pick<ReferenceRecord, "metadata"> | null | undefined, messages: Array<Pick<MessageRecord, "metadata" | "createdAt">> = []): ReferenceRejectionReasonCode | null {
65
- const metadataReason = reasonCodeFromMetadata(reference?.metadata);
66
- if (metadataReason) return metadataReason;
67
- const sortedMessages = [...messages].sort((left, right) => String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? "")));
68
- for (const message of sortedMessages) {
69
- const reason = reasonCodeFromMetadata(message.metadata);
70
- if (reason) return reason;
71
- }
72
- return null;
142
+ /** The reason code of the Reference's latest vote (`curationReasonCode`), else one recorded in its metadata. */
143
+ export function referenceReasonCode(reference: Pick<ReferenceRecord, "metadata" | "curationReasonCode"> | null | undefined): ReferenceRejectionReasonCode | null {
144
+ return normalizeReferenceRejectionReasonCode(reference?.curationReasonCode) ?? reasonCodeFromMetadata(reference?.metadata);
73
145
  }
74
146
 
75
- export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata"> | null | undefined, messages: Array<Pick<MessageRecord, "metadata" | "createdAt">> = []): "positive" | "negative" | null {
147
+ export function scopeTrainingLabelForReference(reference: Pick<ReferenceRecord, "versionState" | "curationStatus" | "metadata" | "curationReasonCode"> | null | undefined): "positive" | "negative" | null {
76
148
  if (!reference || reference.versionState !== "current") return null;
77
149
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
78
150
  if (status === "accepted") return "positive";
79
151
  if (status !== "rejected") return null;
80
- const reasonCode = referenceReasonCode(reference, messages);
152
+ const reasonCode = referenceReasonCode(reference);
81
153
  return reasonCode && SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(reasonCode) ? "negative" : null;
82
154
  }
83
155
 
@@ -1,71 +1,35 @@
1
- import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
1
+ import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
2
  import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
3
3
 
4
- function metadataRecord(value: unknown): Record<string, unknown> {
5
- if (typeof value === "string") {
6
- try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
7
- }
8
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
9
- }
10
-
11
- /** The selected Reference's index is authoritative even when the global feed is truncated. */
4
+ /**
5
+ * The selected Reference's indexed predictions (the Cyclotron's relevance decisions). The Reference's own latest vote
6
+ * (`curationActorKind`, `curationReasonCode`, ...) is already on the Reference, so no Message is read.
7
+ */
12
8
  export async function loadReferenceReviewDetails(lineageId: string, read: {
13
- relations: (direction: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
14
- message: (id: string) => Promise<MessageRecord | null>;
15
- payloads: (id: string) => Promise<HydratedModelPayload[]>;
16
- }): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
17
- const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
18
- const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
19
- const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
20
- && relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
21
- && metadataRecord(relation.metadata).messageKind === "reference_curation")
22
- .sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
23
- if (!latest) return { relations, messages: [] };
24
- const message = await read.message(latest.subjectId);
25
- if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
26
- const payload = (await read.payloads(message.id))
27
- .filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
28
- .sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
29
- if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
30
- if (payload.error) throw new Error(payload.error);
31
- if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
32
- throw new Error("The latest reference review metadata is invalid.");
33
- }
34
- const hydrated = { ...message, metadata: payload.json };
35
- const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
36
- if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
37
- return { relations, messages: [hydrated] };
38
- }
39
-
40
- function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
41
- const latest = new Map<string, MessageRecord>();
42
- for (const message of messages) {
43
- if (message.messageKind !== "reference_curation") continue;
44
- const lineage = metadataRecord(message.metadata).referenceLineageId;
45
- if (typeof lineage !== "string" || !lineage) continue;
46
- if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
47
- }
48
- return latest;
9
+ relations: (direction: "outgoing") => Promise<SemanticRelationRecord[]>;
10
+ }): Promise<{ relations: SemanticRelationRecord[] }> {
11
+ const outgoing = await read.relations("outgoing");
12
+ return { relations: outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId) };
49
13
  }
50
14
 
51
- /** A later acceptance or archive clears any earlier rejection reason. */
52
- export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
15
+ /** The reason code of each rejected Reference's latest vote. A later acceptance or archive replaced the vote, so it clears the reason. */
16
+ export function latestReferenceReviewReasons(references: readonly ReferenceRecord[]): Map<string, ReferenceRejectionReasonCode> {
53
17
  const reasons = new Map<string, ReferenceRejectionReasonCode>();
54
- for (const [lineage, message] of latestReferenceReviews(messages)) {
55
- const metadata = metadataRecord(message.metadata);
56
- const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
57
- if (reason) reasons.set(lineage, reason);
18
+ for (const reference of references) {
19
+ if (!reference.curationActorKind || reference.curationStatus !== "rejected") continue;
20
+ const reason = normalizeReferenceRejectionReasonCode(reference.curationReasonCode);
21
+ if (reason) reasons.set(reference.lineageId ?? reference.id, reason);
58
22
  }
59
23
  return reasons;
60
24
  }
61
25
 
62
- /** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
63
- export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
26
+ /** The Section a person explicitly recorded on a Reference's latest vote when accepting it, never a prediction. */
27
+ export function latestReferenceReviewSections(references: readonly ReferenceRecord[]): Map<string, string> {
64
28
  const sections = new Map<string, string>();
65
- for (const [lineage, message] of latestReferenceReviews(messages)) {
66
- const metadata = metadataRecord(message.metadata);
67
- const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
68
- if (sectionId) sections.set(lineage, sectionId);
29
+ for (const reference of references) {
30
+ if (!reference.curationActorKind || reference.curationStatus !== "accepted") continue;
31
+ const sectionId = reference.curationSectionId?.trim() ?? "";
32
+ if (sectionId) sections.set(reference.lineageId ?? reference.id, sectionId);
69
33
  }
70
34
  return sections;
71
35
  }
@@ -149,11 +149,15 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
149
149
  * any other label, or a review without a label, rejects with the reason code,
150
150
  * which the existing scope-training rule turns back into a cyclotron label
151
151
  * (out_of_scope and policy_exclusion) or none.
152
+ *
153
+ * A rejection must say why, but either way of saying it will do: a reason code,
154
+ * or the editor's own words, which the curation Lambda reads for the code they
155
+ * name. Only a rejection with neither is refused.
152
156
  */
153
157
  export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
154
158
  const accepted = review.label !== null && review.label === decision.positiveLabel;
155
- if (!accepted && !review.reasonCode) {
156
- throw new Error("A review that does not accept the reference needs a reason code.");
159
+ if (!accepted && !review.reasonCode && !review.explanation?.trim()) {
160
+ throw new Error("A review that does not accept the reference needs a reason code, or a written reason.");
157
161
  }
158
162
  return {
159
163
  action: accepted ? "accept" : "reject",