@anthusai/papyrus 2.0.0-next.76 → 2.0.0-next.78

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 (30) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +62 -60
  4. package/amplify/data/resource.ts +62 -60
  5. package/amplify/data/schema.js +57 -56
  6. package/amplify/data/schema.ts +57 -56
  7. package/amplify/functions/automation-view/schema.js +3 -2
  8. package/amplify/functions/automation-view/schema.ts +3 -2
  9. package/amplify/functions/editorial-checks/schema.js +9 -8
  10. package/amplify/functions/editorial-checks/schema.ts +9 -8
  11. package/amplify/functions/monitoring-view/schema.js +2 -1
  12. package/amplify/functions/monitoring-view/schema.ts +2 -1
  13. package/amplify/plugins/newsletter/schema.js +2 -1
  14. package/amplify/plugins/newsletter/schema.ts +2 -1
  15. package/amplify/plugins/reader-steering/schema.js +4 -3
  16. package/amplify/plugins/reader-steering/schema.ts +4 -3
  17. package/amplify/plugins/signup/schema.js +2 -1
  18. package/amplify/plugins/signup/schema.ts +2 -1
  19. package/components/news-desk-taxonomy-client.ts +24 -1
  20. package/components/newsroom-references-view.tsx +179 -36
  21. package/components/reference-cyclotron-suggestion.tsx +9 -5
  22. package/components/reference-relevance-review.tsx +3 -3
  23. package/components/topic-steering-workspace.tsx +143 -121
  24. package/lib/cyclotron-suggestions.ts +4 -4
  25. package/lib/reference-review-details.ts +71 -0
  26. package/lib/reference-review-request.ts +14 -0
  27. package/lib/relevance-decisions.ts +42 -10
  28. package/lib/use-reference-review-data.ts +44 -0
  29. package/package.json +4 -1
  30. package/routes.manifest.json +1 -1
