@anthusai/papyrus 1.4.0-next.6 → 1.4.0-next.8
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 +59 -0
- package/amplify/data/resource.ts +64 -0
- package/amplify/data/schema.js +59 -0
- package/amplify/data/schema.ts +64 -0
- package/amplify/functions/category-action/handler.ts +179 -3
- package/amplify/functions/shared/curation-delegation.ts +67 -0
- package/amplify/functions/shared/reference-curation-metadata.ts +36 -0
- package/components/administration-delegations-panel.tsx +204 -0
- package/components/board-card-excerpts.tsx +43 -27
- package/components/curation-delegation-client.ts +81 -0
- package/components/news-desk-taxonomy-client.ts +7 -0
- package/components/newsroom-organizer.css +22 -0
- package/components/newsroom-references-view.tsx +56 -21
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/topic-steering-workspace.tsx +49 -3
- package/lib/curation-delegation-helpers.ts +59 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/local-preview-seed.ts +119 -1
- package/lib/relevance-decisions.ts +29 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
curationForSuggestionChoice,
|
|
5
|
+
type DelegatedCuration,
|
|
6
|
+
provenanceSummary,
|
|
7
|
+
suggestionConfidenceWords,
|
|
8
|
+
type SuggestionChoice,
|
|
9
|
+
} from "../lib/cyclotron-suggestions";
|
|
10
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
11
|
+
|
|
12
|
+
const RULE_BUTTON_CLASS =
|
|
13
|
+
"border border-foreground px-3 py-1 text-sm font-semibold text-foreground underline-offset-4 hover:underline disabled:cursor-not-allowed disabled:opacity-100 disabled:line-through";
|
|
14
|
+
|
|
15
|
+
/** UTC and fixed-format so server and browser render the same words. */
|
|
16
|
+
export function formatSuggestionTime(instant: string | null): string {
|
|
17
|
+
if (!instant) return "time not recorded";
|
|
18
|
+
const parsed = new Date(instant);
|
|
19
|
+
if (Number.isNaN(parsed.getTime())) return "time not recorded";
|
|
20
|
+
return `${parsed.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The one-line suggestion in a queue row. Never worded as a decision. */
|
|
24
|
+
export function ReferenceSuggestionRowLine({
|
|
25
|
+
decision,
|
|
26
|
+
referenceLineageId,
|
|
27
|
+
}: {
|
|
28
|
+
decision: RelevanceDecisionView;
|
|
29
|
+
referenceLineageId: string;
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<p className="mt-1 text-xs text-foreground" data-reference-suggestion-row={referenceLineageId}>
|
|
33
|
+
<span className="font-semibold">Suggestion:</span> {decision.label}, {suggestionConfidenceWords(decision)}
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The detail's Suggestion block. Agree and Overrule go through the ordinary
|
|
40
|
+
* accept and reject actions; nothing changes until an editor clicks.
|
|
41
|
+
*/
|
|
42
|
+
export function ReferenceSuggestionBlock({
|
|
43
|
+
decision,
|
|
44
|
+
disabled,
|
|
45
|
+
onCurate,
|
|
46
|
+
referenceLineageId,
|
|
47
|
+
statusLabel,
|
|
48
|
+
settled,
|
|
49
|
+
}: {
|
|
50
|
+
decision: RelevanceDecisionView;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
onCurate?: (curation: RelevanceCuration) => void;
|
|
53
|
+
referenceLineageId: string;
|
|
54
|
+
statusLabel: string;
|
|
55
|
+
settled: boolean;
|
|
56
|
+
}) {
|
|
57
|
+
const choose = (choice: SuggestionChoice) => onCurate?.(curationForSuggestionChoice(decision, choice));
|
|
58
|
+
const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
|
|
59
|
+
return (
|
|
60
|
+
<section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
|
|
61
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
|
|
62
|
+
<p className="text-sm text-foreground">
|
|
63
|
+
The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
|
|
64
|
+
</p>
|
|
65
|
+
<p className="text-xs text-foreground" data-reference-suggestion-notice>
|
|
66
|
+
A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
|
|
67
|
+
</p>
|
|
68
|
+
{!settled && onCurate ? (
|
|
69
|
+
<div className="flex flex-wrap gap-2">
|
|
70
|
+
<button
|
|
71
|
+
className={RULE_BUTTON_CLASS}
|
|
72
|
+
data-reference-suggestion-agree
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
onClick={() => choose("agree")}
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
Agree ({suggestsAccept ? "accept" : "reject"})
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
className={RULE_BUTTON_CLASS}
|
|
81
|
+
data-reference-suggestion-overrule
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
onClick={() => choose("overrule")}
|
|
84
|
+
type="button"
|
|
85
|
+
>
|
|
86
|
+
Overrule ({suggestsAccept ? "reject" : "accept"})
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The expandable "How this suggestion was made" block, from the recorded provenance. */
|
|
95
|
+
export function ReferenceSuggestionProvenance({ decision }: { decision: RelevanceDecisionView }) {
|
|
96
|
+
const summary = provenanceSummary(decision);
|
|
97
|
+
return (
|
|
98
|
+
<details className="text-sm text-foreground" data-reference-suggestion-provenance>
|
|
99
|
+
<summary className="cursor-pointer font-semibold underline-offset-4 hover:underline">How this suggestion was made</summary>
|
|
100
|
+
{summary ? (
|
|
101
|
+
<ul className="mt-2 space-y-1">
|
|
102
|
+
<li>{summary.teachingLabelsSentence}.</li>
|
|
103
|
+
<li>{summary.primingExamplesSentence}.</li>
|
|
104
|
+
<li>Doctrine fingerprint {summary.doctrineShortHash ?? "not recorded"}.</li>
|
|
105
|
+
<li>Model {summary.modelVersion ?? "version not recorded"}.</li>
|
|
106
|
+
<li>Decided {formatSuggestionTime(summary.decidedAt)}.</li>
|
|
107
|
+
</ul>
|
|
108
|
+
) : (
|
|
109
|
+
<p className="mt-2">This suggestion was made before the cyclotron recorded how. Only its model version and confidence are known.</p>
|
|
110
|
+
)}
|
|
111
|
+
</details>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Shown only when the latest curation of the reference was made by a signed-in person. */
|
|
116
|
+
export function CountsAsTrainingLabelNote({ referenceLineageId }: { referenceLineageId: string }) {
|
|
117
|
+
return (
|
|
118
|
+
<p className="border-l-2 border-foreground pl-2 text-xs font-semibold text-foreground" data-reference-training-label={referenceLineageId}>
|
|
119
|
+
Counts as a training label: an editor made this decision, so the cyclotron learns from it.
|
|
120
|
+
</p>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Shown on a record an agent decided for a person under a delegation. The
|
|
126
|
+
* backend stamps it human, so it counts as that person's training label.
|
|
127
|
+
*/
|
|
128
|
+
export function DelegatedCurationNote({ delegated, referenceLineageId }: { delegated: DelegatedCuration; referenceLineageId: string }) {
|
|
129
|
+
return (
|
|
130
|
+
<section aria-label="Delegated decision" className="space-y-1 border-l-2 border-foreground pl-2 text-sm text-foreground" data-reference-delegated-curation={referenceLineageId}>
|
|
131
|
+
<p className="font-semibold">
|
|
132
|
+
Decided by {delegated.agent} on behalf of {delegated.person ?? "a person"}
|
|
133
|
+
</p>
|
|
134
|
+
{delegated.instruction ? <blockquote className="italic">“{delegated.instruction}”</blockquote> : null}
|
|
135
|
+
<p className="text-xs">Delegation {delegated.delegationShortId || "id not recorded"}.</p>
|
|
136
|
+
<p className="text-xs font-semibold" data-reference-training-label={referenceLineageId}>
|
|
137
|
+
{delegated.countsAsTrainingLabel
|
|
138
|
+
? `Counts as a training label: the record is stamped as ${delegated.person ?? "the person"}'s own decision, so the cyclotron learns from it.`
|
|
139
|
+
: "Does not count as a training label."}
|
|
140
|
+
</p>
|
|
141
|
+
</section>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
agreementStripState,
|
|
5
|
+
formatPercentage,
|
|
6
|
+
dailyCapWords,
|
|
7
|
+
loopStateWords,
|
|
8
|
+
type AgreementStripMeasurement,
|
|
9
|
+
type ShadowAgreement,
|
|
10
|
+
} from "../lib/cyclotron-suggestions";
|
|
11
|
+
import { formatSuggestionTime } from "./reference-cyclotron-suggestion";
|
|
12
|
+
|
|
13
|
+
function MeasurementFigures({ measurement }: { measurement: AgreementStripMeasurement }) {
|
|
14
|
+
const figures: Array<[string, string]> = [
|
|
15
|
+
["Agreement", formatPercentage(measurement.agreement)],
|
|
16
|
+
["Precision", formatPercentage(measurement.precision)],
|
|
17
|
+
["Recall", formatPercentage(measurement.recall)],
|
|
18
|
+
["Comparisons", String(measurement.comparisons)],
|
|
19
|
+
];
|
|
20
|
+
return (
|
|
21
|
+
<dl className="flex flex-wrap gap-x-6 gap-y-1">
|
|
22
|
+
{figures.map(([name, value]) => (
|
|
23
|
+
<div key={name}>
|
|
24
|
+
<dt className="text-xs font-semibold uppercase tracking-wide text-foreground">{name}</dt>
|
|
25
|
+
<dd className="text-base font-semibold text-foreground">{value}</dd>
|
|
26
|
+
</div>
|
|
27
|
+
))}
|
|
28
|
+
</dl>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Live agreement at the top of References: how often the cyclotron's
|
|
34
|
+
* suggestions matched what editors then decided. Hidden without a payload.
|
|
35
|
+
*/
|
|
36
|
+
export function ReferenceLiveAgreement({ agreement }: { agreement: ShadowAgreement | null }) {
|
|
37
|
+
const state = agreementStripState(agreement);
|
|
38
|
+
if (state.kind === "hidden") return null;
|
|
39
|
+
return (
|
|
40
|
+
<section aria-label="Live agreement" className="shrink-0 space-y-2 border-y border-foreground py-3" data-reference-live-agreement={state.kind}>
|
|
41
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Live agreement</h3>
|
|
42
|
+
{state.loop ? (
|
|
43
|
+
<p className="text-sm text-foreground" data-reference-live-agreement-loop>
|
|
44
|
+
<span className="font-semibold">{loopStateWords(state.loop)}</span>
|
|
45
|
+
{" · "}Last sweep {formatSuggestionTime(state.loop.lastSweepAt)}
|
|
46
|
+
{" · "}{dailyCapWords(state.loop)}
|
|
47
|
+
</p>
|
|
48
|
+
) : null}
|
|
49
|
+
{state.stopReasonSentence ? (
|
|
50
|
+
<p className="text-sm font-semibold text-foreground" data-reference-live-agreement-stopped>{state.stopReasonSentence}</p>
|
|
51
|
+
) : null}
|
|
52
|
+
{state.kind === "not_enough" ? (
|
|
53
|
+
<p className="text-sm text-foreground" data-reference-live-agreement-not-enough>
|
|
54
|
+
Not enough comparisons yet: {state.measurement.comparisons} of {state.needed} editor decisions have followed a suggestion. Figures appear at {state.needed}.
|
|
55
|
+
</p>
|
|
56
|
+
) : <MeasurementFigures measurement={state.measurement} />}
|
|
57
|
+
<p className="text-xs text-foreground">
|
|
58
|
+
Suggestions compared with the decisions editors made afterwards. Computed {formatSuggestionTime(agreement?.computedAt ?? null)}.
|
|
59
|
+
</p>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { AdministrationDelegationsPanel } from "./administration-delegations-panel";
|
|
3
4
|
import { WikiConceptsView } from "./wiki-concepts-view";
|
|
4
5
|
import { Hub } from "aws-amplify/utils";
|
|
5
6
|
import { fetchAuthSession } from "aws-amplify/auth";
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
type KnowledgeQueryResponse,
|
|
55
56
|
type NewsroomRecordPage,
|
|
56
57
|
loadCyclotronStatus,
|
|
58
|
+
loadCyclotronShadowAgreement,
|
|
57
59
|
requestCyclotronPriming,
|
|
58
60
|
loadCyclotronPrimingRequests,
|
|
59
61
|
requestCyclotronReviewRate,
|
|
@@ -156,7 +158,10 @@ import {
|
|
|
156
158
|
REFERENCE_REJECTION_REASON_CODES,
|
|
157
159
|
referenceCurationStatusForAction,
|
|
158
160
|
} from "../lib/reference-policy";
|
|
161
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
159
162
|
import { currentRelevanceDecisions } from "../lib/relevance-decisions";
|
|
163
|
+
import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
164
|
+
import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
|
|
160
165
|
import {
|
|
161
166
|
buildPrimingRequest,
|
|
162
167
|
buildReviewRateRequest,
|
|
@@ -238,7 +243,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
238
243
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
239
244
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
240
245
|
type UserRoleAction = "grant" | "revoke";
|
|
241
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "signups";
|
|
246
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups";
|
|
242
247
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
243
248
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
244
249
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -784,7 +789,22 @@ function NewsDeskDashboard({
|
|
|
784
789
|
const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
|
|
785
790
|
const [messages, setMessages] = useState(dashboard.messages);
|
|
786
791
|
const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
|
|
787
|
-
const
|
|
792
|
+
const cyclotronPreviewFixtures = useMemo(
|
|
793
|
+
() => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
|
|
794
|
+
[references],
|
|
795
|
+
);
|
|
796
|
+
const relevanceDecisions = useMemo(
|
|
797
|
+
() => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
|
|
798
|
+
[cyclotronPreviewFixtures, semanticRelations],
|
|
799
|
+
);
|
|
800
|
+
const delegatedCurations = useMemo(
|
|
801
|
+
() => delegatedCurationsByReference(cyclotronPreviewFixtures ? [...messages, ...cyclotronPreviewFixtures.messages] : messages),
|
|
802
|
+
[cyclotronPreviewFixtures, messages],
|
|
803
|
+
);
|
|
804
|
+
const humanCuratedReferenceLineageIds = useMemo(
|
|
805
|
+
() => referencesWithHumanLatestCuration(cyclotronPreviewFixtures ? [...messages, ...cyclotronPreviewFixtures.messages] : messages),
|
|
806
|
+
[cyclotronPreviewFixtures, messages],
|
|
807
|
+
);
|
|
788
808
|
const relevanceCyclotronId = useMemo(
|
|
789
809
|
() => [...relevanceDecisions.values()].find((decision) => decision.cyclotronId)?.cyclotronId ?? null,
|
|
790
810
|
[relevanceDecisions],
|
|
@@ -819,6 +839,19 @@ function NewsDeskDashboard({
|
|
|
819
839
|
.catch(() => { if (!cancelled) setCyclotronStatus(null); });
|
|
820
840
|
return () => { cancelled = true; };
|
|
821
841
|
}, [dashboard.isDemo, relevanceCyclotronId]);
|
|
842
|
+
const [shadowAgreement, setShadowAgreement] = useState<ShadowAgreement | null>(null);
|
|
843
|
+
useEffect(() => {
|
|
844
|
+
if (cyclotronPreviewFixtures) {
|
|
845
|
+
setShadowAgreement(cyclotronPreviewFixtures.shadowAgreement);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (!relevanceCyclotronId || dashboard.isDemo) return;
|
|
849
|
+
let cancelled = false;
|
|
850
|
+
loadCyclotronShadowAgreement(relevanceCyclotronId)
|
|
851
|
+
.then((loaded) => { if (!cancelled) setShadowAgreement(loaded); })
|
|
852
|
+
.catch(() => { if (!cancelled) setShadowAgreement(null); });
|
|
853
|
+
return () => { cancelled = true; };
|
|
854
|
+
}, [cyclotronPreviewFixtures, dashboard.isDemo, relevanceCyclotronId]);
|
|
822
855
|
const [semanticNodes, setSemanticNodes] = useState(dashboard.semanticNodes);
|
|
823
856
|
const [assignments, setAssignments] = useState(dashboard.assignments);
|
|
824
857
|
const [assignmentEvents, setAssignmentEvents] = useState(dashboard.assignmentEvents);
|
|
@@ -3217,6 +3250,9 @@ function NewsDeskDashboard({
|
|
|
3217
3250
|
},
|
|
3218
3251
|
}}
|
|
3219
3252
|
decisions={relevanceDecisions}
|
|
3253
|
+
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3254
|
+
delegatedCurations={delegatedCurations}
|
|
3255
|
+
shadowAgreement={shadowAgreement}
|
|
3220
3256
|
onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
|
|
3221
3257
|
const request = buildReviewRateRequest(choice, authState.label);
|
|
3222
3258
|
if (dashboard.isDemo) return;
|
|
@@ -4463,6 +4499,15 @@ function AdministrationDeskView({
|
|
|
4463
4499
|
<strong>Procedures</strong>
|
|
4464
4500
|
<span>Procedure registry and versioned Tactus code</span>
|
|
4465
4501
|
</Link>
|
|
4502
|
+
<Link
|
|
4503
|
+
href={administrationPanelHref("delegations", isDemo)}
|
|
4504
|
+
data-news-desk-admin-nav="delegations"
|
|
4505
|
+
data-active={administrationPanel === "delegations" || undefined}
|
|
4506
|
+
onClick={() => onPanelChange("delegations")}
|
|
4507
|
+
>
|
|
4508
|
+
<strong>Agent delegations</strong>
|
|
4509
|
+
<span>Let an agent curate References for you</span>
|
|
4510
|
+
</Link>
|
|
4466
4511
|
{signupPluginEnabled() ? (
|
|
4467
4512
|
<Link
|
|
4468
4513
|
href={administrationPanelHref("signups", isDemo)}
|
|
@@ -4477,6 +4522,7 @@ function AdministrationDeskView({
|
|
|
4477
4522
|
</nav>}
|
|
4478
4523
|
<div className="news-desk-settings-panel">
|
|
4479
4524
|
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4525
|
+
{administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
|
|
4480
4526
|
{administrationPanel === "users" ? (
|
|
4481
4527
|
<AdministrationUsersPanel
|
|
4482
4528
|
canManageUsers={canManageUsers}
|
|
@@ -13181,7 +13227,7 @@ function signupPluginEnabled(): boolean {
|
|
|
13181
13227
|
}
|
|
13182
13228
|
|
|
13183
13229
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
13184
|
-
if (value === "policies" || value === "sections" || value === "procedures") return value;
|
|
13230
|
+
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations") return value;
|
|
13185
13231
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
13186
13232
|
return "users";
|
|
13187
13233
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const DEFAULT_DELEGATION_HOURS = 8;
|
|
2
|
+
export const MINIMUM_DELEGATION_HOURS = 1;
|
|
3
|
+
export const MAXIMUM_DELEGATION_HOURS = 72;
|
|
4
|
+
|
|
5
|
+
export type CurationDelegationRecord = {
|
|
6
|
+
delegationId: string;
|
|
7
|
+
agentLabel: string;
|
|
8
|
+
grantorLabel: string | null;
|
|
9
|
+
expiresAt: string;
|
|
10
|
+
revokedAt: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DelegationInputValidation =
|
|
14
|
+
| { ok: true; agentLabel: string; hours: number }
|
|
15
|
+
| { ok: false; message: string };
|
|
16
|
+
|
|
17
|
+
export function validateDelegationInput(agentLabelText: string, hoursText: string): DelegationInputValidation {
|
|
18
|
+
const agentLabel = agentLabelText.trim();
|
|
19
|
+
if (!agentLabel) return { ok: false, message: "Enter the name of the agent you are trusting." };
|
|
20
|
+
if (/\s/.test(agentLabel)) return { ok: false, message: "The agent name cannot contain spaces. Use the name the agent passes as --agent." };
|
|
21
|
+
const trimmedHours = hoursText.trim();
|
|
22
|
+
if (!/^\d+$/.test(trimmedHours)) return { ok: false, message: `Enter a whole number of hours from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS}.` };
|
|
23
|
+
const hours = Number(trimmedHours);
|
|
24
|
+
if (hours < MINIMUM_DELEGATION_HOURS || hours > MAXIMUM_DELEGATION_HOURS) {
|
|
25
|
+
return { ok: false, message: `A delegation lasts from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS} hours.` };
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, agentLabel, hours };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
|
|
31
|
+
if (revokedAt) return "Revoked";
|
|
32
|
+
const remaining = Date.parse(expiresAt) - nowMilliseconds;
|
|
33
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
|
|
34
|
+
const minutes = Math.ceil(remaining / 60_000);
|
|
35
|
+
if (minutes < 60) return `Expires in ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
36
|
+
const hours = Math.round(remaining / 3_600_000);
|
|
37
|
+
return `Expires in ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isDelegationActive(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
|
|
41
|
+
return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildDelegatedCommandLine(input: { email: string; agentLabel: string; token: string; action?: "approve" | "reject" }): string {
|
|
45
|
+
const email = input.email.trim() || "<your-email>";
|
|
46
|
+
return `papyrus references ${input.action ?? "approve"} <reference-id> --on-behalf-of ${email} --agent ${input.agentLabel} --instruction "<the person's own words>" --delegation-token ${input.token}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function describeDelegationError(error: unknown): string {
|
|
50
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
51
|
+
const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
|
|
52
|
+
const text = `${raw} ${graphql}`.toLowerCase();
|
|
53
|
+
if (text.includes("not authorized") || text.includes("unauthorized")) return "Your account is not allowed to manage delegations. Only editors and administrators can.";
|
|
54
|
+
if (text.includes("not found")) return "That delegation no longer exists.";
|
|
55
|
+
if (text.includes("only the person who granted")) return "Only the person who granted a delegation, or an administrator, can revoke it.";
|
|
56
|
+
if (text.includes("network") || text.includes("failed to fetch")) return "Could not reach the server. Check your connection and try again.";
|
|
57
|
+
const detail = (graphql || raw).trim();
|
|
58
|
+
return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
|
|
59
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { MessageRecord } from "./category-repository";
|
|
2
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "./relevance-decisions";
|
|
3
|
+
import { safeId } from "./relevance-status";
|
|
4
|
+
|
|
5
|
+
export const SHADOW_AGREEMENT_SCHEMA = "cyclotron-shadow-agreement/v1";
|
|
6
|
+
export const MINIMUM_COMPARISONS_FOR_AGREEMENT = 10;
|
|
7
|
+
|
|
8
|
+
/** What the sweep recorded about the loop itself: whether it is on, when it last ran, why it stopped. */
|
|
9
|
+
export type CyclotronLoopState = {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
stoppedBecause: string | null;
|
|
12
|
+
lastSweepAt: string | null;
|
|
13
|
+
maxDecisionsPerDay: number | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The live-agreement payload the sweep writes: suggestions compared with later human decisions. */
|
|
17
|
+
export type ShadowAgreement = {
|
|
18
|
+
schema: typeof SHADOW_AGREEMENT_SCHEMA;
|
|
19
|
+
comparisons: number;
|
|
20
|
+
agreement: number | null;
|
|
21
|
+
precision: number | null;
|
|
22
|
+
recall: number | null;
|
|
23
|
+
confusion: Record<string, unknown> | null;
|
|
24
|
+
computedAt: string | null;
|
|
25
|
+
positiveLabel: string | null;
|
|
26
|
+
loop: CyclotronLoopState | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AgreementStripMeasurement = {
|
|
30
|
+
comparisons: number;
|
|
31
|
+
agreement: number | null;
|
|
32
|
+
precision: number | null;
|
|
33
|
+
recall: number | null;
|
|
34
|
+
computedAt: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AgreementStripState =
|
|
38
|
+
| { kind: "hidden" }
|
|
39
|
+
| {
|
|
40
|
+
kind: "not_enough" | "measured";
|
|
41
|
+
measurement: AgreementStripMeasurement;
|
|
42
|
+
needed: number;
|
|
43
|
+
loop: CyclotronLoopState | null;
|
|
44
|
+
stopReasonSentence: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function shadowAgreementPayloadId(cyclotronId: string): string {
|
|
48
|
+
return `knowledge-raw-payload-cyclotron-shadow-agreement-${safeId(cyclotronId)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function numberOrNull(value: unknown): number | null {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseLoop(value: unknown): CyclotronLoopState | null {
|
|
56
|
+
if (!value || typeof value !== "object") return null;
|
|
57
|
+
const loop = value as Record<string, unknown>;
|
|
58
|
+
if (typeof loop.enabled !== "boolean") return null;
|
|
59
|
+
return {
|
|
60
|
+
enabled: loop.enabled,
|
|
61
|
+
stoppedBecause: typeof loop.stoppedBecause === "string" && loop.stoppedBecause ? loop.stoppedBecause : null,
|
|
62
|
+
lastSweepAt: typeof loop.lastSweepAt === "string" ? loop.lastSweepAt : null,
|
|
63
|
+
maxDecisionsPerDay: numberOrNull(loop.maxDecisionsPerDay),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A shadow-agreement payload, or null for any other JSON. */
|
|
68
|
+
export function parseShadowAgreement(json: unknown): ShadowAgreement | null {
|
|
69
|
+
if (!json || typeof json !== "object") return null;
|
|
70
|
+
const record = json as Record<string, unknown>;
|
|
71
|
+
if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
|
|
72
|
+
return {
|
|
73
|
+
schema: SHADOW_AGREEMENT_SCHEMA,
|
|
74
|
+
comparisons: numberOrNull(record.comparisons) ?? 0,
|
|
75
|
+
agreement: numberOrNull(record.agreement),
|
|
76
|
+
precision: numberOrNull(record.precision),
|
|
77
|
+
recall: numberOrNull(record.recall),
|
|
78
|
+
confusion: record.confusion && typeof record.confusion === "object" ? (record.confusion as Record<string, unknown>) : null,
|
|
79
|
+
computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
|
|
80
|
+
positiveLabel: typeof record.positiveLabel === "string" ? record.positiveLabel : null,
|
|
81
|
+
loop: parseLoop(record.loop),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The newest valid payload among a payload owner's attachments. */
|
|
86
|
+
export function latestShadowAgreement(payloads: Array<{ json: unknown; updatedAt?: string | null }>): ShadowAgreement | null {
|
|
87
|
+
const valid = payloads
|
|
88
|
+
.map((payload) => ({ agreement: parseShadowAgreement(payload.json), updatedAt: payload.updatedAt }))
|
|
89
|
+
.filter((entry): entry is { agreement: ShadowAgreement; updatedAt: string | null | undefined } => entry.agreement !== null)
|
|
90
|
+
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
|
|
91
|
+
return valid.length ? valid[0].agreement : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const STOP_REASON_WORDS: Record<string, string> = {
|
|
95
|
+
disabled: "The relevance cyclotron is switched off in the steering config, so it is making no suggestions.",
|
|
96
|
+
"daily decision cap": "The last sweep stopped at the daily decision cap, so some references have no suggestion yet.",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The sentence an editor reads for a stop reason; unknown reasons are shown as given. */
|
|
100
|
+
export function stopReasonSentence(reason: string): string {
|
|
101
|
+
return STOP_REASON_WORDS[reason] ?? `The last sweep stopped early: ${reason}.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What the Live agreement strip shows. No payload hides it; under ten
|
|
106
|
+
* comparisons the figures are withheld; a stop reason is always put in words.
|
|
107
|
+
*/
|
|
108
|
+
export function agreementStripState(agreement: ShadowAgreement | null): AgreementStripState {
|
|
109
|
+
if (!agreement) return { kind: "hidden" };
|
|
110
|
+
const loop = agreement.loop;
|
|
111
|
+
return {
|
|
112
|
+
kind: agreement.comparisons < MINIMUM_COMPARISONS_FOR_AGREEMENT ? "not_enough" : "measured",
|
|
113
|
+
measurement: {
|
|
114
|
+
comparisons: agreement.comparisons,
|
|
115
|
+
agreement: agreement.agreement,
|
|
116
|
+
precision: agreement.precision,
|
|
117
|
+
recall: agreement.recall,
|
|
118
|
+
computedAt: agreement.computedAt,
|
|
119
|
+
},
|
|
120
|
+
needed: MINIMUM_COMPARISONS_FOR_AGREEMENT,
|
|
121
|
+
loop,
|
|
122
|
+
stopReasonSentence: loop?.stoppedBecause ? stopReasonSentence(loop.stoppedBecause) : null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** "Loop on" or "Loop off", the word the strip leads with. */
|
|
127
|
+
export function loopStateWords(loop: CyclotronLoopState): string {
|
|
128
|
+
return loop.enabled ? "Loop on" : "Loop off";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The daily cap in words; no cap is said plainly. */
|
|
132
|
+
export function dailyCapWords(loop: CyclotronLoopState): string {
|
|
133
|
+
return loop.maxDecisionsPerDay === null ? "No daily decision cap" : `Daily cap ${loop.maxDecisionsPerDay} decisions`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatPercentage(fraction: number | null): string {
|
|
137
|
+
return fraction === null ? "not measured" : `${Math.round(fraction * 100)}%`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseMetadata(value: unknown): Record<string, unknown> {
|
|
141
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
142
|
+
if (typeof value !== "string") return {};
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(value);
|
|
145
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reference lineage ids whose latest curation Message was made by a signed-in
|
|
153
|
+
* person. Agent and classifier status changes never appear here, and neither
|
|
154
|
+
* does a reference a person decided before a later non-human change.
|
|
155
|
+
*/
|
|
156
|
+
export function referencesWithHumanLatestCuration(messages: MessageRecord[]): Set<string> {
|
|
157
|
+
const latestByReference = new Map<string, { createdAt: string; human: boolean }>();
|
|
158
|
+
for (const message of messages) {
|
|
159
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
160
|
+
const metadata = parseMetadata(message.metadata);
|
|
161
|
+
const lineageId = typeof metadata.referenceLineageId === "string" ? metadata.referenceLineageId : "";
|
|
162
|
+
if (!lineageId) continue;
|
|
163
|
+
const seen = latestByReference.get(lineageId);
|
|
164
|
+
if (seen && seen.createdAt >= message.createdAt) continue;
|
|
165
|
+
latestByReference.set(lineageId, { createdAt: message.createdAt, human: metadata.actorKind === "human" });
|
|
166
|
+
}
|
|
167
|
+
return new Set([...latestByReference].filter(([, entry]) => entry.human).map(([lineageId]) => lineageId));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type SuggestionChoice = "agree" | "overrule";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The accept or reject an editor's click becomes. Agreeing does what the
|
|
174
|
+
* suggestion implies; overruling does the opposite. Both are the ordinary
|
|
175
|
+
* curation actions, tied to the suggestion they answered.
|
|
176
|
+
*/
|
|
177
|
+
export function curationForSuggestionChoice(decision: RelevanceDecisionView, choice: SuggestionChoice): RelevanceCuration {
|
|
178
|
+
const suggestsAccept = decision.positiveLabel !== null && decision.label === decision.positiveLabel;
|
|
179
|
+
const accept = choice === "agree" ? suggestsAccept : !suggestsAccept;
|
|
180
|
+
return {
|
|
181
|
+
action: accept ? "accept" : "reject",
|
|
182
|
+
reasonCode: null,
|
|
183
|
+
note: null,
|
|
184
|
+
decisionRelationId: decision.decisionRelationId,
|
|
185
|
+
shareable: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type ProvenanceSummary = {
|
|
190
|
+
teachingLabelsSentence: string;
|
|
191
|
+
primingExamplesSentence: string;
|
|
192
|
+
doctrineShortHash: string | null;
|
|
193
|
+
modelVersion: string | null;
|
|
194
|
+
decidedAt: string | null;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const SHORT_HASH_LENGTH = 8;
|
|
198
|
+
|
|
199
|
+
/** The facts the "How this suggestion was made" block lists; null when the decision recorded no provenance. */
|
|
200
|
+
export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSummary | null {
|
|
201
|
+
const provenance = decision.provenance;
|
|
202
|
+
if (!provenance) return null;
|
|
203
|
+
const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
204
|
+
return {
|
|
205
|
+
teachingLabelsSentence: `Taught by ${plural(provenance.teachingLabels, "label")} that editors set themselves`,
|
|
206
|
+
primingExamplesSentence: `${plural(provenance.primingExamples, "priming example")} supplied as starting examples`,
|
|
207
|
+
doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
|
|
208
|
+
modelVersion: decision.version === null ? null : `version ${decision.version}`,
|
|
209
|
+
decidedAt: decision.decidedAt,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function suggestionConfidenceWords(decision: RelevanceDecisionView): string {
|
|
214
|
+
return decision.confidence === null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A curation recorded by an agent using a delegation a person granted. */
|
|
218
|
+
export type DelegatedCuration = {
|
|
219
|
+
agent: string;
|
|
220
|
+
person: string | null;
|
|
221
|
+
instruction: string;
|
|
222
|
+
delegationId: string;
|
|
223
|
+
delegationShortId: string;
|
|
224
|
+
/** What the backend does: a delegated curation is stamped human, so the person's label trains the cyclotron. */
|
|
225
|
+
countsAsTrainingLabel: boolean;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const DELEGATION_ID_PREFIX = "curation-delegation-";
|
|
229
|
+
const DELEGATION_SHORT_ID_LENGTH = 8;
|
|
230
|
+
|
|
231
|
+
export function delegationShortId(delegationId: string): string {
|
|
232
|
+
return delegationId.replace(DELEGATION_ID_PREFIX, "").slice(0, DELEGATION_SHORT_ID_LENGTH);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Delegated curations by reference lineage, from each reference's latest
|
|
237
|
+
* curation Message only. The person is the Message author; the agent is relayedBy.
|
|
238
|
+
*/
|
|
239
|
+
export function delegatedCurationsByReference(messages: MessageRecord[]): Map<string, DelegatedCuration> {
|
|
240
|
+
const latest = new Map<string, MessageRecord>();
|
|
241
|
+
for (const message of messages) {
|
|
242
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
243
|
+
const lineageId = parseMetadata(message.metadata).referenceLineageId;
|
|
244
|
+
if (typeof lineageId !== "string" || !lineageId) continue;
|
|
245
|
+
const seen = latest.get(lineageId);
|
|
246
|
+
if (!seen || seen.createdAt < message.createdAt) latest.set(lineageId, message);
|
|
247
|
+
}
|
|
248
|
+
const delegated = new Map<string, DelegatedCuration>();
|
|
249
|
+
for (const [lineageId, message] of latest) {
|
|
250
|
+
const metadata = parseMetadata(message.metadata);
|
|
251
|
+
if (metadata.attestation !== "delegated") continue;
|
|
252
|
+
const delegationId = typeof metadata.delegationId === "string" ? metadata.delegationId : "";
|
|
253
|
+
delegated.set(lineageId, {
|
|
254
|
+
agent: typeof metadata.relayedBy === "string" && metadata.relayedBy ? metadata.relayedBy : "an agent",
|
|
255
|
+
person: message.authorLabel?.trim() || null,
|
|
256
|
+
instruction: typeof metadata.instruction === "string" ? metadata.instruction : "",
|
|
257
|
+
delegationId,
|
|
258
|
+
delegationShortId: delegationShortId(delegationId),
|
|
259
|
+
countsAsTrainingLabel: metadata.actorKind === "human",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return delegated;
|
|
263
|
+
}
|