@anthusai/papyrus 1.0.0-next.60 → 1.0.0-next.61
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/app/tailwind.css +1 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-priming.tsx +18 -0
- package/lib/curation-client.ts +61 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/package.json +8 -2
- package/routes.manifest.json +1 -1
package/app/tailwind.css
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {createContext,useContext,useMemo,useRef,type ReactNode} from 'react';
|
|
4
|
+
import {createCurationSession,requestCuration,type CurationBootstrap,type CurationRequest,type CurationSession} from '../lib/curation-client';
|
|
5
|
+
import type {PublicationScope} from '../lib/publication-cyclotron';
|
|
6
|
+
|
|
7
|
+
const Context=createContext<CurationSession|null>(null);
|
|
8
|
+
|
|
9
|
+
/** Host opt-in. Absence preserves the released CMS and performs no new reads. */
|
|
10
|
+
export function NewsroomCurationProvider({bootstrap,request=requestCuration,children}:{bootstrap:CurationBootstrap|null;request?:CurationRequest;children:ReactNode}){
|
|
11
|
+
const selected=useRef<PublicationScope|null>(bootstrap?.scope??null);
|
|
12
|
+
selected.current=bootstrap?.scope??null;
|
|
13
|
+
const session=useMemo(()=>bootstrap?createCurationSession(bootstrap,request,()=>selected.current):null,[bootstrap,request]);
|
|
14
|
+
return <Context.Provider value={session}>{children}</Context.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useNewsroomCuration(){return useContext(Context);}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
4
|
+
import { ReferencePriming } from "./reference-priming";
|
|
5
|
+
import { ReferenceCurationControls } from "./reference-curation-controls";
|
|
6
|
+
|
|
3
7
|
import {
|
|
4
8
|
ArchiveIcon,
|
|
5
9
|
ChevronLeftIcon,
|
|
@@ -54,6 +58,8 @@ import {
|
|
|
54
58
|
} from "./news-desk-taxonomy-client";
|
|
55
59
|
|
|
56
60
|
type NewsroomReferencesViewProps = {
|
|
61
|
+
/** Present only when the host has configured optional example priming. */
|
|
62
|
+
priming?: PrimingPanelProps;
|
|
57
63
|
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
58
64
|
cyclotronStatus?: CyclotronStatus | null;
|
|
59
65
|
/** Present for editors who may ask for a manual review rate. */
|
|
@@ -391,6 +397,7 @@ function ReferenceDetailPanel({
|
|
|
391
397
|
}
|
|
392
398
|
|
|
393
399
|
export function NewsroomReferencesView({
|
|
400
|
+
priming,
|
|
394
401
|
cyclotronStatus,
|
|
395
402
|
decisions,
|
|
396
403
|
onRequestReviewRate,
|
|
@@ -507,6 +514,8 @@ export function NewsroomReferencesView({
|
|
|
507
514
|
Review source intake before it becomes accepted evidence.
|
|
508
515
|
</p>
|
|
509
516
|
</div>
|
|
517
|
+
<ReferenceCurationControls disabled={disabled} />
|
|
518
|
+
{priming && !disabled ? <ReferencePriming {...priming} /> : null}
|
|
510
519
|
{cyclotronStatus ? (
|
|
511
520
|
<div className="shrink-0">
|
|
512
521
|
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useRef,useState} from 'react';
|
|
4
|
+
import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/review-control';
|
|
5
|
+
import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
|
|
6
|
+
import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
|
|
7
|
+
|
|
8
|
+
function requestId(){
|
|
9
|
+
const bytes=crypto.getRandomValues(new Uint8Array(16));
|
|
10
|
+
return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Data-only shared operator panel. The authorized host owns reads, writes and refresh. */
|
|
14
|
+
type PublicationPanelProps={mode:'embedded'|'remote';status:PublicationStatus;history:PublicationHistory;onReview?:(payload:PublicationReviewPayload)=>Promise<void>;onSteer?:(classifier:string)=>void};
|
|
15
|
+
export function PublicationCyclotronPanel(props:PublicationPanelProps){
|
|
16
|
+
assertPublicationScope(props.status,props.history);
|
|
17
|
+
// Remount all drafts, retry identities, pending messages and busy state on scope change.
|
|
18
|
+
return <PublicationControls key={publicationScopeKey(props.status)} {...props}/>;
|
|
19
|
+
}
|
|
20
|
+
function PublicationControls({mode,status,history,onReview,onSteer}:PublicationPanelProps){
|
|
21
|
+
assertPublicationScope(status,history);
|
|
22
|
+
const context=publicationScopeKey(status);const current=useRef(context);current.current=context;
|
|
23
|
+
const [message,setMessage]=useState<{context:string;key:string;error:boolean;text:string}|null>(null);const [busy,setBusy]=useState<string|null>(null);
|
|
24
|
+
const [editing,setEditing]=useState<Record<string,boolean>>({});const requests=useRef<Record<string,{signature:string;id:string}>>({});
|
|
25
|
+
async function submit(row:PublicationHistoryRow,classifier:string,review:ReviewControlReview){
|
|
26
|
+
const key=`${context}:${row.decision.decisionId}:${classifier}:${row.context?.contextId??'legacy'}`;
|
|
27
|
+
try{
|
|
28
|
+
const signature=JSON.stringify(review);const previous=requests.current[key];
|
|
29
|
+
const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
|
|
30
|
+
const payload=publicationReviewPayload(status,row,classifier,review,id);
|
|
31
|
+
if(!onReview)throw new Error('This host has not enabled human review');
|
|
32
|
+
setBusy(key);setMessage(null);await onReview(payload);
|
|
33
|
+
if(current.current===context){setMessage({context,key,error:false,text:'Human label acknowledged.'});setEditing(old=>({...old,[key]:false}));}
|
|
34
|
+
}catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Review failed; its outcome must be checked before retrying.'});}
|
|
35
|
+
finally{if(current.current===context)setBusy(null);}
|
|
36
|
+
}
|
|
37
|
+
return <section data-publication-scope={context} aria-label="Publication Cyclotron controls" className="space-y-4">
|
|
38
|
+
<header><h2 className="text-lg font-semibold">Cyclotron · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Classifier data owned by this deployment.':'Canonical classifier data owned by the shared remote lab.'}</p></header>
|
|
39
|
+
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
|
+
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
+
{onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Evaluate classifier proposal</button>:null}
|
|
42
|
+
{history.history.map(row=>{
|
|
43
|
+
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
|
+
const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
|
|
45
|
+
return <article key={key} className="space-y-2 rounded-md border p-3">
|
|
46
|
+
{historical?<p className="text-sm">Historical source or binding revision · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Canonical feedback changed; provenance reconciliation is required before replay.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
|
|
47
|
+
<p className="whitespace-pre-wrap text-sm">{String(row.item.abstract??row.item.text??'')}</p>
|
|
48
|
+
{!saved||editing[key]?<p className="text-sm" role="status">{historical?'This source revision is read-only.':!onReview?'Human review is unavailable in this session.':busy===key?'Submitting review… Your selection is preserved; wait for acknowledgement.':'Choose a label to enable Submit review. Explanation is optional.'}</p>:null}
|
|
49
|
+
<ReviewControl key={`${key}:${Boolean(editing[key])}`} itemId={`${row.decision.itemId}:${Array.from(new TextEncoder().encode(context+':'+output.id+':'+(row.context?.contextId??'legacy')),byte=>byte.toString(16).padStart(2,'0')).join('')}`} decision={{decisionId:row.decision.decisionId,...prediction,classes:output.classes}} question={output.question} mode="labels" positiveLabel={output.positiveLabel??undefined} disabled={Boolean(busy)||!onReview||historical} value={saved&&!editing[key]?{label:saved.label,reasonCode:saved.reasonCode,explanation:saved.explanation}:undefined} onReview={review=>void submit(row,output.id,{...review,itemId:row.decision.itemId})}/>
|
|
50
|
+
{message?.context===context&&message.key===key?<p role={message.error?'alert':'status'} className="text-sm">{message.text}</p>:null}
|
|
51
|
+
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Correct human label</button>:null}
|
|
52
|
+
<details><summary>Decision provenance and version</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
|
|
53
|
+
</article>;
|
|
54
|
+
})}
|
|
55
|
+
</section>)}
|
|
56
|
+
</section>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useCallback,useEffect,useRef,useState} from 'react';
|
|
4
|
+
import {useNewsroomCuration} from './newsroom-curation-provider';
|
|
5
|
+
import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
|
|
6
|
+
import {ReferencePriming} from './reference-priming';
|
|
7
|
+
import {Button} from './ui/button';
|
|
8
|
+
import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
|
|
9
|
+
import type {CurationSession} from '../lib/curation-client';
|
|
10
|
+
|
|
11
|
+
export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
|
|
12
|
+
const session=useNewsroomCuration();
|
|
13
|
+
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
|
|
17
|
+
const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
|
|
18
|
+
const [error,setError]=useState('');
|
|
19
|
+
const [loading,setLoading]=useState(true);
|
|
20
|
+
const [primingMessage,setPrimingMessage]=useState('');
|
|
21
|
+
const [steering,setSteering]=useState<string|null>(null);
|
|
22
|
+
const [rubric,setRubric]=useState('');
|
|
23
|
+
const [explanation,setExplanation]=useState('');
|
|
24
|
+
const [busy,setBusy]=useState(false);
|
|
25
|
+
const [trial,setTrial]=useState<{fingerprint:string;improved:boolean}|null>(null);
|
|
26
|
+
const [steeringMessage,setSteeringMessage]=useState('');
|
|
27
|
+
const steeringRequests=useRef<Record<string,string>>({});
|
|
28
|
+
const canReview=!disabled&&session.bootstrap.capabilities.includes('review');
|
|
29
|
+
const canSteer=!disabled&&session.bootstrap.capabilities.includes('steer');
|
|
30
|
+
const refresh=useCallback(async()=>{
|
|
31
|
+
setLoading(true);setError('');
|
|
32
|
+
try{setSnapshot(await session.snapshot());}
|
|
33
|
+
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
|
|
34
|
+
finally{setLoading(false);}
|
|
35
|
+
},[session]);
|
|
36
|
+
useEffect(()=>{
|
|
37
|
+
let active=true;
|
|
38
|
+
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}}).finally(()=>{if(active)setLoading(false);});
|
|
39
|
+
return()=>{active=false;};
|
|
40
|
+
},[session]);
|
|
41
|
+
async function steer(action:'evaluate'|'promote'){
|
|
42
|
+
if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
|
|
43
|
+
setBusy(true);setSteeringMessage('');
|
|
44
|
+
try{
|
|
45
|
+
const identity=JSON.stringify([action,steering,rubric.trim(),explanation.trim(),trial?.fingerprint??'',snapshot.status.classifiers[steering].cyclotron.fingerprint]);
|
|
46
|
+
const requestId=steeringRequests.current[identity]??=crypto.randomUUID();
|
|
47
|
+
const command=action==='evaluate'?{action,proposal:{rubric:rubric.trim()},explanation,requestId} as const:{action,trialFingerprint:trial?.fingerprint??'',explanation,requestId} as const;
|
|
48
|
+
const payload=publicationSteeringPayload(snapshot.status,steering,session.bootstrap.bindingRef,command);
|
|
49
|
+
const receipt=await session.publication.steer(payload);
|
|
50
|
+
const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
|
|
51
|
+
if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
|
|
52
|
+
setSteeringMessage(action==='promote'?'Evaluated proposal promoted.':result?.improved?'Evaluation complete: the candidate improved the measured result.':'Evaluation complete: no improvement was reported.');
|
|
53
|
+
await refresh();
|
|
54
|
+
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
|
|
55
|
+
finally{setBusy(false);}
|
|
56
|
+
}
|
|
57
|
+
return <section aria-label="Publication curation" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
|
|
58
|
+
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Publication curation</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh curation</Button></div>
|
|
59
|
+
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Remote workspace':'Embedded workspace'} · {session.bootstrap.scope.publicationId}</p>
|
|
60
|
+
{loading?<p role="status">Loading publication monitoring…</p>:null}
|
|
61
|
+
{error?<p role="alert">{error}</p>:null}
|
|
62
|
+
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
63
|
+
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
64
|
+
onSteer={canSteer&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
65
|
+
{steering?<form aria-label="Classifier proposal" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
66
|
+
<h4 className="font-semibold">Evaluate classifier proposal</h4>
|
|
67
|
+
<p className="text-sm">A proposal is evaluated before an explicit promotion. Existing classifications are not relabeled by opening this form.</p>
|
|
68
|
+
<label className="block text-sm">Proposed instructions<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
|
|
69
|
+
<label className="block text-sm">Why make this change?<textarea className="mt-1 block min-h-20 w-full rounded border bg-background p-2" required value={explanation} onChange={event=>setExplanation(event.target.value)}/></label>
|
|
70
|
+
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Evaluate proposal</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Promote evaluated proposal</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close proposal</Button></div>
|
|
71
|
+
{steeringMessage?<p role="status">{steeringMessage}</p>:null}
|
|
72
|
+
</form>:null}
|
|
73
|
+
{session.bootstrap.priming&&canReview?<ReferencePriming client={session.priming} listId={session.bootstrap.priming.listId} classifiers={session.bootstrap.priming.classifiers}
|
|
74
|
+
onAccepted={draft=>{setPrimingMessage(`Accepted ${draft.rows.length} example item${draft.rows.length===1?'':'s'} for this pinned publication. Operation remains available without priming.`);void refresh();}}/>:null}
|
|
75
|
+
{primingMessage?<p role="status">{primingMessage}</p>:null}
|
|
76
|
+
</section>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
5
|
+
import { Button } from "./ui/button";
|
|
6
|
+
|
|
7
|
+
/** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
|
|
8
|
+
export function ReferencePriming(props: PrimingPanelProps) {
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<section className="shrink-0 space-y-3" aria-label="Representative reference examples">
|
|
12
|
+
<Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
|
|
13
|
+
{open ? "Hide example priming" : "Curate reference examples (optional)"}
|
|
14
|
+
</Button>
|
|
15
|
+
{open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
|
|
16
|
+
</section>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {createPrimingClient} from 'cyclotron/priming-client';
|
|
2
|
+
import {assertPublicationScope,createPublicationClient,publicationScopeKey,type PublicationEnvelope,type PublicationRevisionRef,type PublicationScope} from './publication-cyclotron';
|
|
3
|
+
|
|
4
|
+
export type CurationBootstrap={
|
|
5
|
+
scope:PublicationScope;
|
|
6
|
+
mode:'embedded'|'remote';
|
|
7
|
+
priming:{listId:string;classifiers:{id:string;revision:number}[]}|null;
|
|
8
|
+
capabilities:readonly string[];
|
|
9
|
+
bindingRef:PublicationRevisionRef|null;
|
|
10
|
+
};
|
|
11
|
+
export type CurationRequest=<T>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<T>;
|
|
12
|
+
|
|
13
|
+
/** Same-origin only. The server verifies these expected pins against its session. */
|
|
14
|
+
export const requestCuration:CurationRequest=async<T,>(path:string,body:Record<string,unknown>,scope:Readonly<PublicationScope>)=>{
|
|
15
|
+
if(!path.startsWith('/api/newsroom/curation/'))throw new Error('Same-origin curation route required');
|
|
16
|
+
const controller=new AbortController();
|
|
17
|
+
const timer=setTimeout(()=>controller.abort(),15000);
|
|
18
|
+
let response:Response;
|
|
19
|
+
let result;
|
|
20
|
+
try{
|
|
21
|
+
response=await fetch(path,{method:'POST',credentials:'same-origin',signal:controller.signal,headers:{
|
|
22
|
+
'Content-Type':'application/json','X-Cyclotron-Account':scope.accountId,
|
|
23
|
+
'X-Papyrus-Publication':scope.publicationId,'X-Papyrus-Binding':scope.bindingRevision,
|
|
24
|
+
},body:JSON.stringify(body)});
|
|
25
|
+
result=await response.json().catch(()=>null);
|
|
26
|
+
}catch(error){
|
|
27
|
+
const timeout=controller.signal.aborted||(error instanceof Error&&error.name==='AbortError');
|
|
28
|
+
throw new Error(timeout?'Curation request timed out. Your selection is preserved. Check the server and saved history before retrying; do not resubmit automatically.':'Cannot reach the curation server. Your selection is preserved. Check the preview is running and saved history before retrying; do not resubmit automatically.');
|
|
29
|
+
}finally{clearTimeout(timer);}
|
|
30
|
+
if(response.status===401)throw new Error('Sign in again: the curation session expired. Your selection is preserved.');
|
|
31
|
+
if(response.status===403)throw new Error('Your session does not have permission for this curation action. Your selection is preserved.');
|
|
32
|
+
if(!response.ok||!result||result.errors)throw new Error(`Curation request failed (HTTP ${response.status}): ${result?.error??result?.errors?.[0]?.message??'invalid server response'}. Check saved history before retrying; your selection is preserved.`);
|
|
33
|
+
return result as T;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** One controller contract for both modes; no browser-selected service URL. */
|
|
37
|
+
export function createCurationSession(bootstrap:CurationBootstrap,request:CurationRequest,currentScope:()=>PublicationScope|null){
|
|
38
|
+
const pinned=Object.freeze({...bootstrap.scope});
|
|
39
|
+
const scopeKey=publicationScopeKey(pinned);
|
|
40
|
+
const assertCurrent=()=>{
|
|
41
|
+
const selected=currentScope();
|
|
42
|
+
if(!selected||publicationScopeKey(selected)!==scopeKey)throw new Error('Curation account selection changed; refresh context');
|
|
43
|
+
};
|
|
44
|
+
const publication=createPublicationClient(pinned,(operation,payload,scope)=>request<PublicationEnvelope&Record<string,unknown>>('/api/newsroom/curation/publication',{operation,payload},scope),currentScope);
|
|
45
|
+
const priming=createPrimingClient(async<T,>(query:string,variables:Record<string,unknown>):Promise<T>=>{
|
|
46
|
+
assertCurrent();
|
|
47
|
+
const result=await request<{data:T}>('/api/newsroom/curation/priming',{query,variables},pinned);
|
|
48
|
+
assertCurrent();
|
|
49
|
+
return result.data;
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
key:JSON.stringify([bootstrap.mode,scopeKey]),bootstrap,publication,priming,
|
|
53
|
+
async snapshot(){
|
|
54
|
+
const [status,history]=await Promise.all([publication.status(),publication.history()]);
|
|
55
|
+
assertCurrent();assertPublicationScope(status,history);
|
|
56
|
+
return {status,history};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type CurationSession=ReturnType<typeof createCurationSession>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {CyclotronDecision,CyclotronReview} from 'cyclotron/sdk-types';
|
|
2
|
+
import type {CyclotronStatus} from 'cyclotron/cyclotron-status';
|
|
3
|
+
import type {ReviewControlReview} from 'cyclotron/components/review-control';
|
|
4
|
+
|
|
5
|
+
export type PublicationScope={accountId:string;publicationId:string;bindingRevision:string};
|
|
6
|
+
export type PublicationEnvelope=PublicationScope&{contractVersion:'cyclotron-publication/v1';publicationId:string;bindingRevision:string;features?:{frozenContext:boolean;replayExport:boolean;steering:boolean};outputs:Array<{id:string;question:string;classes:string[];positiveLabel:string|null}>};
|
|
7
|
+
export type PublicationStatus=PublicationEnvelope&{classifiers:Record<string,CyclotronStatus>};
|
|
8
|
+
export type PublicationRevisionRef={listId:string;itemId:string;revision:number;contentHash:string};
|
|
9
|
+
export type PublicationContext={contextId:string;decisionId:string;publicationId:string;sourceRef:PublicationRevisionRef;bindingRef:PublicationRevisionRef;source:{id:string;values:Record<string,unknown>};binding:{values:{accountId:string;publicationId:string;revision:string;outputs:string[]}}};
|
|
10
|
+
export type PublicationHistoryRow={decision:CyclotronDecision;item:Record<string,unknown>;state?:string;reviews:Record<string,CyclotronReview>;context?:PublicationContext;historicalContext?:boolean;provenanceUnavailable?:boolean;feedbackReconciliationRequired?:boolean};
|
|
11
|
+
export type PublicationHistory=PublicationEnvelope&{history:PublicationHistoryRow[]};
|
|
12
|
+
export type PublicationReviewPayload={bindingRevision:string;decisionId:string;classifier:string;predictionVersion:number;predictionFingerprint:string;requestId:string;label:string;explanation:string;reasonCode:string|null;contextId?:string};
|
|
13
|
+
export type PublicationSteeringInput={requestId:string;explanation:string}&({action:'evaluate';proposal:Record<string,unknown>}|{action:'promote';trialFingerprint:string});
|
|
14
|
+
export type PublicationSteeringPayload=PublicationSteeringInput&{bindingRevision:string;bindingRef:PublicationRevisionRef;classifier:string;expectedFingerprint:string};
|
|
15
|
+
|
|
16
|
+
function ordered(value:unknown):unknown{
|
|
17
|
+
if(Array.isArray(value))return value.map(ordered);
|
|
18
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,ordered(item)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable cache/component identity; length-safe even when IDs contain delimiters. */
|
|
23
|
+
export function publicationScopeKey(scope:PublicationScope):string{
|
|
24
|
+
if([scope.accountId,scope.publicationId,scope.bindingRevision].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Complete publication account scope required');
|
|
25
|
+
return JSON.stringify([scope.accountId,scope.publicationId,scope.bindingRevision]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertPublicationScope(status:PublicationStatus,history:PublicationHistory){
|
|
29
|
+
if(status.contractVersion!=='cyclotron-publication/v1'||history.contractVersion!==status.contractVersion||publicationScopeKey(status)!==publicationScopeKey(history)||status.publicationId!==history.publicationId||status.bindingRevision!==history.bindingRevision)throw new Error('Publication snapshot binding mismatch');
|
|
30
|
+
const outputs=new Set(status.outputs.map(output=>output.id));
|
|
31
|
+
if(!outputs.size||outputs.size!==status.outputs.length||status.outputs.some(output=>status.classifiers[output.id]?.cyclotron.classifier!==output.id)||JSON.stringify(history.outputs)!==JSON.stringify(status.outputs))throw new Error('Publication snapshot is incomplete');
|
|
32
|
+
if(Object.keys(status.classifiers).some(id=>!outputs.has(id))||history.outputs.some(output=>!outputs.has(output.id))||history.history.some(row=>[...Object.keys(row.decision.classifiers),...Object.keys(row.reviews)].some(id=>!outputs.has(id))))throw new Error('Snapshot includes an output outside this publication');
|
|
33
|
+
if(history.history.some(row=>row.context&&(row.context.binding.values.accountId!==status.accountId||row.context.publicationId!==status.publicationId||row.context.decisionId!==row.decision.decisionId||JSON.stringify(ordered(row.context.source.values))!==JSON.stringify(ordered(row.item)))))throw new Error('Historical source context mismatch');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Explicit output target and incumbent pin; never a global review-rate mutation. */
|
|
37
|
+
export function publicationSteeringPayload(status:PublicationStatus,classifier:string,bindingRef:PublicationRevisionRef,command:PublicationSteeringInput):PublicationSteeringPayload{
|
|
38
|
+
if(!status.outputs.some(output=>output.id===classifier)||!status.classifiers[classifier])throw new Error('Steering output is outside this publication');
|
|
39
|
+
if(!command.requestId.trim()||!command.explanation.trim())throw new Error('Steering needs a stable identity and explicit explanation');
|
|
40
|
+
const fingerprint=status.classifiers[classifier].cyclotron.fingerprint;
|
|
41
|
+
if(!fingerprint)throw new Error('Active classifier fingerprint unavailable');
|
|
42
|
+
if(command.action==='evaluate'){
|
|
43
|
+
if(!Object.keys(command.proposal).length||Object.keys(command.proposal).some(key=>!['rubric','tasks','example_ids','dynamic_elements'].includes(key)))throw new Error('Native proposal fields required');
|
|
44
|
+
}else if(command.action!=='promote'||!command.trialFingerprint.trim())throw new Error('An explicitly evaluated trial is required');
|
|
45
|
+
return {...command,explanation:command.explanation.trim(),bindingRevision:status.bindingRevision,bindingRef,classifier,expectedFingerprint:fingerprint};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function publicationReviewPayload(binding:PublicationEnvelope,row:PublicationHistoryRow,classifier:string,review:ReviewControlReview,requestId:string):PublicationReviewPayload{
|
|
49
|
+
if(row.context&&row.context.binding.values.accountId!==binding.accountId)throw new Error('Review account context mismatch');
|
|
50
|
+
if(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired||row.context&&row.context.binding.values.revision!==binding.bindingRevision)throw new Error('Historical source or binding revision is read-only');
|
|
51
|
+
const output=binding.outputs.find(output=>output.id===classifier);const prediction=row.decision.classifiers[classifier];
|
|
52
|
+
if(!output||!prediction||review.decisionId!==row.decision.decisionId||review.itemId!==row.decision.itemId)throw new Error('Review does not match the displayed publication decision');
|
|
53
|
+
if(!review.label||!output.classes.includes(review.label))throw new Error('Choose an explicit classification label');
|
|
54
|
+
if(!requestId.trim())throw new Error('A stable review request identity is required');
|
|
55
|
+
return {bindingRevision:binding.bindingRevision,decisionId:row.decision.decisionId,classifier,predictionVersion:prediction.version,predictionFingerprint:prediction.fingerprint,requestId,label:review.label,explanation:review.explanation?.trim()??'',reasonCode:review.reasonCode,...(row.context?{contextId:row.context.contextId}:{})};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Host chooses an authenticated same-origin BFF; storage mode never changes UI semantics. */
|
|
59
|
+
export function createPublicationClient(scope:PublicationScope,request:(operation:string,payload:Record<string,unknown>,scope:Readonly<PublicationScope>)=>Promise<PublicationEnvelope&Record<string,unknown>>,currentScope:()=>PublicationScope|null){
|
|
60
|
+
const pinned=Object.freeze({accountId:scope.accountId,publicationId:scope.publicationId,bindingRevision:scope.bindingRevision});
|
|
61
|
+
const key=publicationScopeKey(pinned);
|
|
62
|
+
const assertSelected=()=>{const current=currentScope();if(!current||publicationScopeKey(current)!==key)throw new Error('Publication account selection changed; refresh context');};
|
|
63
|
+
const command=async(operation:string,payload:Record<string,unknown>)=>{
|
|
64
|
+
assertSelected();
|
|
65
|
+
if('bindingRevision' in payload&&payload.bindingRevision!==pinned.bindingRevision)throw new Error('Publication binding changed; refresh context');
|
|
66
|
+
const value=await request(operation,payload,pinned);
|
|
67
|
+
assertSelected();
|
|
68
|
+
if(value.contractVersion!=='cyclotron-publication/v1'||publicationScopeKey(value)!==key)throw new Error('Publication response contract mismatch');
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
return {scope:pinned,cacheKey:key,command,status:async()=>await command('status',{}) as PublicationStatus,history:async()=>await command('history',{}) as PublicationHistory,
|
|
72
|
+
review:async(payload:PublicationReviewPayload)=>command('review',payload),
|
|
73
|
+
steer:async(payload:PublicationSteeringPayload)=>command('steer',payload),replayRecords:async()=>command('replay',{})};
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
"./components/newsroom-assignments-view": "./components/newsroom-assignments-view.tsx",
|
|
89
89
|
"./components/newsroom-board-view": "./components/newsroom-board-view.tsx",
|
|
90
90
|
"./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
|
|
91
|
+
"./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
|
|
91
92
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
92
93
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
93
94
|
"./components/newsroom-organizer-shell": "./components/newsroom-organizer-shell.tsx",
|
|
@@ -98,11 +99,14 @@
|
|
|
98
99
|
"./components/presentation-footer": "./components/presentation-footer.tsx",
|
|
99
100
|
"./components/presentation-header": "./components/presentation-header.tsx",
|
|
100
101
|
"./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
|
|
102
|
+
"./components/publication-cyclotron-panel": "./components/publication-cyclotron-panel.tsx",
|
|
101
103
|
"./components/reader-auth-control": "./components/reader-auth-control.tsx",
|
|
102
104
|
"./components/reader-auth-state": "./components/reader-auth-state.ts",
|
|
103
105
|
"./components/reader-settings": "./components/reader-settings.ts",
|
|
104
106
|
"./components/reader-theme-control": "./components/reader-theme-control.tsx",
|
|
107
|
+
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
105
108
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
109
|
+
"./components/reference-priming": "./components/reference-priming.tsx",
|
|
106
110
|
"./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
|
|
107
111
|
"./components/reference-source-preview": "./components/reference-source-preview.tsx",
|
|
108
112
|
"./components/settings-page": "./components/settings-page.tsx",
|
|
@@ -153,6 +157,7 @@
|
|
|
153
157
|
"./lib/content-repository": "./lib/content-repository.ts",
|
|
154
158
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
155
159
|
"./lib/content-types": "./lib/content-types.ts",
|
|
160
|
+
"./lib/curation-client": "./lib/curation-client.ts",
|
|
156
161
|
"./lib/define-site": "./lib/define-site.ts",
|
|
157
162
|
"./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
|
|
158
163
|
"./lib/dev-sandbox-editor-auth": "./lib/dev-sandbox-editor-auth.ts",
|
|
@@ -201,6 +206,7 @@
|
|
|
201
206
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
202
207
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
203
208
|
"./lib/public-placeholder-config": "./lib/public-placeholder-config.ts",
|
|
209
|
+
"./lib/publication-cyclotron": "./lib/publication-cyclotron.ts",
|
|
204
210
|
"./lib/publication-items": "./lib/publication-items.ts",
|
|
205
211
|
"./lib/rate-limit-retry": "./lib/rate-limit-retry.ts",
|
|
206
212
|
"./lib/reader-base-path": "./lib/reader-base-path.ts",
|
|
@@ -319,7 +325,7 @@
|
|
|
319
325
|
"optional": true
|
|
320
326
|
}
|
|
321
327
|
},
|
|
322
|
-
"version": "1.0.0-next.
|
|
328
|
+
"version": "1.0.0-next.61",
|
|
323
329
|
"dependencies": {
|
|
324
330
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
325
331
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
|
@@ -342,7 +348,7 @@
|
|
|
342
348
|
"class-variance-authority": "^0.7.1",
|
|
343
349
|
"clsx": "^2.1.1",
|
|
344
350
|
"cmdk": "^1.1.1",
|
|
345
|
-
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.
|
|
351
|
+
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
|
|
346
352
|
"esbuild": "^0.28.2",
|
|
347
353
|
"gray-matter": "^4.0.3",
|
|
348
354
|
"gsap": "^3.15.0",
|
package/routes.manifest.json
CHANGED