@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.
Files changed (49) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +8 -0
  4. package/amplify/data/schema.ts +9 -0
  5. package/amplify/functions/content-actions/handler.py +15 -0
  6. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  7. package/amplify/functions/editorial-checks/resource.js +3 -3
  8. package/amplify/functions/editorial-checks/resource.ts +3 -3
  9. package/amplify/functions/editorial-checks/schema.js +2 -2
  10. package/amplify/functions/editorial-checks/schema.ts +2 -2
  11. package/amplify/functions/monitoring-view/handler.py +11 -0
  12. package/amplify/functions/monitoring-view/resource.js +24 -0
  13. package/amplify/functions/monitoring-view/resource.ts +34 -0
  14. package/amplify/functions/monitoring-view/schema.js +16 -0
  15. package/amplify/functions/monitoring-view/schema.ts +18 -0
  16. package/amplify/functions/monitoring-view/wire.js +37 -0
  17. package/amplify/functions/monitoring-view/wire.ts +47 -0
  18. package/amplify/publication/redirect-and-index.js +80 -0
  19. package/amplify/publication/redirect-rules.js +56 -0
  20. package/amplify/publication/redirect-rules.ts +73 -0
  21. package/amplify/publication/s3-projection.js +5 -1
  22. package/amplify/publication/s3-projection.ts +7 -1
  23. package/amplify/site-backend.js +9 -0
  24. package/amplify/site-backend.ts +9 -0
  25. package/components/editorial-checks-admin-panel.tsx +5 -5
  26. package/components/monitoring-overview-view.tsx +240 -0
  27. package/components/monitoring-panel.tsx +40 -0
  28. package/components/newsroom-organizer.css +17 -0
  29. package/components/newsroom-references-view.tsx +14 -14
  30. package/components/publication-cyclotron-panel.tsx +10 -10
  31. package/components/reference-add-entry.tsx +75 -0
  32. package/components/reference-curation-controls.tsx +30 -21
  33. package/components/reference-cyclotron-priming.tsx +4 -4
  34. package/components/reference-cyclotron-suggestion.tsx +1 -1
  35. package/components/reference-priming.tsx +14 -10
  36. package/components/reference-section-suggestion.tsx +1 -1
  37. package/components/topic-steering-workspace.tsx +13 -2
  38. package/lib/category-repository.ts +11 -9
  39. package/lib/editorial-checks-demo.ts +1 -1
  40. package/lib/editorial-checks.ts +2 -2
  41. package/lib/editorial-preview.ts +5 -5
  42. package/lib/monitoring-client.ts +29 -0
  43. package/lib/monitoring-overview-fixture.ts +51 -0
  44. package/lib/monitoring-overview.ts +144 -0
  45. package/lib/relevance-status.ts +2 -2
  46. package/lib/site-brand-tenant.ts +11 -6
  47. package/lib/site-brand.ts +6 -3
  48. package/package.json +8 -2
  49. 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 {ReferencePriming} from './reference-priming';
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
- export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
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
- function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
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:'Curation status unavailable');}
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:'Curation status unavailable');}}).finally(()=>{if(active)setLoading(false);});
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'?'Evaluated proposal promoted.':result?.improved?'Evaluation complete: the candidate improved the measured result.':'Evaluation complete: no improvement was reported.');
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:'Proposal outcome unavailable; check before retrying');}
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="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}
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="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>
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()}>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>
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
- * A compact "Add a reference" form for a pasted URL. The page is read in the
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
- <section aria-label="Add a reference" className="space-y-2 text-sm" data-reference-cyclotron-priming>
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">Add a reference by URL</span>
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
- </section>
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
- The cyclotron suggests <span className="font-semibold">{decision.label}</span> ({suggestionConfidenceWords(decision)}).
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
- /** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
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
- <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 references" : "Add example references (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>
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
- The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
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
- export async function loadAnalysisProfileSummaries(filepath = DEFAULT_ANALYSIS_PROFILES_PATH): Promise<AnalysisProfileSummary[]> {
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
- export async function loadConfiguredCorpusSummaries(filepath = DEFAULT_STEERING_CONFIG_PATH): Promise<CategorySteeringCorpus[]> {
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 = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
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 = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH) {
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 = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH,
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 decision is reviewed while the cyclotron is onboarding.",
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 Limatus results so the editor, the preview marks and the
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";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Editorial checks: the Limatus result shapes the newsroom uses, and pure helpers
3
- * over them. Limatus counts offsets in UTF-16 code units, which is exactly how
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
 
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Places Limatus findings on the rendered Markus preview.
2
+ * Places Humanus findings on the rendered Markus preview.
3
3
  *
4
- * Limatus reports spans in the markup; the preview renders an IR tree with no
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 Limatus paragraph is matched, in document order, to
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 Limatus collapse it differently (citations, breaks).
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 Limatus paragraph to a rendered block and place its findings and sentences there. */
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
+ }
@@ -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: "Onboarding: every decision is reviewed until the cyclotron earns a lower rate.",
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 calibration gap under 5 points. Next check in 99 decisions.",
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,
@@ -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
- export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
13
- return resolveBrandConfigPath(brand.steeringConfigPath);
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 getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
17
- return resolveBrandConfigPath(brand.newsroomSectionsConfigPath);
17
+ export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
18
+ return optionalBrandConfigPath(brand.steeringConfigPath);
18
19
  }
19
20
 
20
- export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string {
21
- return resolveBrandConfigPath(brand.analysisProfilesPath);
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
  }