@anthusai/papyrus 1.0.0-next.32 → 1.0.0-next.33
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.
- package/amplify/data/resource.js +2 -0
- package/amplify/data/resource.ts +2 -0
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/app/newsroom/layout.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +51 -0
- package/components/newsroom-references-view.tsx +83 -8
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/topic-steering-workspace.tsx +48 -1
- package/lib/category-repository.ts +41 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/semantic-graph.ts +2 -0
- package/package.json +6 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -287,6 +287,8 @@ const schema = a.schema({
|
|
|
287
287
|
actorLabel: a.string(),
|
|
288
288
|
note: a.string(),
|
|
289
289
|
reasonCode: a.string(),
|
|
290
|
+
decisionRelationId: a.id(),
|
|
291
|
+
shareable: a.boolean(),
|
|
290
292
|
})
|
|
291
293
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
292
294
|
.authorization((allow) => [
|
package/amplify/data/resource.ts
CHANGED
|
@@ -312,6 +312,8 @@ const schema = a.schema({
|
|
|
312
312
|
actorLabel: a.string(),
|
|
313
313
|
note: a.string(),
|
|
314
314
|
reasonCode: a.string(),
|
|
315
|
+
decisionRelationId: a.id(),
|
|
316
|
+
shareable: a.boolean(),
|
|
315
317
|
})
|
|
316
318
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
317
319
|
.authorization((allow) => [
|
package/amplify/data/schema.js
CHANGED
|
@@ -245,6 +245,8 @@ export const schema = a.schema({
|
|
|
245
245
|
actorLabel: a.string(),
|
|
246
246
|
note: a.string(),
|
|
247
247
|
reasonCode: a.string(),
|
|
248
|
+
decisionRelationId: a.id(),
|
|
249
|
+
shareable: a.boolean(),
|
|
248
250
|
})
|
|
249
251
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
250
252
|
.authorization((allow) => [
|
package/amplify/data/schema.ts
CHANGED
|
@@ -268,6 +268,8 @@ export const schema = a.schema({
|
|
|
268
268
|
actorLabel: a.string(),
|
|
269
269
|
note: a.string(),
|
|
270
270
|
reasonCode: a.string(),
|
|
271
|
+
decisionRelationId: a.id(),
|
|
272
|
+
shareable: a.boolean(),
|
|
271
273
|
})
|
|
272
274
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
273
275
|
.authorization((allow) => [
|
|
@@ -4,6 +4,7 @@ import { Amplify } from "aws-amplify";
|
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
6
|
import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
|
|
7
|
+
import { referenceCurationMetadata } from "../shared/reference-curation-metadata";
|
|
7
8
|
|
|
8
9
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
9
10
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -225,14 +226,15 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
|
|
|
225
226
|
{ ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
|
|
226
227
|
"metadata",
|
|
227
228
|
"metadata",
|
|
228
|
-
{
|
|
229
|
+
referenceCurationMetadata({
|
|
229
230
|
action,
|
|
230
231
|
curationStatus: nextStatus,
|
|
231
232
|
reasonCode,
|
|
232
|
-
curationReasonCode: reasonCode,
|
|
233
233
|
referenceId,
|
|
234
234
|
referenceLineageId,
|
|
235
|
-
|
|
235
|
+
decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
|
|
236
|
+
shareable: event.arguments.shareable ?? null,
|
|
237
|
+
}),
|
|
236
238
|
{ filename: "metadata.json", now },
|
|
237
239
|
);
|
|
238
240
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|
package/app/newsroom/layout.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Component rules only (no reset, no theme): the Cyclotron review control and status widget.
|
|
2
|
+
import "cyclotron/styles/components.css";
|
|
1
3
|
import { NewsroomClientShell } from "../../components/newsroom-client-shell";
|
|
2
4
|
|
|
3
5
|
export default function NewsroomLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
@@ -3682,3 +3682,54 @@ function normalizeUnknownErrorMessage(error: unknown, fallback: string): string
|
|
|
3682
3682
|
return fallback;
|
|
3683
3683
|
}
|
|
3684
3684
|
}
|
|
3685
|
+
|
|
3686
|
+
const CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION = `
|
|
3687
|
+
mutation CreateKnowledgeRawPayload($input: CreateKnowledgeRawPayloadInput!) {
|
|
3688
|
+
createKnowledgeRawPayload(input: $input) { id }
|
|
3689
|
+
}
|
|
3690
|
+
`;
|
|
3691
|
+
|
|
3692
|
+
const GET_KNOWLEDGE_RAW_PAYLOAD_QUERY = `
|
|
3693
|
+
query GetKnowledgeRawPayload($id: ID!) {
|
|
3694
|
+
getKnowledgeRawPayload(id: $id) { id }
|
|
3695
|
+
}
|
|
3696
|
+
`;
|
|
3697
|
+
|
|
3698
|
+
/** The relevance cyclotron's latest cyclotron-status/v1 snapshot, as the sweep recorded it. */
|
|
3699
|
+
export async function loadCyclotronStatus(cyclotronId: string): Promise<import("cyclotron/cyclotron-status").CyclotronStatus | null> {
|
|
3700
|
+
const { cyclotronStatusPayloadId, latestCyclotronStatus } = await import("../lib/relevance-status");
|
|
3701
|
+
const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", cyclotronStatusPayloadId(cyclotronId), ["raw_payload"]);
|
|
3702
|
+
return latestCyclotronStatus(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** Ask the next decide-relevance sweep to set or clear a manual review rate. */
|
|
3706
|
+
export async function requestCyclotronReviewRate(
|
|
3707
|
+
cyclotronId: string,
|
|
3708
|
+
request: import("../lib/relevance-status").ReviewRateRequest,
|
|
3709
|
+
): Promise<void> {
|
|
3710
|
+
const { reviewRateRequestPayloadId, safeId } = await import("../lib/relevance-status");
|
|
3711
|
+
const id = reviewRateRequestPayloadId(cyclotronId);
|
|
3712
|
+
const existing = await runGraphql<{ getKnowledgeRawPayload?: { id: string } | null }>(GET_KNOWLEDGE_RAW_PAYLOAD_QUERY, { id });
|
|
3713
|
+
if (!existing.getKnowledgeRawPayload) {
|
|
3714
|
+
await runGraphql(CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION, {
|
|
3715
|
+
input: {
|
|
3716
|
+
id,
|
|
3717
|
+
ownerType: "cyclotron",
|
|
3718
|
+
ownerId: safeId(cyclotronId),
|
|
3719
|
+
payloadKind: "cyclotron-review-rate-request",
|
|
3720
|
+
createdAt: request.requestedAt,
|
|
3721
|
+
updatedAt: request.requestedAt,
|
|
3722
|
+
},
|
|
3723
|
+
});
|
|
3724
|
+
}
|
|
3725
|
+
await uploadModelPayloadForOwner({
|
|
3726
|
+
ownerKind: "knowledgeRawPayload",
|
|
3727
|
+
ownerId: id,
|
|
3728
|
+
ownerLineageId: id,
|
|
3729
|
+
role: "raw_payload",
|
|
3730
|
+
sortKey: `review-rate-request-${request.requestedAt}`,
|
|
3731
|
+
filename: "review-rate-request.json",
|
|
3732
|
+
mediaType: "application/json",
|
|
3733
|
+
content: JSON.stringify(request),
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
@@ -44,21 +44,36 @@ import {
|
|
|
44
44
|
import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
45
45
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
46
46
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
47
|
+
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
48
|
+
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
49
|
+
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
50
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
47
51
|
import {
|
|
48
52
|
loadReferenceAttachmentsForLineageId,
|
|
49
53
|
loadStoragePathUrl,
|
|
50
54
|
} from "./news-desk-taxonomy-client";
|
|
51
55
|
|
|
52
56
|
type NewsroomReferencesViewProps = {
|
|
57
|
+
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
58
|
+
cyclotronStatus?: CyclotronStatus | null;
|
|
59
|
+
/** Present for editors who may ask for a manual review rate. */
|
|
60
|
+
onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
|
|
53
61
|
demo?: boolean;
|
|
62
|
+
/** The relevance cyclotron's current decisions, keyed by reference lineage. */
|
|
63
|
+
decisions?: Map<string, RelevanceDecisionView>;
|
|
54
64
|
disabled?: boolean;
|
|
65
|
+
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
|
|
55
66
|
initialReferenceLineageId?: string | null;
|
|
56
67
|
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
|
|
57
68
|
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
58
69
|
references: ReferenceRecord[];
|
|
59
70
|
};
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
|
|
73
|
+
|
|
74
|
+
const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
|
|
75
|
+
|
|
76
|
+
const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
|
|
62
77
|
{ key: "all", label: "All" },
|
|
63
78
|
{ key: "pending", label: "Pending" },
|
|
64
79
|
{ key: "accepted", label: "Accepted" },
|
|
@@ -183,16 +198,30 @@ function useReferencePreviewAttachments(
|
|
|
183
198
|
return previewAttachments;
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
function decisionSummary(decision: RelevanceDecisionView): string {
|
|
202
|
+
const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
203
|
+
return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
|
|
207
|
+
return Boolean(decision?.reviewRecommended)
|
|
208
|
+
&& normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function ReferenceDetailPanel({
|
|
212
|
+
decision,
|
|
187
213
|
demo,
|
|
188
214
|
disabled,
|
|
215
|
+
onRelevanceReview,
|
|
189
216
|
onReview,
|
|
190
217
|
previewAttachments,
|
|
191
218
|
reference,
|
|
192
219
|
onClose,
|
|
193
220
|
}: {
|
|
221
|
+
decision?: RelevanceDecisionView;
|
|
194
222
|
demo?: boolean;
|
|
195
223
|
disabled?: boolean;
|
|
224
|
+
onRelevanceReview?: (curation: RelevanceCuration) => void;
|
|
196
225
|
onReview: (action: ReferenceCurationAction) => void;
|
|
197
226
|
previewAttachments: ReferenceAttachmentRecord[];
|
|
198
227
|
reference: ReferenceRecord;
|
|
@@ -224,11 +253,25 @@ function ReferenceDetailPanel({
|
|
|
224
253
|
) : null}
|
|
225
254
|
</div>
|
|
226
255
|
|
|
256
|
+
{decision && status === "pending" && onRelevanceReview ? (
|
|
257
|
+
<ReferenceRelevanceReview
|
|
258
|
+
decision={decision}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onCurate={onRelevanceReview}
|
|
261
|
+
referenceLineageId={lineageId}
|
|
262
|
+
/>
|
|
263
|
+
) : decision ? (
|
|
264
|
+
<p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
|
|
265
|
+
Cyclotron decision: {decisionSummary(decision)}
|
|
266
|
+
</p>
|
|
267
|
+
) : null}
|
|
268
|
+
|
|
227
269
|
<div
|
|
228
270
|
className="flex flex-wrap items-center gap-2"
|
|
229
271
|
data-news-desk-reference-curation-cluster
|
|
230
272
|
data-reference-curation-status={status}
|
|
231
273
|
>
|
|
274
|
+
{decision && status === "pending" && onRelevanceReview ? null : <>
|
|
232
275
|
<Button
|
|
233
276
|
aria-label="Accept reference"
|
|
234
277
|
aria-pressed={status === "accepted"}
|
|
@@ -255,6 +298,7 @@ function ReferenceDetailPanel({
|
|
|
255
298
|
<ThumbsDownIcon className="size-4" />
|
|
256
299
|
Reject
|
|
257
300
|
</Button>
|
|
301
|
+
</>}
|
|
258
302
|
<Button
|
|
259
303
|
aria-label="Archive reference"
|
|
260
304
|
disabled={archiveDisabled}
|
|
@@ -347,8 +391,12 @@ function ReferenceDetailPanel({
|
|
|
347
391
|
}
|
|
348
392
|
|
|
349
393
|
export function NewsroomReferencesView({
|
|
394
|
+
cyclotronStatus,
|
|
395
|
+
decisions,
|
|
396
|
+
onRequestReviewRate,
|
|
350
397
|
demo = false,
|
|
351
398
|
disabled = false,
|
|
399
|
+
onRelevanceReview,
|
|
352
400
|
initialReferenceLineageId = null,
|
|
353
401
|
onReview,
|
|
354
402
|
referenceAttachments = [],
|
|
@@ -365,13 +413,20 @@ export function NewsroomReferencesView({
|
|
|
365
413
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
366
414
|
const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
367
415
|
|
|
368
|
-
const [statusFilter, setStatusFilter] = useState<
|
|
416
|
+
const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
|
|
417
|
+
const needsReviewCount = useMemo(
|
|
418
|
+
() => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
|
|
419
|
+
[canonicalReferences, decisions],
|
|
420
|
+
);
|
|
421
|
+
const filters = decisions?.size ? [NEEDS_REVIEW_FILTER, ...STATUS_FILTERS] : STATUS_FILTERS;
|
|
369
422
|
const [selectedLineageId, setSelectedLineageId] = useState(routeLineageId);
|
|
370
423
|
const [mobileDetailOpen, setMobileDetailOpen] = useState(Boolean(routeLineageId) && isMobileDetail);
|
|
371
424
|
|
|
372
425
|
const filteredReferences = useMemo(
|
|
373
|
-
() =>
|
|
374
|
-
|
|
426
|
+
() => statusFilter === "needs_review"
|
|
427
|
+
? canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference))))
|
|
428
|
+
: filterReferencesByStatus(canonicalReferences, statusFilter),
|
|
429
|
+
[canonicalReferences, decisions, statusFilter],
|
|
375
430
|
);
|
|
376
431
|
|
|
377
432
|
const selectedReference = useMemo(
|
|
@@ -390,7 +445,7 @@ export function NewsroomReferencesView({
|
|
|
390
445
|
? filteredReferences[selectedIndex + 1]
|
|
391
446
|
: null;
|
|
392
447
|
|
|
393
|
-
const syncStatusUrl = useCallback((nextStatus:
|
|
448
|
+
const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
|
|
394
449
|
if (demo) return;
|
|
395
450
|
syncBrowserNewsroomIndexUrl("references", {
|
|
396
451
|
status: nextStatus === "all" ? "" : nextStatus,
|
|
@@ -452,22 +507,29 @@ export function NewsroomReferencesView({
|
|
|
452
507
|
Review source intake before it becomes accepted evidence.
|
|
453
508
|
</p>
|
|
454
509
|
</div>
|
|
510
|
+
{cyclotronStatus ? (
|
|
511
|
+
<div className="shrink-0">
|
|
512
|
+
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
513
|
+
</div>
|
|
514
|
+
) : null}
|
|
455
515
|
|
|
456
516
|
<Tabs
|
|
457
517
|
className="shrink-0"
|
|
458
518
|
defaultValue="all"
|
|
459
519
|
onValueChange={(value) => {
|
|
460
|
-
const next = value as
|
|
520
|
+
const next = value as ReferenceListFilter;
|
|
461
521
|
setStatusFilter(next);
|
|
462
522
|
syncStatusUrl(next);
|
|
463
523
|
}}
|
|
464
524
|
value={statusFilter}
|
|
465
525
|
>
|
|
466
526
|
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 bg-muted/40 p-1">
|
|
467
|
-
{
|
|
527
|
+
{filters.map((filter) => {
|
|
468
528
|
const count = filter.key === "all"
|
|
469
529
|
? canonicalReferences.length
|
|
470
|
-
:
|
|
530
|
+
: filter.key === "needs_review"
|
|
531
|
+
? needsReviewCount
|
|
532
|
+
: statusCounts[filter.key] ?? 0;
|
|
471
533
|
return (
|
|
472
534
|
<TabsTrigger className="text-xs sm:text-sm" key={filter.key} value={filter.key}>
|
|
473
535
|
{filter.label}
|
|
@@ -484,6 +546,7 @@ export function NewsroomReferencesView({
|
|
|
484
546
|
const lineageId = referenceLineageId(reference);
|
|
485
547
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
486
548
|
const active = selectedLineageId === lineageId;
|
|
549
|
+
const decision = decisions?.get(lineageId);
|
|
487
550
|
return (
|
|
488
551
|
<button
|
|
489
552
|
aria-current={active ? "true" : undefined}
|
|
@@ -504,6 +567,14 @@ export function NewsroomReferencesView({
|
|
|
504
567
|
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
505
568
|
</div>
|
|
506
569
|
<p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
|
|
570
|
+
{decision ? (
|
|
571
|
+
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
|
|
572
|
+
<span>Decision: {decisionSummary(decision)}</span>
|
|
573
|
+
{needsReview(reference, decision) ? (
|
|
574
|
+
<Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
|
|
575
|
+
) : null}
|
|
576
|
+
</p>
|
|
577
|
+
) : null}
|
|
507
578
|
</button>
|
|
508
579
|
);
|
|
509
580
|
}) : (
|
|
@@ -552,8 +623,10 @@ export function NewsroomReferencesView({
|
|
|
552
623
|
<ScrollArea className="min-h-0 flex-1">
|
|
553
624
|
{selectedReference ? (
|
|
554
625
|
<ReferenceDetailPanel
|
|
626
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
555
627
|
demo={demo}
|
|
556
628
|
disabled={disabled}
|
|
629
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
557
630
|
onReview={runReview}
|
|
558
631
|
previewAttachments={previewAttachments}
|
|
559
632
|
reference={selectedReference}
|
|
@@ -581,9 +654,11 @@ export function NewsroomReferencesView({
|
|
|
581
654
|
{selectedReference ? (
|
|
582
655
|
<ScrollArea className="max-h-[75dvh]">
|
|
583
656
|
<ReferenceDetailPanel
|
|
657
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
584
658
|
demo={demo}
|
|
585
659
|
disabled={disabled}
|
|
586
660
|
onClose={closeDetail}
|
|
661
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
587
662
|
onReview={runReview}
|
|
588
663
|
previewAttachments={previewAttachments}
|
|
589
664
|
reference={selectedReference}
|
|
@@ -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(
|
|
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}
|
|
@@ -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",
|
package/lib/reference-policy.ts
CHANGED
|
@@ -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
|
+
};
|
package/lib/semantic-graph.ts
CHANGED
|
@@ -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"] },
|
package/package.json
CHANGED
|
@@ -100,6 +100,8 @@
|
|
|
100
100
|
"./components/reader-auth-state": "./components/reader-auth-state.ts",
|
|
101
101
|
"./components/reader-settings": "./components/reader-settings.ts",
|
|
102
102
|
"./components/reader-theme-control": "./components/reader-theme-control.tsx",
|
|
103
|
+
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
104
|
+
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
103
105
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
104
106
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
105
107
|
"./components/staging-banner": "./components/staging-banner.tsx",
|
|
@@ -198,6 +200,8 @@
|
|
|
198
200
|
"./lib/reference-display": "./lib/reference-display.ts",
|
|
199
201
|
"./lib/reference-policy": "./lib/reference-policy.ts",
|
|
200
202
|
"./lib/reference-source-preview": "./lib/reference-source-preview.ts",
|
|
203
|
+
"./lib/relevance-decisions": "./lib/relevance-decisions.ts",
|
|
204
|
+
"./lib/relevance-status": "./lib/relevance-status.ts",
|
|
201
205
|
"./lib/renderer-config": "./lib/renderer-config.ts",
|
|
202
206
|
"./lib/renderer": "./lib/renderer.ts",
|
|
203
207
|
"./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
|
|
@@ -295,7 +299,7 @@
|
|
|
295
299
|
"optional": true
|
|
296
300
|
}
|
|
297
301
|
},
|
|
298
|
-
"version": "1.0.0-next.
|
|
302
|
+
"version": "1.0.0-next.33",
|
|
299
303
|
"dependencies": {
|
|
300
304
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
301
305
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
|
@@ -317,6 +321,7 @@
|
|
|
317
321
|
"class-variance-authority": "^0.7.1",
|
|
318
322
|
"clsx": "^2.1.1",
|
|
319
323
|
"cmdk": "^1.1.1",
|
|
324
|
+
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.1/cyclotron-0.2.1.tgz",
|
|
320
325
|
"esbuild": "^0.28.2",
|
|
321
326
|
"gray-matter": "^4.0.3",
|
|
322
327
|
"gsap": "^3.15.0",
|
package/routes.manifest.json
CHANGED