@@ -28,7 +28,7 @@ export function ReferenceRelevanceReview({
28
28
  }: {
29
29
  disabled?: boolean;
30
30
  decision: RelevanceDecisionView;
31
- onCurate: (curation: RelevanceCuration) => void;
31
+ onCurate: (curation: RelevanceCuration) => Promise<void> | void;
32
32
  referenceLineageId: string;
33
33
  }) {
34
34
  const [shareable, setShareable] = useState(false);
@@ -47,10 +47,10 @@ export function ReferenceRelevanceReview({
47
47
  disabled={disabled}
48
48
  itemId={referenceLineageId}
49
49
  mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
50
- onReview={(review) => {
50
+ onReview={async (review) => {
51
51
  try {
52
52
  setError(null);
53
- onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
53
+ await onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
54
54
  } catch (failure) {
55
55
  setError(failure instanceof Error ? failure.message : String(failure));
56
56
  }
@@ -40,6 +40,7 @@ import {
40
40
  loadEditorReferencesData,
41
41
  loadEditorProcedureData,
42
42
  loadEditorSemanticRelationsData,
43
+ loadEditorReferenceReviewDetails,
43
44
  loadEditionForumThreads,
44
45
  loadNewsroomAssignmentPage,
45
46
  loadNewsroomMessagePage,
@@ -167,6 +168,9 @@ import {
167
168
  } from "../lib/reference-policy";
168
169
  import { isLocalPreviewEnabled } from "../lib/local-preview";
169
170
  import { currentRelevanceDecisions } from "../lib/relevance-decisions";
171
+ import { runReferenceReviewRequest } from "../lib/reference-review-request";
172
+ import { useReferenceReviewData } from "../lib/use-reference-review-data";
173
+ import { latestReferenceReviewReasons, latestReferenceReviewSections } from "../lib/reference-review-details";
170
174
  import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
171
175
  import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
172
176
  import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
@@ -902,6 +906,11 @@ function NewsDeskDashboard({
902
906
  const [referencesRealtimeStatus, setReferencesRealtimeStatus] = useState<RealtimeSubscriptionStatus>("idle");
903
907
  const [referencesRealtimeError, setReferencesRealtimeError] = useState<string | null>(null);
904
908
  const referencesRef = useRef(references);
909
+ const referenceReviewsPending = useRef(new Map<string, Promise<void>>());
910
+ const [selectedReviewRead, setSelectedReviewRead] = useState<{ lineageId: string; state: "loading" | "ready" | "error"; error: string | null }>({ lineageId: "", state: "ready", error: null });
911
+ const [selectedReviewAttempt, setSelectedReviewAttempt] = useState(0);
912
+ const reviewReasons = useMemo(() => latestReferenceReviewReasons(messages), [messages]);
913
+ const reviewSectionIds = useMemo(() => latestReferenceReviewSections(messages), [messages]);
905
914
 
906
915
  useEffect(() => {
907
916
  referencesRef.current = references;
@@ -1231,17 +1240,6 @@ function NewsDeskDashboard({
1231
1240
  if (dashboard.isDemo) return;
1232
1241
  let active = true;
1233
1242
 
1234
- if (activeTab === "messages" && !loadedSections.messages) {
1235
- setLoadedSections((current) => ({ ...current, messages: true }));
1236
- void loadEditorMessagesData()
1237
- .then((nextMessages) => {
1238
- if (active) setMessages(nextMessages);
1239
- })
1240
- .catch((error) => {
1241
- if (active) setActionState({ id: "messages-load", message: error instanceof Error ? error.message : "messages load failed", tone: "error" });
1242
- });
1243
- }
1244
-
1245
1243
  if (activeTab === "assignments" && !loadedSections.assignments) {
1246
1244
  setLoadedSections((current) => ({ ...current, assignments: true }));
1247
1245
  void loadEditorAssignmentsData()
@@ -1269,23 +1267,6 @@ function NewsDeskDashboard({
1269
1267
  });
1270
1268
  }
1271
1269
 
1272
- if ((activeTab === "assignments" || activeTab === "references" || activeTab === "messages") && !loadedSections.semanticRelations) {
1273
- setLoadedSections((current) => ({ ...current, semanticRelations: true }));
1274
- void loadEditorSemanticRelationsData()
1275
- .then((relations) => {
1276
- if (active) setSemanticRelations(relations);
1277
- })
1278
- .catch(() => {
1279
- if (active) setSemanticRelations([]);
1280
- });
1281
- if (!loadedSections.messages) {
1282
- setLoadedSections((current) => ({ ...current, messages: true }));
1283
- void loadEditorMessagesData().then((nextMessages) => {
1284
- if (active) setMessages(nextMessages);
1285
- }).catch(() => undefined);
1286
- }
1287
- }
1288
-
1289
1270
  if ((activeTab === "topics" || activeTab === "desks" || activeTab === "concepts" || activeTab === "administration") && !loadedSections.fullDashboard) {
1290
1271
  setLoadedSections((current) => ({ ...current, fullDashboard: true }));
1291
1272
  void loadEditorFullNewsDeskDashboard({ isAdmin: Boolean(dashboard.canManageUsers) })
@@ -1340,6 +1321,37 @@ function NewsDeskDashboard({
1340
1321
  refreshNewsroomSections,
1341
1322
  ]);
1342
1323
 
1324
+ const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
1325
+ const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
1326
+ const referenceReviewData = useReferenceReviewData({
1327
+ enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
1328
+ loadRelations: loadEditorSemanticRelationsData,
1329
+ loadMessages: loadEditorMessagesData,
1330
+ onRelations: setSemanticRelations,
1331
+ onMessages: setMessages,
1332
+ });
1333
+
1334
+ useEffect(() => {
1335
+ if (!canLoadPrivateCorpus || activeTab !== "references" || !selectedReviewLineageId) return;
1336
+ let active = true;
1337
+ setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "loading", error: null });
1338
+ void loadEditorReferenceReviewDetails(selectedReviewLineageId).then(({ relations, messages: history }) => {
1339
+ if (!active) return;
1340
+ setSemanticRelations((current) => [...current.filter((relation) => relation.subjectLineageId !== selectedReviewLineageId || relation.subjectKind !== "reference"), ...relations]);
1341
+ setMessages((current) => [...current.filter((message) => !history.some((entry) => entry.id === message.id)), ...history]);
1342
+ setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "ready", error: null });
1343
+ }).catch((error) => {
1344
+ if (active) setSelectedReviewRead({ lineageId: selectedReviewLineageId, state: "error", error: error instanceof Error ? error.message : "Reference review details could not be loaded." });
1345
+ });
1346
+ return () => { active = false; };
1347
+ }, [activeTab, canLoadPrivateCorpus, selectedReviewLineageId, selectedReviewAttempt]);
1348
+ const selectedReviewIsCurrent = selectedReviewLineageId && !dashboard.isDemo;
1349
+ const predictionState = selectedReviewIsCurrent
1350
+ ? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
1351
+ : referenceReviewData.state;
1352
+ const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
1353
+ const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
1354
+
1343
1355
  const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
1344
1356
  const { nextRecords, nextRecord, previousRecord } = upsertReferenceRecords(referencesRef.current, nextReference);
1345
1357
  referencesRef.current = nextRecords;
@@ -1594,96 +1606,98 @@ function NewsDeskDashboard({
1594
1606
  reasonCode?: ReferenceRejectionReasonCode | null,
1595
1607
  relevance?: { decisionRelationId: string; shareable: boolean },
1596
1608
  sectionId?: string | null,
1597
- ) {
1598
- const nextStatus = referenceCurationStatusForAction(action);
1599
- setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1600
- if (dashboard.isDemo) {
1601
- const now = new Date().toISOString();
1602
- const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
1603
- const relationId = `semantic-relation-demo-${messageId}`;
1604
- applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1605
- actorLabel: authState.label,
1606
- nextStatus,
1607
- note: note ?? null,
1608
- now,
1609
- }));
1610
- setMessages((current) => [{
1611
- id: messageId,
1612
- messageKind: "reference_curation",
1613
- messageDomain: "commentary",
1614
- status: "active",
1615
- body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
1616
- summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
1617
- source: "newsroom",
1618
- authorLabel: authState.label,
1619
- createdAt: now,
1620
- updatedAt: now,
1621
- metadata: {
1622
- action,
1623
- curationStatus: nextStatus,
1624
- reasonCode: action === "reject" ? reasonCode ?? null : null,
1625
- ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1626
- ...(action === "accept" && sectionId ? { sectionId } : {}),
1627
- },
1628
- }, ...current]);
1629
- setSemanticRelations((current) => [{
1630
- id: relationId,
1631
- relationState: "current",
1632
- predicate: "comment",
1633
- relationTypeId: "semantic-relation-type-comment",
1634
- relationTypeKey: "comment",
1635
- relationDomain: "commentary",
1636
- subjectKind: "message",
1637
- subjectId: messageId,
1638
- subjectLineageId: messageId,
1639
- subjectVersionNumber: 1,
1640
- objectKind: "reference",
1641
- objectId: reference.id,
1642
- objectLineageId: reference.lineageId ?? reference.id,
1643
- objectVersionNumber: reference.versionNumber ?? null,
1644
- subjectStateKey: `message#${messageId}#current`,
1645
- objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
1646
- objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
1647
- predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
1648
- subjectVersionKey: `message#${messageId}`,
1649
- objectVersionKey: `reference#${reference.id}`,
1650
- rank: 1,
1651
- reviewRecommended: false,
1652
- importedAt: now,
1653
- }, ...current]);
1654
- setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1655
- return;
1656
- }
1657
- startTransition(() => {
1658
- void (async () => {
1659
- try {
1660
- const response = await dataClient.mutations.reviewReferenceCuration(
1661
- {
1662
- referenceId: reference.id,
1663
- action,
1664
- actorLabel: authState.label,
1665
- note: note?.trim() || undefined,
1666
- reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1667
- decisionRelationId: relevance?.decisionRelationId,
1668
- shareable: relevance ? relevance.shareable : undefined,
1669
- sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1670
- },
1671
- { authMode: USER_POOL_AUTH_MODE },
1672
- );
1673
- const review = assertReferenceReviewMutationSucceeded(response, reference.id);
1674
- const status = review.status ?? nextStatus;
1675
- const now = new Date().toISOString();
1676
- applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1609
+ ): Promise<void> {
1610
+ return runReferenceReviewRequest(referenceReviewsPending.current, reference.lineageId ?? reference.id, async () => {
1611
+ if (!canEdit) throw new Error("Your session does not have permission to review references.");
1612
+ const nextStatus = referenceCurationStatusForAction(action);
1613
+ setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1614
+ if (dashboard.isDemo) {
1615
+ const now = new Date().toISOString();
1616
+ const messageId = `message-demo-${reference.id}-${action}-${now.replace(/[^0-9]/g, "")}`;
1617
+ const relationId = `semantic-relation-demo-${messageId}`;
1618
+ applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1619
+ actorLabel: authState.label,
1620
+ nextStatus,
1621
+ note: note ?? null,
1622
+ now,
1623
+ }));
1624
+ setMessages((current) => [{
1625
+ id: messageId,
1626
+ messageKind: "reference_curation",
1627
+ messageDomain: "commentary",
1628
+ status: "active",
1629
+ body: note?.trim() || `${authState.label} marked this reference ${nextStatus}.`,
1630
+ summary: `${reference.title ?? reference.externalItemId}: ${nextStatus}`,
1631
+ source: "newsroom",
1632
+ authorLabel: authState.label,
1633
+ createdAt: now,
1634
+ updatedAt: now,
1635
+ metadata: {
1636
+ referenceLineageId: reference.lineageId ?? reference.id,
1637
+ action,
1638
+ curationStatus: nextStatus,
1639
+ reasonCode: action === "reject" ? reasonCode ?? null : null,
1640
+ ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1641
+ ...(action === "accept" && sectionId ? { sectionId } : {}),
1642
+ },
1643
+ }, ...current]);
1644
+ setSemanticRelations((current) => [{
1645
+ id: relationId,
1646
+ relationState: "current",
1647
+ predicate: "comment",
1648
+ relationTypeId: "semantic-relation-type-comment",
1649
+ relationTypeKey: "comment",
1650
+ relationDomain: "commentary",
1651
+ subjectKind: "message",
1652
+ subjectId: messageId,
1653
+ subjectLineageId: messageId,
1654
+ subjectVersionNumber: 1,
1655
+ objectKind: "reference",
1656
+ objectId: reference.id,
1657
+ objectLineageId: reference.lineageId ?? reference.id,
1658
+ objectVersionNumber: reference.versionNumber ?? null,
1659
+ subjectStateKey: `message#${messageId}#current`,
1660
+ objectStateKey: `reference#${reference.lineageId ?? reference.id}#current`,
1661
+ objectSubjectStateKey: `reference#${reference.lineageId ?? reference.id}#current#message`,
1662
+ predicateObjectStateKey: `comment#reference#${reference.lineageId ?? reference.id}#current`,
1663
+ subjectVersionKey: `message#${messageId}`,
1664
+ objectVersionKey: `reference#${reference.id}`,
1665
+ rank: 1,
1666
+ reviewRecommended: false,
1667
+ importedAt: now,
1668
+ }, ...current]);
1669
+ setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1670
+ return;
1671
+ }
1672
+ try {
1673
+ const response = await dataClient.mutations.reviewReferenceCuration(
1674
+ {
1675
+ referenceId: reference.id,
1676
+ action,
1677
1677
  actorLabel: authState.label,
1678
- nextStatus: status,
1679
- note: note?.trim() || null,
1680
- now,
1681
- }));
1682
- setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1683
- } catch (error) {
1684
- setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
1685
- }
1686
- })();
1678
+ note: note?.trim() || undefined,
1679
+ reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1680
+ decisionRelationId: relevance?.decisionRelationId,
1681
+ shareable: relevance ? relevance.shareable : undefined,
1682
+ sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1683
+ },
1684
+ { authMode: USER_POOL_AUTH_MODE },
1685
+ );
1686
+ const review = assertReferenceReviewMutationSucceeded(response, reference.id);
1687
+ const status = review.status ?? nextStatus;
1688
+ const now = new Date().toISOString();
1689
+ applyReferenceRecord(buildReviewedReferenceRecord(referencesRef.current, reference, {
1690
+ actorLabel: authState.label,
1691
+ nextStatus: status,
1692
+ note: note?.trim() || null,
1693
+ now,
1694
+ }));
1695
+ setActionState({ id: reference.id, message: `${action} saved`, tone: "ok" });
1696
+ retryReferenceReviewData();
1697
+ } catch (error) {
1698
+ setActionState({ id: reference.id, message: error instanceof Error ? error.message : `${action} failed`, tone: "error" });
1699
+ throw error;
1700
+ }
1687
1701
  });
1688
1702
  }
1689
1703
 
@@ -3127,7 +3141,7 @@ function NewsDeskDashboard({
3127
3141
  <NewsroomReferencesView
3128
3142
  demo={Boolean(dashboard.isDemo)}
3129
3143
  disabled={controlsDisabled}
3130
- initialReferenceLineageId={initialSelection.reference ?? pathnameReferenceLineageId}
3144
+ initialReferenceLineageId={selectedReviewLineageId}
3131
3145
  cyclotronStatus={cyclotronStatus}
3132
3146
  cyclotronPriming={{
3133
3147
  labels: primingLabels,
@@ -3139,8 +3153,8 @@ function NewsDeskDashboard({
3139
3153
  },
3140
3154
  }}
3141
3155
  decisions={relevanceDecisions}
3142
- humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3143
- delegatedCurations={delegatedCurations}
3156
+ humanCuratedReferenceLineageIds={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : humanCuratedReferenceLineageIds}
3157
+ delegatedCurations={selectedReviewIsCurrent && predictionState !== "ready" ? undefined : delegatedCurations}
3144
3158
  shadowAgreement={shadowAgreement}
3145
3159
  onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
3146
3160
  const request = buildReviewRateRequest(choice, authState.label);
@@ -3153,11 +3167,19 @@ function NewsDeskDashboard({
3153
3167
  curation.note ?? undefined,
3154
3168
  normalizeReferenceRejectionReasonCode(curation.reasonCode),
3155
3169
  { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3170
+ curation.sectionId,
3156
3171
  )}
3157
3172
  sections={sectionChoices}
3158
3173
  sectionSuggestions={sectionSuggestions}
3159
3174
  sectionAgreement={siteSectionAgreement}
3160
- onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, undefined, undefined, options?.sectionId)}
3175
+ predictionState={predictionState}
3176
+ predictionError={predictionError}
3177
+ onRetryPredictions={retryReferenceReviewData}
3178
+ reviewReasons={reviewReasons}
3179
+ reviewSectionIds={reviewSectionIds}
3180
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
3181
+ options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined,
3182
+ options?.sectionId)}
3161
3183
  referenceAttachments={referenceAttachments}
3162
3184
  references={references}
3163
3185
  />
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
187
187
  }
188
188
 
189
189
  export type ProvenanceSummary = {
190
- teachingLabelsSentence: string;
191
- primingExamplesSentence: string;
190
+ teachingLabelsSentence: string | null;
191
+ primingExamplesSentence: string | null;
192
192
  doctrineShortHash: string | null;
193
193
  modelVersion: string | null;
194
194
  decidedAt: string | null;
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
202
202
  if (!provenance) return null;
203
203
  const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
204
204
  return {
205
- teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
- primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
205
+ teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
206
+ primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
207
207
  doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
208
208
  modelVersion: decision.version === null ? null : `revision ${decision.version}`,
209
209
  decidedAt: decision.decidedAt,
@@ -0,0 +1,71 @@
1
+ import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
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. */
12
+ 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;
49
+ }
50
+
51
+ /** A later acceptance or archive clears any earlier rejection reason. */
52
+ export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
53
+ 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);
58
+ }
59
+ return reasons;
60
+ }
61
+
62
+ /** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
63
+ export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
64
+ 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);
69
+ }
70
+ return sections;
71
+ }
@@ -0,0 +1,14 @@
1
+ /** Hold the lock before invoking a save, including before React can rerender. */
2
+ export function runReferenceReviewRequest(
3
+ pending: Map<string, Promise<void>>,
4
+ lineageId: string,
5
+ save: () => Promise<void>,
6
+ ): Promise<void> {
7
+ const existing = pending.get(lineageId);
8
+ if (existing) return existing;
9
+ const request = Promise.resolve().then(save).finally(() => {
10
+ if (pending.get(lineageId) === request) pending.delete(lineageId);
11
+ });
12
+ pending.set(lineageId, request);
13
+ return request;
14
+ }
@@ -7,8 +7,12 @@ export type RelevanceProvenance = {
7
7
  labelSet: string[];
8
8
  doctrineFingerprint: string | null;
9
9
  teachingFingerprint: string | null;
10
- teachingLabels: number;
11
- primingExamples: number;
10
+ teachingLabels: number | null;
11
+ primingExamples: number | null;
12
+ /** Recorded by the shared monitoring router, absent on local sweep provenance. */
13
+ system?: string;
14
+ publicationId?: string;
15
+ output?: string;
12
16
  };
13
17
 
14
18
  /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
@@ -28,6 +32,7 @@ export type RelevanceDecisionView = {
28
32
  /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
29
33
  reviewControl: "thumbs" | "labels";
30
34
  cyclotronId: string | null;
35
+ classifierId?: string;
31
36
  /** When the cyclotron made the suggestion, if the decision recorded it. */
32
37
  decidedAt: string | null;
33
38
  /** Null for decisions made before provenance was recorded. */
@@ -44,6 +49,7 @@ export type RelevanceReview = {
44
49
 
45
50
  /** The existing curation action an editor's review becomes. */
46
51
  export type RelevanceCuration = {
52
+ sectionId?: string | null;
47
53
  action: "accept" | "reject";
48
54
  reasonCode: string | null;
49
55
  note: string | null;
@@ -65,7 +71,7 @@ function parseMetadata(value: unknown): Record<string, unknown> {
65
71
  function parseProvenance(value: unknown): RelevanceProvenance | null {
66
72
  const record = parseMetadata(value);
67
73
  if (!Object.keys(record).length) return null;
68
- const count = (entry: unknown) => (Number.isFinite(Number(entry)) ? Number(entry) : 0);
74
+ const count = (entry: unknown) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 ? entry : null;
69
75
  const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
70
76
  return {
71
77
  labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
@@ -73,27 +79,52 @@ function parseProvenance(value: unknown): RelevanceProvenance | null {
73
79
  teachingFingerprint: text(record.teachingFingerprint),
74
80
  teachingLabels: count(record.teachingLabels),
75
81
  primingExamples: count(record.primingExamples),
82
+ ...(text(record.system) ? { system: text(record.system)! } : {}),
83
+ ...(text(record.publicationId) ? { publicationId: text(record.publicationId)! } : {}),
84
+ ...(text(record.output) ? { output: text(record.output)! } : {}),
76
85
  };
77
86
  }
78
87
 
88
+ function recordedVersion(value: unknown): number | null {
89
+ if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
90
+ const version = Number(value);
91
+ return Number.isInteger(version) && version >= 0 ? version : null;
92
+ }
93
+
94
+ function decisionTime(relation: SemanticRelationRecord): number {
95
+ const metadata = parseMetadata(relation.metadata);
96
+ for (const value of [metadata.decidedAt, relation.importedAt, relation.createdAt]) {
97
+ if (typeof value !== "string") continue;
98
+ const timestamp = Date.parse(value);
99
+ if (Number.isFinite(timestamp)) return timestamp;
100
+ }
101
+ return 0;
102
+ }
103
+
79
104
  /**
80
105
  * Current decisions keyed by reference lineage, from relations the Newsroom
81
106
  * already loads: no extra request per row.
82
107
  */
83
108
  export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
84
109
  const decisions = new Map<string, RelevanceDecisionView>();
85
- for (const relation of relations) {
86
- if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current") continue;
110
+ // Realtime events can briefly expose both sides of a superseding write. The
111
+ // result must not depend on list pagination or subscription arrival order.
112
+ const ordered = [...relations].sort((left, right) => decisionTime(left) - decisionTime(right) || left.id.localeCompare(right.id));
113
+ for (const relation of ordered) {
114
+ if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current" || relation.subjectKind !== "reference") continue;
87
115
  const metadata = parseMetadata(relation.metadata);
116
+ if (!relation.subjectLineageId || typeof metadata.label !== "string" || !metadata.label.trim()
117
+ || typeof metadata.decisionId !== "string" || !metadata.decisionId.trim()) continue;
88
118
  const probabilities = parseMetadata(metadata.probabilities);
89
119
  const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
90
- const version = Number(relation.modelVersion ?? metadata.version);
120
+ const version = recordedVersion(relation.modelVersion ?? metadata.version);
91
121
  decisions.set(relation.subjectLineageId, {
92
122
  decisionRelationId: relation.id,
93
- decisionId: String(metadata.decisionId ?? ""),
94
- label: String(metadata.label ?? ""),
95
- confidence: typeof relation.confidence === "number" ? relation.confidence : null,
96
- version: Number.isFinite(version) ? version : null,
123
+ decisionId: metadata.decisionId,
124
+ label: metadata.label,
125
+ confidence: typeof relation.confidence === "number" && Number.isFinite(relation.confidence)
126
+ && relation.confidence >= 0 && relation.confidence <= 1 ? relation.confidence : null,
127
+ version,
97
128
  reviewRecommended: Boolean(relation.reviewRecommended),
98
129
  reviewReason: reason,
99
130
  reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
@@ -104,6 +135,7 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
104
135
  question: typeof metadata.question === "string" ? metadata.question : null,
105
136
  reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
106
137
  cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
138
+ ...(relation.classifierId ? { classifierId: relation.classifierId } : {}),
107
139
  decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
108
140
  provenance: parseProvenance(metadata.provenance),
109
141
  });
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ /** Load persisted predictions and attributed review history without cancelling on a loading rerender. */
6
+ export function useReferenceReviewData<Relation, Message>({
7
+ enabled,
8
+ loadRelations,
9
+ loadMessages,
10
+ onRelations,
11
+ onMessages,
12
+ }: {
13
+ enabled: boolean;
14
+ loadRelations: () => Promise<Relation[]>;
15
+ loadMessages: () => Promise<Message[]>;
16
+ onRelations: (relations: Relation[]) => void;
17
+ onMessages: (messages: Message[]) => void;
18
+ }) {
19
+ const [state, setState] = useState<"loading" | "ready" | "error">(enabled ? "loading" : "ready");
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [attempt, setAttempt] = useState(0);
22
+ const retry = useCallback(() => setAttempt((current) => current + 1), []);
23
+ useEffect(() => {
24
+ if (!enabled) return;
25
+ let active = true;
26
+ setState("loading");
27
+ setError(null);
28
+ void Promise.allSettled([loadRelations(), loadMessages()]).then(([relations, history]) => {
29
+ if (!active) return;
30
+ if (relations.status === "fulfilled") onRelations(relations.value);
31
+ if (history.status === "fulfilled") onMessages(history.value);
32
+ const failure = relations.status === "rejected" ? relations.reason : history.status === "rejected" ? history.reason : null;
33
+ if (failure) {
34
+ const subject = relations.status === "rejected" ? "Predictions" : "Review history";
35
+ setError(`${subject} could not be loaded. ${failure instanceof Error ? failure.message : "Try again."}`);
36
+ setState("error");
37
+ } else {
38
+ setState("ready");
39
+ }
40
+ });
41
+ return () => { active = false; };
42
+ }, [enabled, loadRelations, loadMessages, onRelations, onMessages, attempt]);
43
+ return { state, error, retry };
44
+ }