@anthusai/papyrus 2.0.0-next.12 → 2.0.0-next.14
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 +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +8 -0
- package/amplify/data/schema.ts +9 -0
- package/amplify/functions/content-actions/handler.py +15 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +2 -2
- package/amplify/functions/editorial-checks/schema.ts +2 -2
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/publication/redirect-and-index.js +80 -0
- package/amplify/publication/redirect-rules.js +56 -0
- package/amplify/publication/redirect-rules.ts +73 -0
- package/amplify/publication/s3-projection.js +5 -1
- package/amplify/publication/s3-projection.ts +7 -1
- package/amplify/site-backend.js +9 -0
- package/amplify/site-backend.ts +9 -0
- package/components/editorial-checks-admin-panel.tsx +5 -5
- package/components/monitoring-overview-view.tsx +240 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-organizer.css +17 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +10 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +30 -21
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +13 -2
- package/lib/category-repository.ts +11 -9
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +2 -2
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +144 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/package.json +8 -2
- package/routes.manifest.json +1 -1
|
@@ -3,21 +3,31 @@
|
|
|
3
3
|
import {useCallback,useEffect,useRef,useState} from 'react';
|
|
4
4
|
import {useNewsroomCuration} from './newsroom-curation-provider';
|
|
5
5
|
import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
|
|
6
|
-
import {
|
|
6
|
+
import type {PrimingPanelProps} from 'cyclotron/components/priming-panel';
|
|
7
7
|
import {Button} from './ui/button';
|
|
8
8
|
import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
|
|
9
9
|
import type {CurationSession} from '../lib/curation-client';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** `refreshSignal` changes when references were just added, so the panel reloads by itself. */
|
|
12
|
+
export function ReferenceCurationControls({disabled=false,refreshSignal=0}:{disabled?:boolean;refreshSignal?:number}){
|
|
12
13
|
const session=useNewsroomCuration();
|
|
13
|
-
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
|
|
14
|
+
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled} refreshSignal={refreshSignal}/>:null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The import option of the Add a reference area, or null. Fail-closed: it exists
|
|
19
|
+
* only when a curation session supplies priming and the person may review.
|
|
20
|
+
*/
|
|
21
|
+
export function useReferenceImport(disabled=false):{panel:PrimingPanelProps;sessionKey:string}|null{
|
|
22
|
+
const session=useNewsroomCuration();
|
|
23
|
+
if(!session||disabled||!session.bootstrap.priming||!session.bootstrap.capabilities.includes('review'))return null;
|
|
24
|
+
return {sessionKey:session.key,panel:{client:session.priming,listId:session.bootstrap.priming.listId,classifiers:session.bootstrap.priming.classifiers}};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Controls({session,disabled,refreshSignal}:{session:CurationSession;disabled:boolean;refreshSignal:number}){
|
|
17
28
|
const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
|
|
18
29
|
const [error,setError]=useState('');
|
|
19
30
|
const [loading,setLoading]=useState(true);
|
|
20
|
-
const [primingMessage,setPrimingMessage]=useState('');
|
|
21
31
|
const [steering,setSteering]=useState<string|null>(null);
|
|
22
32
|
const [rubric,setRubric]=useState('');
|
|
23
33
|
const [explanation,setExplanation]=useState('');
|
|
@@ -30,14 +40,16 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
|
|
|
30
40
|
const refresh=useCallback(async()=>{
|
|
31
41
|
setLoading(true);setError('');
|
|
32
42
|
try{setSnapshot(await session.snapshot());}
|
|
33
|
-
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'
|
|
43
|
+
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}
|
|
34
44
|
finally{setLoading(false);}
|
|
35
45
|
},[session]);
|
|
36
46
|
useEffect(()=>{
|
|
37
47
|
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:'
|
|
48
|
+
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}}).finally(()=>{if(active)setLoading(false);});
|
|
39
49
|
return()=>{active=false;};
|
|
40
50
|
},[session]);
|
|
51
|
+
const lastSignal=useRef(refreshSignal);
|
|
52
|
+
useEffect(()=>{if(lastSignal.current!==refreshSignal){lastSignal.current=refreshSignal;void refresh();}},[refreshSignal,refresh]);
|
|
41
53
|
async function steer(action:'evaluate'|'promote'){
|
|
42
54
|
if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
|
|
43
55
|
setBusy(true);setSteeringMessage('');
|
|
@@ -49,29 +61,26 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
|
|
|
49
61
|
const receipt=await session.publication.steer(payload);
|
|
50
62
|
const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
|
|
51
63
|
if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
|
|
52
|
-
setSteeringMessage(action==='promote'?'
|
|
64
|
+
setSteeringMessage(action==='promote'?'The tested change is now in use.':result?.improved?'Test finished: the change improved the results.':'Test finished: no improvement was found.');
|
|
53
65
|
await refresh();
|
|
54
|
-
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'
|
|
66
|
+
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'The result is unknown; check before trying again.');}
|
|
55
67
|
finally{setBusy(false);}
|
|
56
68
|
}
|
|
57
|
-
return <section aria-label="
|
|
58
|
-
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">
|
|
59
|
-
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'
|
|
60
|
-
{loading?<p role="status">Loading
|
|
69
|
+
return <section aria-label="Suggestions for this publication" 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}>
|
|
70
|
+
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Suggestions for this publication</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh</Button></div>
|
|
71
|
+
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Shared review service':'This site'} · {session.bootstrap.scope.publicationId}</p>
|
|
72
|
+
{loading?<p role="status">Loading suggestions…</p>:null}
|
|
61
73
|
{error?<p role="alert">{error}</p>:null}
|
|
62
74
|
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
63
75
|
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
64
76
|
onSteer={canSteer&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
65
|
-
{steering?<form aria-label="
|
|
66
|
-
<h4 className="font-semibold">
|
|
67
|
-
<p className="text-sm">A
|
|
68
|
-
<label className="block text-sm">Proposed
|
|
77
|
+
{steering?<form aria-label="Rule change" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
78
|
+
<h4 className="font-semibold">Test a rule change</h4>
|
|
79
|
+
<p className="text-sm">A change is tested before it is adopted. Opening this form does not change any existing suggestion.</p>
|
|
80
|
+
<label className="block text-sm">Proposed rule<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
81
|
<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()}>
|
|
82
|
+
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Test change</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Adopt tested change</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close</Button></div>
|
|
71
83
|
{steeringMessage?<p role="status">{steeringMessage}</p>:null}
|
|
72
84
|
</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
85
|
</section>;
|
|
77
86
|
}
|
|
@@ -6,7 +6,7 @@ import { primingStateText, type PrimingRequestView } from "../lib/relevance-stat
|
|
|
6
6
|
export type PrimingFormInput = { url: string; label: string; explanation: string };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* The "Paste a URL" option of the Add a reference area. The page is read in the
|
|
10
10
|
* background and the outcome is shown beside each added reference.
|
|
11
11
|
*/
|
|
12
12
|
export function ReferenceCyclotronPriming({
|
|
@@ -36,7 +36,7 @@ export function ReferenceCyclotronPriming({
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
|
-
<
|
|
39
|
+
<div className="space-y-2 text-sm" data-reference-cyclotron-priming>
|
|
40
40
|
<form
|
|
41
41
|
className="flex flex-wrap items-end gap-3"
|
|
42
42
|
onSubmit={(event) => {
|
|
@@ -45,7 +45,7 @@ export function ReferenceCyclotronPriming({
|
|
|
45
45
|
}}
|
|
46
46
|
>
|
|
47
47
|
<label className="flex min-w-64 flex-1 flex-col gap-1">
|
|
48
|
-
<span className="text-xs text-muted-foreground">
|
|
48
|
+
<span className="text-xs text-muted-foreground">Web page address</span>
|
|
49
49
|
<input
|
|
50
50
|
aria-label="Reference URL"
|
|
51
51
|
onChange={(event) => setUrl(event.target.value)}
|
|
@@ -79,6 +79,6 @@ export function ReferenceCyclotronPriming({
|
|
|
79
79
|
))}
|
|
80
80
|
</ul>
|
|
81
81
|
) : null}
|
|
82
|
-
</
|
|
82
|
+
</div>
|
|
83
83
|
);
|
|
84
84
|
}
|
|
@@ -60,7 +60,7 @@ export function ReferenceSuggestionBlock({
|
|
|
60
60
|
<section aria-label="Suggestion" className="space-y-2 border-y border-foreground py-3" data-reference-suggestion={referenceLineageId}>
|
|
61
61
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggestion</h3>
|
|
62
62
|
<p className="text-sm text-foreground">
|
|
63
|
-
|
|
63
|
+
Papyrus suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
|
|
64
64
|
</p>
|
|
65
65
|
<p className="text-xs text-foreground" data-reference-suggestion-notice>
|
|
66
66
|
A suggestion never changes the status until an editor acts. This reference is {statusLabel.toLowerCase()}.
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
3
|
import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
5
|
-
import { Button } from "./ui/button";
|
|
6
4
|
|
|
7
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* The "Import a file" option of the Add a reference area. Papyrus supplies the
|
|
7
|
+
* transport and configuration; Cyclotron owns the preview and label controls.
|
|
8
|
+
* It is only rendered by `ReferenceAddEntry`, never as a section of its own.
|
|
9
|
+
*/
|
|
8
10
|
export function ReferencePriming(props: PrimingPanelProps) {
|
|
9
|
-
const [open, setOpen] = useState(false);
|
|
10
11
|
return (
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
<PrimingPanel
|
|
13
|
+
{...props}
|
|
14
|
+
renderItem={props.renderItem ?? ((item) => (
|
|
15
|
+
<div className="space-y-2">
|
|
16
|
+
<p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p>
|
|
17
|
+
<p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p>
|
|
18
|
+
</div>
|
|
19
|
+
))}
|
|
20
|
+
/>
|
|
17
21
|
);
|
|
18
22
|
}
|
|
@@ -40,7 +40,7 @@ export function SectionSuggestionBlock({
|
|
|
40
40
|
<section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
|
|
41
41
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
|
|
42
42
|
<p className="text-sm text-foreground">
|
|
43
|
-
|
|
43
|
+
Papyrus suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
|
|
44
44
|
{suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
|
|
45
45
|
</p>
|
|
46
46
|
{others.length ? (
|
|
@@ -14,6 +14,7 @@ import type { ReactNode, RefObject } from "react";
|
|
|
14
14
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
15
15
|
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
16
16
|
import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
|
|
17
|
+
import { MonitoringPanel } from "./monitoring-panel";
|
|
17
18
|
import { SignupEntriesPanel } from "./signup-entries-panel";
|
|
18
19
|
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
19
20
|
import { generateClient } from "aws-amplify/data";
|
|
@@ -247,7 +248,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
|
|
|
247
248
|
type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
|
|
248
249
|
type AssignmentDeskViewMode = "queue" | "budget";
|
|
249
250
|
type UserRoleAction = "grant" | "revoke";
|
|
250
|
-
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks";
|
|
251
|
+
type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "editorial-checks" | "monitoring";
|
|
251
252
|
export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
|
|
252
253
|
type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
|
|
253
254
|
type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
|
|
@@ -4327,6 +4328,15 @@ function AdministrationDeskView({
|
|
|
4327
4328
|
<strong>Editorial checks</strong>
|
|
4328
4329
|
<span>Policies the article editor checks drafts against</span>
|
|
4329
4330
|
</Link>
|
|
4331
|
+
<Link
|
|
4332
|
+
href={administrationPanelHref("monitoring", isDemo)}
|
|
4333
|
+
data-news-desk-admin-nav="monitoring"
|
|
4334
|
+
data-active={administrationPanel === "monitoring" || undefined}
|
|
4335
|
+
onClick={() => onPanelChange("monitoring")}
|
|
4336
|
+
>
|
|
4337
|
+
<strong>Monitoring</strong>
|
|
4338
|
+
<span>The automated research pipeline, read only</span>
|
|
4339
|
+
</Link>
|
|
4330
4340
|
{signupPluginEnabled() ? (
|
|
4331
4341
|
<Link
|
|
4332
4342
|
href={administrationPanelHref("signups", isDemo)}
|
|
@@ -4342,6 +4352,7 @@ function AdministrationDeskView({
|
|
|
4342
4352
|
<div className="news-desk-settings-panel">
|
|
4343
4353
|
{administrationPanel === "signups" ? <SignupEntriesPanel /> : null}
|
|
4344
4354
|
{administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
|
|
4355
|
+
{administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
|
|
4345
4356
|
{administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
|
|
4346
4357
|
{administrationPanel === "users" ? (
|
|
4347
4358
|
<AdministrationUsersPanel
|
|
@@ -12806,7 +12817,7 @@ function signupPluginEnabled(): boolean {
|
|
|
12806
12817
|
}
|
|
12807
12818
|
|
|
12808
12819
|
function normalizeAdministrationPanel(value: string | null | undefined): AdministrationPanel {
|
|
12809
|
-
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks") return value;
|
|
12820
|
+
if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
|
|
12810
12821
|
if (value === "signups" && signupPluginEnabled()) return value;
|
|
12811
12822
|
return "users";
|
|
12812
12823
|
}
|
|
@@ -200,7 +200,6 @@ export type ProcedureRunRecord = {
|
|
|
200
200
|
newsroomFeedKey?: string | null;
|
|
201
201
|
};
|
|
202
202
|
|
|
203
|
-
const DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-newsroom-sections.yml");
|
|
204
203
|
const NEWSROOM_SECTION_TYPES = new Set<NewsroomSectionType>(["canonical", "floating", "rotating"]);
|
|
205
204
|
type NewsroomSectionSeedRow = Omit<NewsroomSectionRecord, "sortOrder" | "enabled" | "enabledStatus" | "createdAt" | "updatedAt"> & {
|
|
206
205
|
enabled: boolean;
|
|
@@ -690,10 +689,10 @@ export type CategorySteeringDashboard = {
|
|
|
690
689
|
loadError?: string | null;
|
|
691
690
|
};
|
|
692
691
|
|
|
693
|
-
const DEFAULT_ANALYSIS_PROFILES_PATH = path.join(process.cwd(), "corpora", "papyrus-analysis-profiles.yml");
|
|
694
|
-
const DEFAULT_STEERING_CONFIG_PATH = path.join(process.cwd(), "corpora", "papyrus-steering.yml");
|
|
695
692
|
|
|
696
|
-
|
|
693
|
+
/** Analysis profiles the site keeps in a file; a site that configures none (data-driven sites) has none. No default file is looked up. */
|
|
694
|
+
export async function loadAnalysisProfileSummaries(filepath?: string): Promise<AnalysisProfileSummary[]> {
|
|
695
|
+
if (!filepath) return [];
|
|
697
696
|
try {
|
|
698
697
|
const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
|
|
699
698
|
schemaVersion?: number;
|
|
@@ -722,7 +721,9 @@ export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_P
|
|
|
722
721
|
}
|
|
723
722
|
}
|
|
724
723
|
|
|
725
|
-
|
|
724
|
+
/** Corpora a site lists in a steering file; no configured file means none. No default file is looked up. */
|
|
725
|
+
export async function loadConfiguredCorpusSummaries(filepath?: string): Promise<CategorySteeringCorpus[]> {
|
|
726
|
+
if (!filepath) return [];
|
|
726
727
|
try {
|
|
727
728
|
const parsed = YAML.parse(fs.readFileSync(filepath, "utf8")) as {
|
|
728
729
|
corpora?: Array<Record<string, unknown>>;
|
|
@@ -818,7 +819,8 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
|
|
|
818
819
|
});
|
|
819
820
|
}
|
|
820
821
|
|
|
821
|
-
function defaultNewsroomSections(importedAt: string, sectionsConfigPath
|
|
822
|
+
function defaultNewsroomSections(importedAt: string, sectionsConfigPath?: string): NewsroomSectionRecord[] {
|
|
823
|
+
if (!sectionsConfigPath) return [];
|
|
822
824
|
return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
|
|
823
825
|
...row,
|
|
824
826
|
enabledStatus: "enabled",
|
|
@@ -828,7 +830,7 @@ function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAUL
|
|
|
828
830
|
}));
|
|
829
831
|
}
|
|
830
832
|
|
|
831
|
-
function loadNewsroomSectionSeedRows(sectionsConfigPath
|
|
833
|
+
function loadNewsroomSectionSeedRows(sectionsConfigPath: string) {
|
|
832
834
|
const cached = newsroomSectionSeedRowsCache.get(sectionsConfigPath);
|
|
833
835
|
if (cached) return cached;
|
|
834
836
|
const parsed = YAML.parse(fs.readFileSync(sectionsConfigPath, "utf8")) as {
|
|
@@ -883,7 +885,7 @@ function normalizeNewsroomSectionSeedRow(
|
|
|
883
885
|
|
|
884
886
|
export function createDemoCategorySteeringDashboard(
|
|
885
887
|
brandId: SiteBrandId = resolveSiteBrandId(),
|
|
886
|
-
newsroomSectionsConfigPath
|
|
888
|
+
newsroomSectionsConfigPath?: string,
|
|
887
889
|
): CategorySteeringDashboard {
|
|
888
890
|
const importedAt = "2026-05-16T12:00:00.000Z";
|
|
889
891
|
const profile = getNewsroomDemoProfile(brandId);
|
|
@@ -1698,7 +1700,7 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1698
1700
|
question: "Should this candidate source become a reference for this publication?",
|
|
1699
1701
|
reviewControl: "thumbs",
|
|
1700
1702
|
reviewReason: "program",
|
|
1701
|
-
reviewDetail: "Every
|
|
1703
|
+
reviewDetail: "Every suggestion is reviewed while suggestions are getting started.",
|
|
1702
1704
|
version: 1,
|
|
1703
1705
|
}),
|
|
1704
1706
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Demo-mode editorial checks: no backend, a few stock phrases, canned rewrites.
|
|
3
|
-
* The shapes match
|
|
3
|
+
* The shapes match Humanus results so the editor, the preview marks and the
|
|
4
4
|
* rewrite flow behave as they do against the real backend.
|
|
5
5
|
*/
|
|
6
6
|
import type { EditorialCheckResult, EditorialFinding, EditorialSegment, RewriteResult, Span } from "./editorial-checks";
|
package/lib/editorial-checks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Editorial checks: the
|
|
3
|
-
* over them.
|
|
2
|
+
* Editorial checks: the Humanus result shapes the newsroom uses, and pure helpers
|
|
3
|
+
* over them. Humanus counts offsets in UTF-16 code units, which is exactly how
|
|
4
4
|
* JavaScript indexes strings, so spans apply to the body text as they are.
|
|
5
5
|
*/
|
|
6
6
|
|
package/lib/editorial-preview.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Places
|
|
2
|
+
* Places Humanus findings on the rendered Markus preview.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Humanus reports spans in the markup; the preview renders an IR tree with no
|
|
5
5
|
* character offsets. Both sides agree on the words a reader sees, so findings
|
|
6
|
-
* are placed by text: each
|
|
6
|
+
* are placed by text: each Humanus paragraph is matched, in document order, to
|
|
7
7
|
* the rendered prose block with the same words, and each finding's plain
|
|
8
8
|
* excerpt is located inside that block. Whitespace is ignored while matching,
|
|
9
|
-
* because rendering and
|
|
9
|
+
* because rendering and Humanus collapse it differently (citations, breaks).
|
|
10
10
|
*
|
|
11
11
|
* "Rendered text" is defined by `inlineLeaves`, which the preview renderer also
|
|
12
12
|
* uses, so offsets computed here are offsets the renderer can split on.
|
|
@@ -131,7 +131,7 @@ function blockKey(text: string): string {
|
|
|
131
131
|
return compact(text).value.replace(/[*_`~]/g, "");
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/** Match every
|
|
134
|
+
/** Match every Humanus paragraph to a rendered block and place its findings and sentences there. */
|
|
135
135
|
export function planPreview(
|
|
136
136
|
document: MarkusDocument,
|
|
137
137
|
result: EditorialCheckResult,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import type { MonitoringResult } from "./monitoring-overview";
|
|
5
|
+
|
|
6
|
+
type GraphQLResult = { data?: { monitoringOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the monitoring overview through the site's own authenticated API (user pool, editor or admin).
|
|
10
|
+
* The browser holds no table name, key or secret: the function behind the query does the read.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadMonitoringOverview(scope: "publication" | "all"): Promise<MonitoringResult> {
|
|
13
|
+
const client = generateClient({ authMode: "userPool" });
|
|
14
|
+
let result: GraphQLResult;
|
|
15
|
+
try {
|
|
16
|
+
result = (await client.graphql({
|
|
17
|
+
query: `query MonitoringOverview($scope: String) { monitoringOverview(scope: $scope) }`,
|
|
18
|
+
variables: { scope },
|
|
19
|
+
})) as GraphQLResult;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Amplify throws the response when AppSync returns errors.
|
|
22
|
+
result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
23
|
+
}
|
|
24
|
+
const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
|
|
25
|
+
if (refused) return { error: "forbidden" };
|
|
26
|
+
if (result.errors?.length || result.data?.monitoringOverview === undefined || result.data.monitoringOverview === null) return { state: "unavailable" };
|
|
27
|
+
const raw = result.data.monitoringOverview;
|
|
28
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as MonitoringResult;
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MonitoringOverview } from "./monitoring-overview";
|
|
2
|
+
|
|
3
|
+
/** A realistic overview for tests and the `?demo=1` newsroom. Public Hacker News titles only. */
|
|
4
|
+
export const FIXTURE_MONITORING_OVERVIEW: MonitoringOverview = {
|
|
5
|
+
state: "ready",
|
|
6
|
+
asOf: "2026-10-21T02:40:00Z",
|
|
7
|
+
scope: { mode: "publication", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
|
|
8
|
+
feeds: [{ id: "hn", sourceUrl: "https://news.ycombinator.com/rss", intervalSeconds: 900, limit: 30, enabled: true,
|
|
9
|
+
lastStatus: "complete", lastFinishedAt: "2026-10-21T02:33:00Z", nextDueAt: "2026-10-21T02:50:00Z" }],
|
|
10
|
+
runs: [
|
|
11
|
+
{ runId: "r3", startedAt: "2026-10-21T02:33:00.000000Z", status: "complete", new: 2, revised: 1, unchanged: 27, itemCount: 30, errorCount: 0, errors: [] },
|
|
12
|
+
{ runId: "r1", startedAt: "2026-10-21T02:18:00.000000Z", status: "complete", new: 0, revised: 0, unchanged: 30, itemCount: 30, errorCount: 0, errors: [] },
|
|
13
|
+
{ runId: "r0", startedAt: "2026-10-21T02:03:00.000000Z", status: "partial", new: 1, revised: 0, unchanged: 28, itemCount: 30, errorCount: 1, errors: ["ConnectionError: timeout"] },
|
|
14
|
+
],
|
|
15
|
+
routerRuns: [{ runId: "q2", startedAt: "2026-10-21T02:37:00.000000Z", mode: "live", status: "stopped", stoppedBy: "request_ceiling_reached",
|
|
16
|
+
counts: { examined: 3, decided: 1, delivered: 1, deliveryFailed: 1, routed: 1 }, errorCount: 0 }],
|
|
17
|
+
router: { mode: "live", feedback: true, enabled: true, maxItemsPerRun: 5 },
|
|
18
|
+
usage: { day: "2026-10-21", decisionsToday: 2, decisionsTotal: 3, ceilings: { decisionsTotal: 300, decisionsPerDay: 100, items: 300 } },
|
|
19
|
+
hiddenNotRelevant: 1,
|
|
20
|
+
items: [
|
|
21
|
+
{ sourceId: "104", revision: 1, title: "Fresh Show HN", url: "https://example.org/104", discussionUrl: "https://news.ycombinator.com/item?id=104",
|
|
22
|
+
observedAt: "2026-10-21T02:30:00.000000Z", routing: "collected", routingNote: null, decidedAt: null,
|
|
23
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
|
|
24
|
+
{ sourceId: "105", revision: 1, title: "Broken request", url: "https://example.org/105", discussionUrl: "https://news.ycombinator.com/item?id=105",
|
|
25
|
+
observedAt: "2026-10-21T01:30:00.000000Z", routing: "error", routingNote: "invalid request: bad title", decidedAt: null,
|
|
26
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: null, label: null, confidence: null, delivery: { state: null } }] },
|
|
27
|
+
{ sourceId: "101", revision: 1, title: "Quantum paper on arXiv", url: "https://example.org/101", discussionUrl: "https://news.ycombinator.com/item?id=101",
|
|
28
|
+
observedAt: "2026-10-21T00:10:00.000000Z", routing: "routed", routingNote: null, decidedAt: "2026-10-21T01:00:00Z",
|
|
29
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.91,
|
|
30
|
+
delivery: { state: "delivered", referenceId: "ref-1", referenceLineageId: "lin-1", deliveredAt: "2026-10-21T01:00:05Z" } }] },
|
|
31
|
+
{ sourceId: "103", revision: 1, title: "Phishing kit leaks", url: "https://example.org/103", discussionUrl: "https://news.ycombinator.com/item?id=103",
|
|
32
|
+
observedAt: "2026-10-20T23:00:00.000000Z", routing: "decided", routingNote: null, decidedAt: "2026-10-20T23:10:00Z",
|
|
33
|
+
relevance: [{ publicationId: "desk-a", site: "a.example.test", relevant: true, label: "include", confidence: 0.7,
|
|
34
|
+
delivery: { state: "failed", error: "ClientError: throttled", attempts: 2 } }] },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const FIXTURE_ALL_PIPELINE_OVERVIEW: MonitoringOverview = {
|
|
39
|
+
...FIXTURE_MONITORING_OVERVIEW,
|
|
40
|
+
scope: { mode: "all", publicationId: "desk-a", site: "a.example.test", canViewAll: true },
|
|
41
|
+
hiddenNotRelevant: 0,
|
|
42
|
+
items: FIXTURE_MONITORING_OVERVIEW.items.map((item) => item.sourceId !== "101" ? item : {
|
|
43
|
+
...item,
|
|
44
|
+
relevance: [
|
|
45
|
+
...item.relevance,
|
|
46
|
+
{ publicationId: "desk-b", site: "b.example.test", relevant: false, label: "exclude", confidence: 0.8, delivery: { state: "not_applicable" } },
|
|
47
|
+
{ publicationId: "desk-c", site: "desk-c", relevant: true, label: "include", confidence: null,
|
|
48
|
+
delivery: { state: "delivered", referenceId: "ref-2", referenceLineageId: "lin-2" } },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Shapes and wording for the newsroom Monitoring page. The function in src/papyrus_content/monitoring_view.py produces them. */
|
|
2
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
3
|
+
|
|
4
|
+
export type MonitoringDeliveryState = "delivered" | "failed" | "pending" | "not_applicable" | null;
|
|
5
|
+
|
|
6
|
+
export type MonitoringRelevance = {
|
|
7
|
+
publicationId: string;
|
|
8
|
+
site: string | null;
|
|
9
|
+
relevant: boolean | null;
|
|
10
|
+
label: string | null;
|
|
11
|
+
confidence: number | null;
|
|
12
|
+
delivery: {
|
|
13
|
+
state: MonitoringDeliveryState;
|
|
14
|
+
referenceId?: string | null;
|
|
15
|
+
referenceLineageId?: string | null;
|
|
16
|
+
deliveredAt?: string | null;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
attempts?: number | null;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MonitoringItem = {
|
|
23
|
+
sourceId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
title: string;
|
|
26
|
+
url: string | null;
|
|
27
|
+
discussionUrl: string | null;
|
|
28
|
+
observedAt: string | null;
|
|
29
|
+
routing: string;
|
|
30
|
+
routingNote: string | null;
|
|
31
|
+
decidedAt: string | null;
|
|
32
|
+
relevance: MonitoringRelevance[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MonitoringRun = {
|
|
36
|
+
runId: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
status: string;
|
|
39
|
+
new: number;
|
|
40
|
+
revised: number;
|
|
41
|
+
unchanged: number;
|
|
42
|
+
itemCount: number | null;
|
|
43
|
+
errorCount: number;
|
|
44
|
+
errors: (string | null)[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type MonitoringRouterRun = {
|
|
48
|
+
runId: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
mode: string;
|
|
51
|
+
status: string;
|
|
52
|
+
stoppedBy: string | null;
|
|
53
|
+
counts: Record<string, number>;
|
|
54
|
+
errorCount: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type MonitoringFeed = {
|
|
58
|
+
id: string;
|
|
59
|
+
sourceUrl: string | null;
|
|
60
|
+
intervalSeconds: number | null;
|
|
61
|
+
limit: number | null;
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
lastStatus: string | null;
|
|
64
|
+
lastFinishedAt: string | null;
|
|
65
|
+
nextDueAt: string | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type MonitoringOverview = {
|
|
69
|
+
state: "ready" | "empty";
|
|
70
|
+
asOf: string;
|
|
71
|
+
notice?: "publication_not_routed";
|
|
72
|
+
scope: { mode: "publication" | "all"; publicationId: string | null; site: string | null; canViewAll: boolean };
|
|
73
|
+
feeds: MonitoringFeed[];
|
|
74
|
+
runs: MonitoringRun[];
|
|
75
|
+
routerRuns: MonitoringRouterRun[];
|
|
76
|
+
router: { mode: string; feedback: boolean; enabled: boolean; maxItemsPerRun: number | null } | null;
|
|
77
|
+
usage: {
|
|
78
|
+
day: string;
|
|
79
|
+
decisionsToday: number;
|
|
80
|
+
decisionsTotal: number;
|
|
81
|
+
ceilings: { decisionsTotal?: number; decisionsPerDay?: number; items?: number } | null;
|
|
82
|
+
} | null;
|
|
83
|
+
items: MonitoringItem[];
|
|
84
|
+
hiddenNotRelevant: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type MonitoringResult =
|
|
88
|
+
| MonitoringOverview
|
|
89
|
+
| { state: "not_configured" }
|
|
90
|
+
| { state: "unavailable" }
|
|
91
|
+
| { error: "forbidden" | "bad_request" };
|
|
92
|
+
|
|
93
|
+
const ROUTING_WORDS: Record<string, string> = {
|
|
94
|
+
collected: "Collected, waiting for the router",
|
|
95
|
+
"dry-run-validated": "Checked in a dry run, not sent",
|
|
96
|
+
decided: "Judged, delivery not finished",
|
|
97
|
+
routed: "Routed",
|
|
98
|
+
refused: "Refused by the shared Cyclotron",
|
|
99
|
+
uncertain: "Uncertain, needs a person",
|
|
100
|
+
error: "Error",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function routingLabel(routing: string): string {
|
|
104
|
+
return ROUTING_WORDS[routing] ?? routing;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fixed-format UTC time so the page reads the same everywhere. */
|
|
108
|
+
export function formatUtc(iso: string | null | undefined): string {
|
|
109
|
+
if (!iso) return "never";
|
|
110
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
|
|
111
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function confidenceText(confidence: number | null): string {
|
|
115
|
+
return confidence === null ? "confidence not reported" : `${Math.round(confidence * 100)}% confidence`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function usageText(used: number, ceiling: number | undefined): string {
|
|
119
|
+
return ceiling === undefined ? `${used} used, no ceiling set` : `${used} of ${ceiling}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function everyText(seconds: number | null): string {
|
|
123
|
+
if (!seconds) return "on a schedule";
|
|
124
|
+
return seconds % 3600 === 0 ? `every ${seconds / 3600} h` : seconds % 60 === 0 ? `every ${seconds / 60} min` : `every ${seconds} s`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function referenceHref(lineageId: string, demo?: boolean): string {
|
|
128
|
+
return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function describeRelevance(entry: MonitoringRelevance): string {
|
|
132
|
+
if (entry.relevant === null) return "Not judged yet";
|
|
133
|
+
return entry.relevant ? "Relevant" : "Not relevant";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function describeDelivery(entry: MonitoringRelevance): string {
|
|
137
|
+
switch (entry.delivery.state) {
|
|
138
|
+
case "delivered": return "In the References queue";
|
|
139
|
+
case "failed": return "Not in the References queue: delivery failed";
|
|
140
|
+
case "pending": return "Not in the References queue yet";
|
|
141
|
+
case "not_applicable": return "Nothing to deliver";
|
|
142
|
+
default: return "";
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/relevance-status.ts
CHANGED
|
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
52
52
|
},
|
|
53
53
|
calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
|
|
54
54
|
reviewRate: {
|
|
55
|
-
state: "onboarding", rate: 1, reason: "
|
|
55
|
+
state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
|
|
56
56
|
auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
|
|
57
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
|
|
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 gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
|
|
59
59
|
expectedReviewsPerWeek: 1,
|
|
60
60
|
},
|
|
61
61
|
lastChange: null,
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
|
|
|
9
9
|
return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
|
|
13
|
+
function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
|
|
14
|
+
return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
17
|
+
export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
18
|
+
return optionalBrandConfigPath(brand.steeringConfigPath);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
21
|
+
export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
22
|
+
return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
26
|
+
return optionalBrandConfigPath(brand.analysisProfilesPath);
|
|
22
27
|
}
|