@anthusai/papyrus 1.0.0-next.32 → 1.0.0-next.34

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.
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CyclotronStatusView } from "cyclotron/components/cyclotron-status";
5
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
6
+
7
+ const RATE_CHOICES = [1, 0.5, 0.25, 0.1, 0];
8
+ const DAY_CHOICES = [1, 7, 14, 30];
9
+
10
+ export type ReviewRateChoice = { rate: number; days: number } | { clear: true };
11
+
12
+ /**
13
+ * The relevance cyclotron's status above the References list: a strip, the
14
+ * detail card on demand, and, for editors who may change it, a form that asks
15
+ * the next sweep for a manual review rate.
16
+ */
17
+ export function ReferenceCyclotronStatus({
18
+ onRequestRate,
19
+ status,
20
+ staleAfterHours = 24,
21
+ now = new Date(),
22
+ }: {
23
+ onRequestRate?: (choice: ReviewRateChoice) => Promise<void> | void;
24
+ status: CyclotronStatus;
25
+ staleAfterHours?: number;
26
+ now?: Date;
27
+ }) {
28
+ const [open, setOpen] = useState(false);
29
+ const [rate, setRate] = useState(0.5);
30
+ const [days, setDays] = useState(7);
31
+ const [message, setMessage] = useState<string | null>(null);
32
+ const ageHours = Math.max(0, (now.getTime() - Date.parse(status.asOf)) / 3_600_000);
33
+ const stale = Number.isFinite(ageHours) && ageHours > staleAfterHours;
34
+ const override = status.reviewRate.override;
35
+
36
+ async function submit(choice: ReviewRateChoice) {
37
+ if (!onRequestRate) return;
38
+ setMessage("Sending…");
39
+ try {
40
+ await onRequestRate(choice);
41
+ setMessage("The next sweep applies it and the status will show who set it.");
42
+ } catch (error) {
43
+ setMessage(error instanceof Error ? error.message : String(error));
44
+ }
45
+ }
46
+
47
+ return (
48
+ <section aria-label="Relevance cyclotron" className="space-y-2" data-reference-cyclotron-status>
49
+ <CyclotronStatusView status={status} />
50
+ {stale ? (
51
+ <p className="text-xs text-muted-foreground" data-reference-cyclotron-status-stale>
52
+ <span aria-hidden="true">⚠ </span>
53
+ Snapshot from {Math.round(ageHours)} hours ago; the next sweep refreshes it.
54
+ </p>
55
+ ) : null}
56
+ <button
57
+ aria-expanded={open}
58
+ className="text-xs text-muted-foreground underline underline-offset-4"
59
+ onClick={() => setOpen((value) => !value)}
60
+ type="button"
61
+ >
62
+ {open ? "Hide what the cyclotron is doing" : "What the cyclotron is doing"}
63
+ </button>
64
+ {open ? (
65
+ <div className="space-y-3">
66
+ <CyclotronStatusView classifierName="Relevant to this publication" status={status} variant="card" />
67
+ {onRequestRate ? (
68
+ <form
69
+ aria-label="Manual review rate"
70
+ className="flex flex-wrap items-end gap-3 text-sm"
71
+ data-reference-review-rate-form
72
+ onSubmit={(event) => {
73
+ event.preventDefault();
74
+ void submit({ rate, days });
75
+ }}
76
+ >
77
+ <label className="flex flex-col gap-1">
78
+ <span className="text-xs text-muted-foreground">Review rate for confident decisions</span>
79
+ <select aria-label="Manual review rate" onChange={(event) => setRate(Number(event.target.value))} value={rate}>
80
+ {RATE_CHOICES.map((choice) => (
81
+ <option key={choice} value={choice}>{Math.round(choice * 100)}%</option>
82
+ ))}
83
+ </select>
84
+ </label>
85
+ <label className="flex flex-col gap-1">
86
+ <span className="text-xs text-muted-foreground">For</span>
87
+ <select aria-label="How long the manual rate lasts" onChange={(event) => setDays(Number(event.target.value))} value={days}>
88
+ {DAY_CHOICES.map((choice) => (
89
+ <option key={choice} value={choice}>{choice} day{choice === 1 ? "" : "s"}</option>
90
+ ))}
91
+ </select>
92
+ </label>
93
+ <button className="cyclotron-review-secondary" type="submit">Set manual rate</button>
94
+ {override ? (
95
+ <button className="cyclotron-review-secondary" onClick={() => void submit({ clear: true })} type="button">
96
+ Clear manual rate
97
+ </button>
98
+ ) : null}
99
+ <p className="basis-full text-xs text-muted-foreground">
100
+ Low-confidence decisions and the random audit share are always reviewed.
101
+ </p>
102
+ {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
103
+ </form>
104
+ ) : null}
105
+ </div>
106
+ ) : null}
107
+ </section>
108
+ );
109
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ReviewControl, type ReviewReason } from "cyclotron/components/review-control";
5
+ import { isScopeTrainingNegativeReason, REFERENCE_REJECTION_REASON_CODES } from "../lib/reference-policy";
6
+ import {
7
+ curationForRelevanceReview,
8
+ type RelevanceCuration,
9
+ type RelevanceDecisionView,
10
+ } from "../lib/relevance-decisions";
11
+
12
+ /**
13
+ * Papyrus's existing rejection reasons, unchanged. Reasons that are not about
14
+ * relevance (a duplicate, an unavailable source) close the review without
15
+ * teaching the cyclotron.
16
+ */
17
+ export const RELEVANCE_REVIEW_REASONS: ReviewReason[] = REFERENCE_REJECTION_REASON_CODES.map((code) => ({
18
+ code,
19
+ text: code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " "),
20
+ ...(isScopeTrainingNegativeReason(code) ? {} : { noLabel: true }),
21
+ }));
22
+
23
+ export function ReferenceRelevanceReview({
24
+ disabled,
25
+ decision,
26
+ onCurate,
27
+ referenceLineageId,
28
+ }: {
29
+ disabled?: boolean;
30
+ decision: RelevanceDecisionView;
31
+ onCurate: (curation: RelevanceCuration) => void;
32
+ referenceLineageId: string;
33
+ }) {
34
+ const [shareable, setShareable] = useState(false);
35
+ const [error, setError] = useState<string | null>(null);
36
+ const classes = decision.classes.length ? decision.classes : [decision.label];
37
+ return (
38
+ <section aria-label="Relevance review" className="space-y-2" data-reference-relevance-review={referenceLineageId}>
39
+ <ReviewControl
40
+ decision={{
41
+ decisionId: decision.decisionId,
42
+ label: decision.label,
43
+ confidence: decision.confidence,
44
+ classes,
45
+ version: decision.version ?? undefined,
46
+ }}
47
+ disabled={disabled}
48
+ itemId={referenceLineageId}
49
+ mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
50
+ onReview={(review) => {
51
+ try {
52
+ setError(null);
53
+ onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
54
+ } catch (failure) {
55
+ setError(failure instanceof Error ? failure.message : String(failure));
56
+ }
57
+ }}
58
+ positiveLabel={decision.positiveLabel ?? undefined}
59
+ question={decision.question ?? undefined}
60
+ reasons={RELEVANCE_REVIEW_REASONS}
61
+ reviewReason={decision.reviewRecommended ? decision.reviewDetail ?? undefined : undefined}
62
+ />
63
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
64
+ <input
65
+ checked={shareable}
66
+ disabled={disabled}
67
+ onChange={(event) => setShareable(event.target.checked)}
68
+ type="checkbox"
69
+ />
70
+ My explanation may be quoted publicly (for the Cyclotron example page)
71
+ </label>
72
+ {error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
73
+ </section>
74
+ );
75
+ }
@@ -53,6 +53,8 @@ import {
53
53
  type ForumThreadWithMessages,
54
54
  type KnowledgeQueryResponse,
55
55
  type NewsroomRecordPage,
56
+ loadCyclotronStatus,
57
+ requestCyclotronReviewRate,
56
58
  } from "./news-desk-taxonomy-client";
57
59
  import { listConsoleThreads } from "../lib/console-chat-client";
58
60
  import {
@@ -151,9 +153,13 @@ import {
151
153
  type SemanticObjectSummary,
152
154
  } from "../lib/semantic-graph";
153
155
  import {
156
+ normalizeReferenceRejectionReasonCode,
154
157
  REFERENCE_REJECTION_REASON_CODES,
155
158
  referenceCurationStatusForAction,
156
159
  } from "../lib/reference-policy";
160
+ import { currentRelevanceDecisions } from "../lib/relevance-decisions";
161
+ import { buildReviewRateRequest, DEMO_CYCLOTRON_STATUS } from "../lib/relevance-status";
162
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
157
163
  import {
158
164
  referenceDisplaySummary,
159
165
  referenceMetadataField,
@@ -776,6 +782,24 @@ function NewsDeskDashboard({
776
782
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
777
783
  const [messages, setMessages] = useState(dashboard.messages);
778
784
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
785
+ const relevanceDecisions = useMemo(() => currentRelevanceDecisions(semanticRelations), [semanticRelations]);
786
+ const relevanceCyclotronId = useMemo(
787
+ () => [...relevanceDecisions.values()].find((decision) => decision.cyclotronId)?.cyclotronId ?? null,
788
+ [relevanceDecisions],
789
+ );
790
+ const [cyclotronStatus, setCyclotronStatus] = useState<CyclotronStatus | null>(null);
791
+ useEffect(() => {
792
+ if (!relevanceCyclotronId) return;
793
+ if (dashboard.isDemo) {
794
+ setCyclotronStatus(DEMO_CYCLOTRON_STATUS);
795
+ return;
796
+ }
797
+ let cancelled = false;
798
+ loadCyclotronStatus(relevanceCyclotronId)
799
+ .then((status) => { if (!cancelled) setCyclotronStatus(status); })
800
+ .catch(() => { if (!cancelled) setCyclotronStatus(null); });
801
+ return () => { cancelled = true; };
802
+ }, [dashboard.isDemo, relevanceCyclotronId]);
779
803
  const [semanticNodes, setSemanticNodes] = useState(dashboard.semanticNodes);
780
804
  const [assignments, setAssignments] = useState(dashboard.assignments);
781
805
  const [assignmentEvents, setAssignmentEvents] = useState(dashboard.assignmentEvents);
@@ -1507,7 +1531,13 @@ function NewsDeskDashboard({
1507
1531
  });
1508
1532
  }
1509
1533
 
1510
- function runReferenceCurationAction(reference: ReferenceRecord, action: ReferenceCurationAction, note?: string, reasonCode?: ReferenceRejectionReasonCode | null) {
1534
+ function runReferenceCurationAction(
1535
+ reference: ReferenceRecord,
1536
+ action: ReferenceCurationAction,
1537
+ note?: string,
1538
+ reasonCode?: ReferenceRejectionReasonCode | null,
1539
+ relevance?: { decisionRelationId: string; shareable: boolean },
1540
+ ) {
1511
1541
  const nextStatus = referenceCurationStatusForAction(action);
1512
1542
  setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1513
1543
  if (dashboard.isDemo) {
@@ -1535,6 +1565,7 @@ function NewsDeskDashboard({
1535
1565
  action,
1536
1566
  curationStatus: nextStatus,
1537
1567
  reasonCode: action === "reject" ? reasonCode ?? null : null,
1568
+ ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1538
1569
  },
1539
1570
  }, ...current]);
1540
1571
  setSemanticRelations((current) => [{
@@ -1575,6 +1606,8 @@ function NewsDeskDashboard({
1575
1606
  actorLabel: authState.label,
1576
1607
  note: note?.trim() || undefined,
1577
1608
  reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1609
+ decisionRelationId: relevance?.decisionRelationId,
1610
+ shareable: relevance ? relevance.shareable : undefined,
1578
1611
  },
1579
1612
  { authMode: USER_POOL_AUTH_MODE },
1580
1613
  );
@@ -3153,6 +3186,20 @@ function NewsDeskDashboard({
3153
3186
  demo={Boolean(dashboard.isDemo)}
3154
3187
  disabled={controlsDisabled}
3155
3188
  initialReferenceLineageId={initialSelection.reference ?? pathnameReferenceLineageId}
3189
+ cyclotronStatus={cyclotronStatus}
3190
+ decisions={relevanceDecisions}
3191
+ onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
3192
+ const request = buildReviewRateRequest(choice, authState.label);
3193
+ if (dashboard.isDemo) return;
3194
+ await requestCyclotronReviewRate(relevanceCyclotronId, request);
3195
+ } : undefined}
3196
+ onRelevanceReview={(reference, curation) => runReferenceCurationAction(
3197
+ reference,
3198
+ curation.action,
3199
+ curation.note ?? undefined,
3200
+ normalizeReferenceRejectionReasonCode(curation.reasonCode),
3201
+ { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3202
+ )}
3156
3203
  onReview={(reference, action) => runReferenceCurationAction(reference, action)}
3157
3204
  referenceAttachments={referenceAttachments}
3158
3205
  references={references}
@@ -13,6 +13,14 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
13
13
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
14
14
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
15
15
  const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
16
+ function originOfRedirectUrl(value) {
17
+ try {
18
+ return new URL(value).origin;
19
+ }
20
+ catch {
21
+ return value;
22
+ }
23
+ }
16
24
  export function resolveRevalidateSecretParameterName(config) {
17
25
  return `/papyrus/${config.siteId}/revalidate-secret`;
18
26
  }
@@ -259,14 +267,15 @@ export function parseSiteConfig(raw) {
259
267
  fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
260
268
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
261
269
  const stagingDomainName = resolveStagingDomainName(config);
270
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
262
271
  const requiredOrigins = [
263
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
264
- ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
265
- LOCAL_DEVELOPMENT_ORIGIN,
272
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
273
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
274
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
266
275
  ];
267
276
  for (const origin of requiredOrigins) {
268
- if (!redirectUrls.includes(origin)) {
269
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
277
+ if (!listedOrigins.has(origin)) {
278
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
270
279
  }
271
280
  }
272
281
  return config;
@@ -57,6 +57,14 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
57
57
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
58
58
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
59
59
  const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
60
+ function originOfRedirectUrl(value: string): string {
61
+ try {
62
+ return new URL(value).origin;
63
+ } catch {
64
+ return value;
65
+ }
66
+ }
67
+
60
68
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
61
69
  return `/papyrus/${config.siteId}/revalidate-secret`;
62
70
  }
@@ -321,14 +329,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
321
329
  if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
322
330
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
323
331
  const stagingDomainName = resolveStagingDomainName(config);
332
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
324
333
  const requiredOrigins = [
325
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
326
- ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
327
- LOCAL_DEVELOPMENT_ORIGIN,
334
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
335
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
336
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
328
337
  ];
329
338
  for (const origin of requiredOrigins) {
330
- if (!redirectUrls.includes(origin)) {
331
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
339
+ if (!listedOrigins.has(origin)) {
340
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
332
341
  }
333
342
  }
334
343
 
@@ -1660,6 +1660,47 @@ export function createDemoCategorySteeringDashboard(
1660
1660
  },
1661
1661
  ],
1662
1662
  semanticRelations: [
1663
+ {
1664
+ // The relevance cyclotron's decision about the pending demo reference.
1665
+ id: "semantic-relation-demo-relevance-history-002",
1666
+ relationState: "current",
1667
+ predicate: "relevance_decision_is",
1668
+ relationTypeKey: "relevance_decision_is",
1669
+ relationDomain: "workflow",
1670
+ subjectKind: "reference",
1671
+ subjectId: referenceHistoryTwoId,
1672
+ subjectLineageId: referenceHistoryTwoLineageId,
1673
+ objectKind: "semanticNode",
1674
+ objectId: "semantic-node-relevance-include-v1",
1675
+ objectLineageId: "semantic-node-relevance-include",
1676
+ subjectStateKey: `reference#${referenceHistoryTwoLineageId}#current`,
1677
+ objectStateKey: "semanticNode#semantic-node-relevance-include#current",
1678
+ objectSubjectStateKey: "semanticNode#semantic-node-relevance-include#current#reference",
1679
+ predicateObjectStateKey: "relevance_decision_is#semanticNode#semantic-node-relevance-include#current",
1680
+ subjectVersionKey: `reference#${referenceHistoryTwoId}`,
1681
+ objectVersionKey: "semanticNode#semantic-node-relevance-include-v1",
1682
+ score: 0.82,
1683
+ confidence: 0.82,
1684
+ rank: 1,
1685
+ classifierId: "papyrus-relevance/relevant",
1686
+ modelVersion: "1",
1687
+ reviewRecommended: true,
1688
+ importRunId: "knowledge-import-demo-projection",
1689
+ importedAt,
1690
+ metadata: JSON.stringify({
1691
+ decisionId: "decision-demo-history-002",
1692
+ cyclotronId: "papyrus-relevance",
1693
+ label: "include",
1694
+ probabilities: { include: 0.82, exclude: 0.18 },
1695
+ labels: ["include", "exclude"],
1696
+ positiveLabel: "include",
1697
+ question: "Should this candidate source become a reference for this publication?",
1698
+ reviewControl: "thumbs",
1699
+ reviewReason: "program",
1700
+ reviewDetail: "Every decision is reviewed while the cyclotron is onboarding.",
1701
+ version: 1,
1702
+ }),
1703
+ },
1663
1704
  {
1664
1705
  id: "semantic-relation-demo-reporting-context-comment",
1665
1706
  relationState: "current",
@@ -0,0 +1,40 @@
1
+ function originOf(value: string): string {
2
+ const trimmed = value.replace(/\/+$/, "");
3
+ try {
4
+ const parsed = new URL(trimmed);
5
+ const hostname = parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" ? "localhost" : parsed.hostname;
6
+ return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
7
+ } catch {
8
+ return trimmed;
9
+ }
10
+ }
11
+
12
+ function pathOf(value: string): string | null {
13
+ try {
14
+ return new URL(value).pathname.replace(/\/+$/, "");
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Puts the redirect URLs of the current origin first; among those, the
22
+ * newsroom return path leads when the site has one, so Amplify (which signs in
23
+ * with the first entry) returns an editor to the newsroom.
24
+ */
25
+ export function prioritizeRedirectUrlsForOrigin(
26
+ urls: readonly string[],
27
+ currentOrigin: string,
28
+ publicNewsroomBasePath: string,
29
+ ): string[] {
30
+ const origin = originOf(currentOrigin);
31
+ const newsroomPath = publicNewsroomBasePath.replace(/\/+$/, "");
32
+ const head = urls.filter((value) => originOf(value) === origin);
33
+ if (head.length === 0) return [...urls];
34
+ const tail = urls.filter((value) => originOf(value) !== origin);
35
+ const newsroomFirst =
36
+ newsroomPath === ""
37
+ ? head
38
+ : [...head.filter((value) => pathOf(value) === newsroomPath), ...head.filter((value) => pathOf(value) !== newsroomPath)];
39
+ return [...newsroomFirst, ...tail];
40
+ }
@@ -17,6 +17,11 @@ export type ReferenceRejectionReasonCode = typeof REFERENCE_REJECTION_REASON_COD
17
17
  const REFERENCE_REJECTION_REASON_CODE_SET = new Set<string>(REFERENCE_REJECTION_REASON_CODES);
18
18
  const SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET = new Set<string>(["out_of_scope", "policy_exclusion"]);
19
19
 
20
+ /** Rejection reasons that say the source is not relevant (scope-training negatives). */
21
+ export function isScopeTrainingNegativeReason(code: string): boolean {
22
+ return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
23
+ }
24
+
20
25
  export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
21
26
  const normalized = normalizePolicyToken(value ?? defaultStatus);
22
27
  if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";
@@ -0,0 +1,103 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+
3
+ export const RELEVANCE_RELATION = "relevance_decision_is";
4
+
5
+ /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
6
+ export type RelevanceDecisionView = {
7
+ decisionRelationId: string;
8
+ decisionId: string;
9
+ label: string;
10
+ confidence: number | null;
11
+ version: number | null;
12
+ /** True when the cyclotron sent this decision to review. */
13
+ reviewRecommended: boolean;
14
+ reviewReason: "program" | "audit" | null;
15
+ reviewDetail: string | null;
16
+ classes: string[];
17
+ positiveLabel: string | null;
18
+ question: string | null;
19
+ /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
20
+ reviewControl: "thumbs" | "labels";
21
+ cyclotronId: string | null;
22
+ };
23
+
24
+ /** What an editor submits from the review control. */
25
+ export type RelevanceReview = {
26
+ label: string | null;
27
+ reasonCode: string | null;
28
+ explanation: string | null;
29
+ shareable: boolean;
30
+ };
31
+
32
+ /** The existing curation action an editor's review becomes. */
33
+ export type RelevanceCuration = {
34
+ action: "accept" | "reject";
35
+ reasonCode: string | null;
36
+ note: string | null;
37
+ decisionRelationId: string;
38
+ shareable: boolean;
39
+ };
40
+
41
+ function parseMetadata(value: unknown): Record<string, unknown> {
42
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
43
+ if (typeof value !== "string") return {};
44
+ try {
45
+ const parsed = JSON.parse(value);
46
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Current decisions keyed by reference lineage, from relations the Newsroom
54
+ * already loads: no extra request per row.
55
+ */
56
+ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
57
+ const decisions = new Map<string, RelevanceDecisionView>();
58
+ for (const relation of relations) {
59
+ if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current") continue;
60
+ const metadata = parseMetadata(relation.metadata);
61
+ const probabilities = parseMetadata(metadata.probabilities);
62
+ const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
63
+ const version = Number(relation.modelVersion ?? metadata.version);
64
+ decisions.set(relation.subjectLineageId, {
65
+ decisionRelationId: relation.id,
66
+ decisionId: String(metadata.decisionId ?? ""),
67
+ label: String(metadata.label ?? ""),
68
+ confidence: typeof relation.confidence === "number" ? relation.confidence : null,
69
+ version: Number.isFinite(version) ? version : null,
70
+ reviewRecommended: Boolean(relation.reviewRecommended),
71
+ reviewReason: reason,
72
+ reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
73
+ classes: Array.isArray(metadata.labels) && metadata.labels.length
74
+ ? metadata.labels.map(String)
75
+ : Object.keys(probabilities),
76
+ positiveLabel: typeof metadata.positiveLabel === "string" ? metadata.positiveLabel : null,
77
+ question: typeof metadata.question === "string" ? metadata.question : null,
78
+ reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
79
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
80
+ });
81
+ }
82
+ return decisions;
83
+ }
84
+
85
+ /**
86
+ * Map a review to the existing curation mutation. The positive label accepts;
87
+ * any other label, or a review without a label, rejects with the reason code,
88
+ * which the existing scope-training rule turns back into a cyclotron label
89
+ * (out_of_scope and policy_exclusion) or none.
90
+ */
91
+ export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
92
+ const accepted = review.label !== null && review.label === decision.positiveLabel;
93
+ if (!accepted && !review.reasonCode) {
94
+ throw new Error("A review that does not accept the reference needs a reason code.");
95
+ }
96
+ return {
97
+ action: accepted ? "accept" : "reject",
98
+ reasonCode: accepted ? null : review.reasonCode,
99
+ note: review.explanation?.trim() || null,
100
+ decisionRelationId: decision.decisionRelationId,
101
+ shareable: review.shareable,
102
+ };
103
+ }
@@ -0,0 +1,63 @@
1
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
2
+ import { isCyclotronStatus } from "cyclotron/cyclotron-status";
3
+
4
+ /** The KnowledgeRawPayload the decide-relevance sweep writes the status snapshot to. */
5
+ export function cyclotronStatusPayloadId(cyclotronId: string): string {
6
+ return `knowledge-raw-payload-cyclotron-status-${safeId(cyclotronId)}`;
7
+ }
8
+
9
+ /** The KnowledgeRawPayload an editor writes to ask for a manual review rate. */
10
+ export function reviewRateRequestPayloadId(cyclotronId: string): string {
11
+ return `knowledge-raw-payload-cyclotron-review-rate-request-${safeId(cyclotronId)}`;
12
+ }
13
+
14
+ /** Mirrors Python's ids.safe_id so both sides name the same record. */
15
+ export function safeId(value: string): string {
16
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
17
+ }
18
+
19
+ export type ReviewRateRequest =
20
+ | { rate: number; expiresAt: string; setBy: string; requestedAt: string }
21
+ | { clear: true; setBy: string; requestedAt: string };
22
+
23
+ export function buildReviewRateRequest(
24
+ choice: { rate: number; days: number } | { clear: true },
25
+ setBy: string,
26
+ now: Date = new Date(),
27
+ ): ReviewRateRequest {
28
+ const requestedAt = now.toISOString();
29
+ if ("clear" in choice) return { clear: true, setBy, requestedAt };
30
+ if (!(choice.rate >= 0 && choice.rate <= 1)) throw new Error("A review rate is between 0% and 100%.");
31
+ if (!(choice.days >= 1)) throw new Error("A manual rate lasts at least one day.");
32
+ const expiresAt = new Date(now.getTime() + choice.days * 24 * 60 * 60 * 1000).toISOString();
33
+ return { rate: choice.rate, expiresAt, setBy, requestedAt };
34
+ }
35
+
36
+ /** The newest valid snapshot among a payload's attachments. */
37
+ export function latestCyclotronStatus(payloads: Array<{ json: unknown; updatedAt?: string | null }>): CyclotronStatus | null {
38
+ const valid = payloads
39
+ .filter((payload) => isCyclotronStatus(payload.json))
40
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
41
+ return valid.length ? (valid[0].json as CyclotronStatus) : null;
42
+ }
43
+
44
+ /** What demo mode shows: an onboarding cyclotron that has made one decision. */
45
+ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
46
+ schema: "cyclotron-status/v1",
47
+ cyclotron: { id: "papyrus-relevance", classifier: "relevant", version: 1, refits: 0, fingerprint: "demo" },
48
+ asOf: "2026-05-16T12:00:00.000Z",
49
+ alignment: {
50
+ window: 200, labels: 0, accuracy: null, precision: null, recall: null,
51
+ positiveLabel: "include", measuredOn: "reviews selected by the cyclotron",
52
+ },
53
+ calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
54
+ reviewRate: {
55
+ state: "onboarding", rate: 1, reason: "Onboarding: every decision is reviewed until the cyclotron earns a lower rate.",
56
+ auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
57
+ nextCheckAfterDecisions: 99,
58
+ nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a calibration gap under 5 points. Next check in 99 decisions.",
59
+ expectedReviewsPerWeek: 1,
60
+ },
61
+ lastChange: null,
62
+ pending: { decisionsAwaitingReview: 1, staleSince: null },
63
+ };
@@ -32,6 +32,7 @@ export type SemanticObjectKind = typeof SEMANTIC_OBJECT_KINDS[number];
32
32
  export type SemanticPredicateId =
33
33
  | "classified_as"
34
34
  | "quality_rating_is"
35
+ | "relevance_decision_is"
35
36
  | "reference_summary_100_tokens"
36
37
  | "reference_summary_200_tokens"
37
38
  | "reference_summary_500_tokens"
@@ -80,6 +81,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
80
81
  { id: "about", label: "about", group: "commentary", inverseLabel: "commentary" },
81
82
  { id: "comment", label: "comments on", group: "commentary", inverseLabel: "commented on by", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
82
83
  { id: "ingestion_rationale", label: "ingestion rationale for", group: "commentary", inverseLabel: "ingestion rationale", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
84
+ { id: "relevance_decision_is", label: "relevance decision is", group: "workflow", inverseLabel: "relevance decision for", contextPackTags: ["reference_curation"] },
83
85
  { id: "requests_work_on", label: "requests work on", group: "workflow", inverseLabel: "requested work", contextPackTags: ["assignment_context", "editing"] },
84
86
  { id: "uses_evidence", label: "uses evidence", group: "evidence", inverseLabel: "used as evidence by", contextPackTags: ["assignment_context", "research", "reference_graph"] },
85
87
  { id: "uses_signal", label: "uses signal", group: "evidence", inverseLabel: "signal for", contextPackTags: ["assignment_context", "research", "reference_graph"] },