@anthusai/papyrus 1.4.0-next.8 → 1.4.0-next.9

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.
@@ -3628,6 +3628,18 @@ export async function loadCyclotronShadowAgreement(cyclotronId: string): Promise
3628
3628
  return latestShadowAgreement(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
3629
3629
  }
3630
3630
 
3631
+ /** The site cyclotron's latest per-Section agreement payload, as the Section sweep recorded it. */
3632
+ export async function loadSiteSectionAgreement(cyclotronId: string): Promise<import("../lib/site-section-suggestions").SiteSectionAgreement | null> {
3633
+ const { shadowAgreementPayloadId } = await import("../lib/cyclotron-suggestions");
3634
+ const { parseSiteSectionAgreement } = await import("../lib/site-section-suggestions");
3635
+ const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", shadowAgreementPayloadId(cyclotronId), ["raw_payload"]);
3636
+ const parsed = payloads
3637
+ .map((payload) => ({ agreement: parseSiteSectionAgreement(payload.json), updatedAt: payload.attachment.updatedAt }))
3638
+ .filter((entry) => entry.agreement !== null)
3639
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
3640
+ return parsed.length ? parsed[0].agreement : null;
3641
+ }
3642
+
3631
3643
  /** Ask the next decide-relevance sweep to set or clear a manual review rate. */
3632
3644
  export async function requestCyclotronReviewRate(
3633
3645
  cyclotronId: string,
@@ -5,8 +5,8 @@ import Link from "next/link";
5
5
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
6
  import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
7
  import "@kanbus/ui/styles/kanban.css";
8
+ import "@kanbus/ui/styles/newsprint.css";
8
9
  import "./newsroom-newsprint-board.css";
9
- import { BoardCardExcerpts } from "./board-card-excerpts";
10
10
  import { PlusIcon } from "lucide-react";
11
11
  import { newsroomHref } from "../lib/newsroom-base-path";
12
12
  import {
@@ -81,7 +81,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
81
81
  const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
82
82
  const generation = useRef(0);
83
83
  const boardBodyRef = useRef<HTMLDivElement | null>(null);
84
- const [boardBodyElement, setBoardBodyElement] = useState<HTMLDivElement | null>(null);
85
84
  const [typeFilter, setTypeFilter] = useState<string | null>("all");
86
85
  const showsContentItems = subject !== "assignment";
87
86
  const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
@@ -173,11 +172,10 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
173
172
  </Link>
174
173
  )}
175
174
  </>}>
176
- <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
175
+ <div className="organizer-native-board papyrus-newsprint-board" data-kanbus-theme="newsprint" data-color-mode={theme}>
177
176
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
178
177
  {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
179
- <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={node => { boardBodyRef.current = node; setBoardBodyElement(node); }} onScrollCapture={handleBoardScroll}>
180
- <BoardCardExcerpts excerpts={issues.flatMap(item => item.kind === "contentItem" && item.excerpt ? [{ issueId: item.id, text: item.excerpt }] : [])} scope={boardBodyElement} />
178
+ <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
181
179
  <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
182
180
  motion={lowMotion ? STATIC_MOTION : undefined}
183
181
  collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
@@ -1,56 +1,7 @@
1
1
  .papyrus-newsprint-board {
2
- --background: var(--org-paper);
3
- --card: var(--org-paper);
4
- --frame: var(--org-paper);
5
- --column: var(--org-paper);
6
- --border: var(--org-line);
7
- --text-foreground: var(--org-ink);
8
- --text-muted: var(--org-muted);
9
- --text-selected: var(--org-ink);
10
- --font-body: Newsroom UI, system-ui, sans-serif;
11
- --kb-column-width: 245px;
12
- --kb-column-radius: 0;
13
- --kb-card-radius: 0;
14
- --kb-card-border-width: 0;
15
- --kb-card-shadow: none;
16
- --kb-card-selected-shadow: none;
17
- --kb-board-gap: 18px;
18
- color: var(--org-ink);
19
- background: transparent;
2
+ --kb-newsprint-paper: var(--org-paper);
3
+ --kb-newsprint-ink: var(--org-ink);
4
+ --kb-newsprint-line: var(--org-line);
5
+ --kb-newsprint-display-font: Newsroom Display, Georgia, serif;
6
+ --kb-newsprint-body-font: Newsroom UI, system-ui, sans-serif;
20
7
  }
21
- .papyrus-newsprint-board .kb-grid { height: 100%; min-height: 0; scrollbar-width: thin; }
22
- .papyrus-newsprint-board .kb-column { border-left: 1px solid var(--org-line); padding-left: 10px; }
23
- .papyrus-newsprint-board .kb-column:first-child { border-left: 0; padding-left: 0; }
24
- .papyrus-newsprint-board .kb-column-header {
25
- border-bottom: 2px solid var(--org-ink);
26
- padding: 5px 0;
27
- font: 700 12px Newsroom Display, Georgia, serif;
28
- text-transform: uppercase;
29
- letter-spacing: .6px;
30
- min-height: 32px;
31
- }
32
- .papyrus-newsprint-board .issue-card {
33
- --issue-accent: var(--org-paper) !important;
34
- --issue-accent-muted: var(--muted) !important;
35
- --issue-accent-foreground: var(--org-ink) !important;
36
- --issue-priority-bg: var(--org-paper) !important;
37
- background: transparent !important;
38
- border-radius: 0;
39
- border: 0 !important;
40
- box-shadow: none !important;
41
- }
42
- .papyrus-newsprint-board .issue-card + .issue-card { border-top: 1px solid var(--org-line) !important; }
43
- .papyrus-newsprint-board .issue-accent-bar { border-bottom: 0; background: var(--org-paper) !important; color: var(--org-ink) !important; }
44
- .papyrus-newsprint-board .issue-accent-id { font: 400 9px Newsroom UI, system-ui, sans-serif; letter-spacing: .8px; white-space: nowrap; color: var(--org-ink) !important; }
45
- .papyrus-newsprint-board .issue-accent-priority { border-radius: 0; color: var(--org-muted) !important; background: transparent !important; font-size: 8px; }
46
- .papyrus-newsprint-board .issue-card h3 { font: 700 19px/1.2 Newsroom Display, Georgia, serif !important; letter-spacing: -.2px; }
47
- .papyrus-newsprint-board .issue-card[data-selected=true] { outline: 2px solid var(--org-ink); outline-offset: -2px; }
48
- .papyrus-newsprint-board .issue-card[data-selected=true] h3 { color: var(--org-ink) !important; }
49
- .papyrus-newsprint-board .kb-issue-detail { background: var(--org-paper); border: 1px solid var(--org-line); border-radius: 0; box-shadow: none; }
50
- .papyrus-newsprint-board .kb-issue-detail h2 { font: 700 28px/1.2 Newsroom Display, Georgia, serif; border-bottom: 3px double var(--org-ink); padding-bottom: 12px; }
51
- .papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
52
- .papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
53
- .papyrus-newsprint-board .issue-card h3 { margin: 0; }
54
- .board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
55
- .board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
56
- .board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
@@ -57,6 +57,11 @@ import {
57
57
  ReferenceSuggestionRowLine,
58
58
  } from "./reference-cyclotron-suggestion";
59
59
  import { ReferenceLiveAgreement } from "./reference-live-agreement";
60
+ import { SectionPicker, SectionSuggestionBlock, type SectionChoice } from "./reference-section-suggestion";
61
+ import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-section-suggestions";
62
+
63
+ /** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
64
+ export type ReferenceReviewOptions = { sectionId?: string | null };
60
65
  import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
61
66
  import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
62
67
  import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
@@ -91,9 +96,15 @@ type NewsroomReferencesViewProps = {
91
96
  /** The live-agreement payload; the strip is hidden without it. */
92
97
  shadowAgreement?: ShadowAgreement | null;
93
98
  disabled?: boolean;
99
+ /** The enabled newsroom Sections an accepted reference can be placed in. */
100
+ sections?: SectionChoice[];
101
+ /** The site cyclotron's suggested Section per reference lineage. Advisory. */
102
+ sectionSuggestions?: Map<string, SectionSuggestion>;
103
+ /** How often the Section suggestions matched later editor decisions. */
104
+ sectionAgreement?: SiteSectionAgreement | null;
94
105
  onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
95
106
  initialReferenceLineageId?: string | null;
96
- onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
107
+ onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
97
108
  referenceAttachments?: ReferenceAttachmentRecord[];
98
109
  references: ReferenceRecord[];
99
110
  };
@@ -243,6 +254,9 @@ function ReferenceDetailPanel({
243
254
  previewAttachments,
244
255
  reference,
245
256
  onClose,
257
+ sectionAgreement = null,
258
+ sectionSuggestion,
259
+ sections = [],
246
260
  }: {
247
261
  countsAsTrainingLabel?: boolean;
248
262
  delegated?: DelegatedCuration;
@@ -250,12 +264,17 @@ function ReferenceDetailPanel({
250
264
  demo?: boolean;
251
265
  disabled?: boolean;
252
266
  onRelevanceReview?: (curation: RelevanceCuration) => void;
253
- onReview: (action: ReferenceCurationAction) => void;
267
+ onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
268
+ sectionAgreement?: SiteSectionAgreement | null;
269
+ sectionSuggestion?: SectionSuggestion;
270
+ sections?: SectionChoice[];
254
271
  previewAttachments: ReferenceAttachmentRecord[];
255
272
  reference: ReferenceRecord;
256
273
  onClose?: () => void;
257
274
  }) {
258
275
  const lineageId = referenceLineageId(reference);
276
+ const [chosenSectionId, setChosenSectionId] = useState<string | null>(null);
277
+ useEffect(() => { setChosenSectionId(null); }, [lineageId]);
259
278
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
260
279
  const url = referenceUrl(reference);
261
280
  const acceptDisabled = disabled || status === "accepted";
@@ -307,6 +326,20 @@ function ReferenceDetailPanel({
307
326
  ) : null}
308
327
  </div>
309
328
  ) : null}
329
+ {status === "pending" && sectionSuggestion ? (
330
+ <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} />
331
+ ) : null}
332
+ {status === "pending" && sections.length ? (
333
+ <SectionPicker
334
+ agreement={sectionAgreement}
335
+ chosenSectionId={chosenSectionId}
336
+ disabled={disabled}
337
+ onChoose={setChosenSectionId}
338
+ referenceLineageId={lineageId}
339
+ sections={sections}
340
+ suggestedSectionId={sectionSuggestion?.sectionId ?? null}
341
+ />
342
+ ) : null}
310
343
  {delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
311
344
 
312
345
  <div
@@ -319,7 +352,7 @@ function ReferenceDetailPanel({
319
352
  aria-pressed={status === "accepted"}
320
353
  data-news-desk-reference-accept
321
354
  disabled={acceptDisabled}
322
- onClick={() => onReview("accept")}
355
+ onClick={() => onReview("accept", chosenSectionId ? { sectionId: chosenSectionId } : undefined)}
323
356
  size="sm"
324
357
  type="button"
325
358
  variant={status === "accepted" ? "default" : "outline"}
@@ -443,6 +476,9 @@ export function NewsroomReferencesView({
443
476
  demo = false,
444
477
  disabled = false,
445
478
  onRelevanceReview,
479
+ sections = [],
480
+ sectionSuggestions,
481
+ sectionAgreement = null,
446
482
  initialReferenceLineageId = null,
447
483
  onReview,
448
484
  referenceAttachments = [],
@@ -531,9 +567,9 @@ export function NewsroomReferencesView({
531
567
  }
532
568
  };
533
569
 
534
- const runReview = (action: ReferenceCurationAction) => {
570
+ const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
535
571
  if (!selectedReference) return;
536
- onReview(selectedReference, action);
572
+ onReview(selectedReference, action, options);
537
573
  };
538
574
 
539
575
  const emptyLabel = canonicalReferences.length
@@ -688,6 +724,9 @@ export function NewsroomReferencesView({
688
724
  disabled={disabled}
689
725
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
690
726
  onReview={runReview}
727
+ sectionAgreement={sectionAgreement}
728
+ sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
729
+ sections={sections}
691
730
  previewAttachments={previewAttachments}
692
731
  reference={selectedReference}
693
732
  />
@@ -722,6 +761,9 @@ export function NewsroomReferencesView({
722
761
  onClose={closeDetail}
723
762
  onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
724
763
  onReview={runReview}
764
+ sectionAgreement={sectionAgreement}
765
+ sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
766
+ sections={sections}
725
767
  previewAttachments={previewAttachments}
726
768
  reference={selectedReference}
727
769
  />
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { formatPercentage } from "../lib/cyclotron-suggestions";
4
+ import { sectionAgreementIsMeasured, type SectionAgreement, type SectionSuggestion, type SiteSectionAgreement } from "../lib/site-section-suggestions";
5
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT } from "../lib/cyclotron-suggestions";
6
+
7
+ export type SectionChoice = { id: string; title: string };
8
+
9
+ function titleOf(sections: SectionChoice[], sectionId: string): string {
10
+ return sections.find((section) => section.id === sectionId)?.title ?? sectionId;
11
+ }
12
+
13
+ function agreementWords(agreement: SectionAgreement | undefined): string {
14
+ if (!agreement) return "no editor decisions compared yet";
15
+ if (!sectionAgreementIsMeasured(agreement)) {
16
+ return `${agreement.comparisons} of ${MINIMUM_COMPARISONS_FOR_AGREEMENT} editor decisions compared, too few to measure`;
17
+ }
18
+ return `agreement ${formatPercentage(agreement.agreement)}, ${agreement.comparisons} editor decisions compared`;
19
+ }
20
+
21
+ /**
22
+ * The suggested Section for a pending reference, how likely the other Sections
23
+ * are, and how often this Section's suggestions matched editors. Advisory: no
24
+ * Section is chosen until an editor picks one.
25
+ */
26
+ export function SectionSuggestionBlock({
27
+ agreement,
28
+ referenceLineageId,
29
+ sections,
30
+ suggestion,
31
+ }: {
32
+ agreement: SiteSectionAgreement | null;
33
+ referenceLineageId: string;
34
+ sections: SectionChoice[];
35
+ suggestion: SectionSuggestion;
36
+ }) {
37
+ const figures = agreement?.sections.find((entry) => entry.sectionId === suggestion.sectionId);
38
+ const others = suggestion.probabilities.filter((entry) => entry.sectionId !== suggestion.sectionId).slice(0, 3);
39
+ return (
40
+ <section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
41
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
42
+ <p className="text-sm text-foreground">
43
+ The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
44
+ {suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
45
+ </p>
46
+ {others.length ? (
47
+ <p className="text-xs text-foreground" data-reference-section-alternatives>
48
+ Next most likely: {others.map((entry) => `${titleOf(sections, entry.sectionId)} ${formatPercentage(entry.probability)}`).join(", ")}.
49
+ </p>
50
+ ) : null}
51
+ <p className="text-xs text-foreground" data-reference-section-agreement>
52
+ For {titleOf(sections, suggestion.sectionId)}: {agreementWords(figures)}.
53
+ </p>
54
+ <p className="text-xs text-foreground" data-reference-section-notice>
55
+ A suggestion never places a reference. The Section you choose when you accept does.
56
+ </p>
57
+ </section>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Picks the Section an accepted reference belongs to. The choice is sent with
63
+ * the accept and is what the cyclotron learns from; nothing is preselected.
64
+ */
65
+ export function SectionPicker({
66
+ agreement,
67
+ chosenSectionId,
68
+ disabled,
69
+ onChoose,
70
+ referenceLineageId,
71
+ sections,
72
+ suggestedSectionId,
73
+ }: {
74
+ agreement: SiteSectionAgreement | null;
75
+ chosenSectionId: string | null;
76
+ disabled?: boolean;
77
+ onChoose: (sectionId: string | null) => void;
78
+ referenceLineageId: string;
79
+ sections: SectionChoice[];
80
+ suggestedSectionId: string | null;
81
+ }) {
82
+ return (
83
+ <fieldset className="space-y-1 border-l-2 border-foreground pl-3" data-reference-section-picker={referenceLineageId} disabled={disabled}>
84
+ <legend className="text-xs font-semibold uppercase tracking-wide text-foreground">Section when you accept</legend>
85
+ <label className="flex items-center gap-2 text-sm text-foreground">
86
+ <input checked={chosenSectionId === null} name={`section-${referenceLineageId}`} onChange={() => onChoose(null)} type="radio" />
87
+ <span className={chosenSectionId === null ? "font-semibold" : undefined}>No Section</span>
88
+ </label>
89
+ {sections.map((section) => {
90
+ const figures = agreement?.sections.find((entry) => entry.sectionId === section.id);
91
+ const chosen = chosenSectionId === section.id;
92
+ return (
93
+ <label className="flex flex-wrap items-center gap-x-2 text-sm text-foreground" key={section.id}>
94
+ <input checked={chosen} name={`section-${referenceLineageId}`} onChange={() => onChoose(section.id)} type="radio" value={section.id} />
95
+ <span className={chosen ? "font-semibold underline underline-offset-4" : undefined}>{section.title}</span>
96
+ {section.id === suggestedSectionId ? <span className="text-xs">suggested</span> : null}
97
+ {sectionAgreementIsMeasured(figures) ? <span className="text-xs">agreement {formatPercentage(figures?.agreement ?? null)}</span> : null}
98
+ </label>
99
+ );
100
+ })}
101
+ <p className="text-xs text-foreground">The Section you choose is a training label for the cyclotron.</p>
102
+ </fieldset>
103
+ );
104
+ }
@@ -56,6 +56,7 @@ import {
56
56
  type NewsroomRecordPage,
57
57
  loadCyclotronStatus,
58
58
  loadCyclotronShadowAgreement,
59
+ loadSiteSectionAgreement,
59
60
  requestCyclotronPriming,
60
61
  loadCyclotronPrimingRequests,
61
62
  requestCyclotronReviewRate,
@@ -162,6 +163,8 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
162
163
  import { currentRelevanceDecisions } from "../lib/relevance-decisions";
163
164
  import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
164
165
  import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
166
+ import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
167
+ import { currentSectionSuggestions, siteCyclotronIdOf, type SiteSectionAgreement } from "../lib/site-section-suggestions";
165
168
  import {
166
169
  buildPrimingRequest,
167
170
  buildReviewRateRequest,
@@ -862,6 +865,35 @@ function NewsDeskDashboard({
862
865
  [dashboard.newsroomSections],
863
866
  );
864
867
  const [newsroomSections, setNewsroomSections] = useState(fallbackNewsroomSections);
868
+ const sectionChoices = useMemo(
869
+ () => newsroomSections
870
+ .filter((section) => section.enabled !== false && section.enabledStatus !== "disabled")
871
+ .sort((left, right) => left.sortOrder - right.sortOrder)
872
+ .map((section) => ({ id: section.id, title: section.title })),
873
+ [newsroomSections],
874
+ );
875
+ const sectionPreviewFixtures = useMemo(
876
+ () => (isLocalPreviewEnabled() ? createLocalPreviewSectionFixtures(references, sectionChoices.map((section) => section.id)) : null),
877
+ [references, sectionChoices],
878
+ );
879
+ const sectionSuggestions = useMemo(
880
+ () => currentSectionSuggestions(sectionPreviewFixtures ? [...semanticRelations, ...sectionPreviewFixtures.relations] : semanticRelations),
881
+ [sectionPreviewFixtures, semanticRelations],
882
+ );
883
+ const siteCyclotronId = useMemo(() => siteCyclotronIdOf(sectionSuggestions), [sectionSuggestions]);
884
+ const [siteSectionAgreement, setSiteSectionAgreement] = useState<SiteSectionAgreement | null>(null);
885
+ useEffect(() => {
886
+ if (sectionPreviewFixtures) {
887
+ setSiteSectionAgreement(sectionPreviewFixtures.agreement);
888
+ return;
889
+ }
890
+ if (!siteCyclotronId || dashboard.isDemo) return;
891
+ let cancelled = false;
892
+ loadSiteSectionAgreement(siteCyclotronId)
893
+ .then((loaded) => { if (!cancelled) setSiteSectionAgreement(loaded); })
894
+ .catch(() => { if (!cancelled) setSiteSectionAgreement(null); });
895
+ return () => { cancelled = true; };
896
+ }, [dashboard.isDemo, sectionPreviewFixtures, siteCyclotronId]);
865
897
  const [procedureDefinitions, setProcedureDefinitions] = useState(dashboard.procedureDefinitions ?? []);
866
898
  const [procedureVersions, setProcedureVersions] = useState(dashboard.procedureVersions ?? []);
867
899
  const [procedureRuns, setProcedureRuns] = useState(dashboard.procedureRuns ?? []);
@@ -1586,6 +1618,7 @@ function NewsDeskDashboard({
1586
1618
  note?: string,
1587
1619
  reasonCode?: ReferenceRejectionReasonCode | null,
1588
1620
  relevance?: { decisionRelationId: string; shareable: boolean },
1621
+ sectionId?: string | null,
1589
1622
  ) {
1590
1623
  const nextStatus = referenceCurationStatusForAction(action);
1591
1624
  setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
@@ -1615,6 +1648,7 @@ function NewsDeskDashboard({
1615
1648
  curationStatus: nextStatus,
1616
1649
  reasonCode: action === "reject" ? reasonCode ?? null : null,
1617
1650
  ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1651
+ ...(action === "accept" && sectionId ? { sectionId } : {}),
1618
1652
  },
1619
1653
  }, ...current]);
1620
1654
  setSemanticRelations((current) => [{
@@ -1657,6 +1691,7 @@ function NewsDeskDashboard({
1657
1691
  reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1658
1692
  decisionRelationId: relevance?.decisionRelationId,
1659
1693
  shareable: relevance ? relevance.shareable : undefined,
1694
+ sectionId: action === "accept" ? sectionId ?? undefined : undefined,
1660
1695
  },
1661
1696
  { authMode: USER_POOL_AUTH_MODE },
1662
1697
  );
@@ -3265,7 +3300,10 @@ function NewsDeskDashboard({
3265
3300
  normalizeReferenceRejectionReasonCode(curation.reasonCode),
3266
3301
  { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3267
3302
  )}
3268
- onReview={(reference, action) => runReferenceCurationAction(reference, action)}
3303
+ sections={sectionChoices}
3304
+ sectionSuggestions={sectionSuggestions}
3305
+ sectionAgreement={siteSectionAgreement}
3306
+ onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, undefined, undefined, options?.sectionId)}
3269
3307
  referenceAttachments={referenceAttachments}
3270
3308
  references={references}
3271
3309
  />
@@ -0,0 +1,72 @@
1
+ import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+ import { SECTION_FIT_CLASSIFIER_PREFIX, SECTION_SELECTOR_CLASSIFIER_PREFIX, parseSiteSectionAgreement, type SiteSectionAgreement } from "./site-section-suggestions";
4
+
5
+ const PREVIEW_SITE_CYCLOTRON_ID = "preview-site-cyclotron";
6
+
7
+ /**
8
+ * Local preview only: a suggested Section on the first pending references and
9
+ * a measured agreement for each Section, so the References screen can be
10
+ * reviewed without a sweep.
11
+ */
12
+ export function createLocalPreviewSectionFixtures(
13
+ references: ReferenceRecord[],
14
+ sectionIds: string[],
15
+ ): { relations: SemanticRelationRecord[]; agreement: SiteSectionAgreement | null } {
16
+ if (sectionIds.length < 2) return { relations: [], agreement: null };
17
+ const relations: SemanticRelationRecord[] = references.slice(0, 6).map((reference, index) => {
18
+ const lineageId = reference.lineageId ?? reference.id;
19
+ const top = sectionIds[index % sectionIds.length];
20
+ const runnerUp = sectionIds[(index + 1) % sectionIds.length];
21
+ const confidence = 0.88 - index * 0.07;
22
+ const probabilities: Record<string, number> = { [top]: confidence, [runnerUp]: (1 - confidence) * 0.7 };
23
+ const rest = sectionIds.filter((candidate) => candidate !== top && candidate !== runnerUp);
24
+ rest.forEach((candidate) => { probabilities[candidate] = (1 - confidence) * 0.3 / rest.length; });
25
+ return {
26
+ id: `preview-section-relation-${index}`,
27
+ relationState: "current",
28
+ predicate: "site_decision_is",
29
+ subjectKind: "reference",
30
+ subjectId: reference.id,
31
+ subjectLineageId: lineageId,
32
+ objectKind: "semanticNode",
33
+ objectId: `semantic-node-site-${top}-v1`,
34
+ objectLineageId: `semantic-node-site-${top}`,
35
+ subjectStateKey: `reference#${lineageId}#current`,
36
+ objectStateKey: `semanticNode#semantic-node-site-${top}#current`,
37
+ objectSubjectStateKey: `semanticNode#semantic-node-site-${top}#current#reference`,
38
+ predicateObjectStateKey: `site_decision_is#semanticNode#semantic-node-site-${top}#current`,
39
+ subjectVersionKey: `reference#${reference.id}`,
40
+ objectVersionKey: `semanticNode#semantic-node-site-${top}-v1`,
41
+ confidence,
42
+ classifierId: `${PREVIEW_SITE_CYCLOTRON_ID}/${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
43
+ modelVersion: "2",
44
+ reviewRecommended: false,
45
+ metadata: JSON.stringify({
46
+ decisionId: `preview-site-decision-${index}`,
47
+ cyclotronId: PREVIEW_SITE_CYCLOTRON_ID,
48
+ classifier: `${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
49
+ label: top,
50
+ probabilities,
51
+ version: 2,
52
+ decidedAt: new Date(Date.UTC(2026, 9, 18, 6 - index)).toISOString(),
53
+ }),
54
+ } as SemanticRelationRecord;
55
+ });
56
+ const classifiers: Record<string, unknown> = { [`${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`]: { comparisons: 14, correct: 10, agreement: 10 / 14 } };
57
+ sectionIds.forEach((sectionId, index) => {
58
+ classifiers[`${SECTION_FIT_CLASSIFIER_PREFIX}${sectionId}`] = index === sectionIds.length - 1
59
+ ? { comparisons: 4, agreement: 0.75, precision: 0.5, recall: 1 }
60
+ : { comparisons: 12 + index, agreement: 0.9 - index * 0.08, precision: 0.85 - index * 0.06, recall: 0.8 };
61
+ });
62
+ return {
63
+ relations,
64
+ agreement: parseSiteSectionAgreement({
65
+ schema: SHADOW_AGREEMENT_SCHEMA,
66
+ computedAt: "2026-10-18T09:00:00.000Z",
67
+ comparisons: 14,
68
+ selectorAccuracy: 10 / 14,
69
+ classifiers,
70
+ }),
71
+ };
72
+ }
@@ -0,0 +1,120 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT, SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+
4
+ export const SITE_DECISION_RELATION = "site_decision_is";
5
+ export const SECTION_SELECTOR_CLASSIFIER_PREFIX = "section-selector-";
6
+ export const SECTION_FIT_CLASSIFIER_PREFIX = "section-fit-";
7
+
8
+ /** The site cyclotron's suggested Section for one reference. Advisory only. */
9
+ export type SectionSuggestion = {
10
+ decisionRelationId: string;
11
+ cyclotronId: string | null;
12
+ sectionId: string;
13
+ confidence: number | null;
14
+ /** Section id to probability, highest first. */
15
+ probabilities: Array<{ sectionId: string; probability: number }>;
16
+ decidedAt: string | null;
17
+ };
18
+
19
+ /** How often the suggestion of one Section matched what editors later decided. */
20
+ export type SectionAgreement = {
21
+ sectionId: string;
22
+ comparisons: number;
23
+ agreement: number | null;
24
+ precision: number | null;
25
+ recall: number | null;
26
+ };
27
+
28
+ export type SiteSectionAgreement = {
29
+ computedAt: string | null;
30
+ selectorAccuracy: number | null;
31
+ selectorComparisons: number;
32
+ sections: SectionAgreement[];
33
+ };
34
+
35
+ function parseMetadata(value: unknown): Record<string, unknown> {
36
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
37
+ if (typeof value !== "string") return {};
38
+ try {
39
+ const parsed = JSON.parse(value);
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function numberOrNull(value: unknown): number | null {
47
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
48
+ }
49
+
50
+ /** The part of `<cyclotronId>/<classifier>` after the last slash. */
51
+ export function siteClassifierName(classifierKey: string): string {
52
+ return classifierKey.slice(classifierKey.lastIndexOf("/") + 1);
53
+ }
54
+
55
+ /**
56
+ * Current suggested Sections keyed by reference lineage, from the relations the
57
+ * Newsroom already loads. Only the section-selector classifier names a Section.
58
+ */
59
+ export function currentSectionSuggestions(relations: SemanticRelationRecord[]): Map<string, SectionSuggestion> {
60
+ const suggestions = new Map<string, SectionSuggestion>();
61
+ for (const relation of relations) {
62
+ if (relation.predicate !== SITE_DECISION_RELATION || relation.relationState !== "current") continue;
63
+ if (!siteClassifierName(relation.classifierId ?? "").startsWith(SECTION_SELECTOR_CLASSIFIER_PREFIX)) continue;
64
+ const metadata = parseMetadata(relation.metadata);
65
+ const sectionId = typeof metadata.label === "string" ? metadata.label : "";
66
+ if (!sectionId) continue;
67
+ const rawProbabilities = parseMetadata(metadata.probabilities);
68
+ const probabilities = Object.entries(rawProbabilities)
69
+ .flatMap(([candidate, probability]) => (numberOrNull(probability) === null ? [] : [{ sectionId: candidate, probability: probability as number }]))
70
+ .sort((left, right) => right.probability - left.probability);
71
+ suggestions.set(relation.subjectLineageId, {
72
+ decisionRelationId: relation.id,
73
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
74
+ sectionId,
75
+ confidence: numberOrNull(relation.confidence) ?? probabilities.find((entry) => entry.sectionId === sectionId)?.probability ?? null,
76
+ probabilities,
77
+ decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
78
+ });
79
+ }
80
+ return suggestions;
81
+ }
82
+
83
+ /** The site cyclotron's id, from any current Section suggestion. */
84
+ export function siteCyclotronIdOf(suggestions: Map<string, SectionSuggestion>): string | null {
85
+ for (const suggestion of suggestions.values()) if (suggestion.cyclotronId) return suggestion.cyclotronId;
86
+ return null;
87
+ }
88
+
89
+ /** The per-Section part of a cyclotron-shadow-agreement/v1 payload the site sweep wrote, or null for any other JSON. */
90
+ export function parseSiteSectionAgreement(json: unknown): SiteSectionAgreement | null {
91
+ if (!json || typeof json !== "object") return null;
92
+ const record = json as Record<string, unknown>;
93
+ if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
94
+ const classifiers = record.classifiers;
95
+ if (!classifiers || typeof classifiers !== "object") return null;
96
+ const sections: SectionAgreement[] = [];
97
+ for (const [classifier, value] of Object.entries(classifiers as Record<string, unknown>)) {
98
+ if (!classifier.startsWith(SECTION_FIT_CLASSIFIER_PREFIX) || !value || typeof value !== "object") continue;
99
+ const metrics = value as Record<string, unknown>;
100
+ sections.push({
101
+ sectionId: classifier.slice(SECTION_FIT_CLASSIFIER_PREFIX.length),
102
+ comparisons: numberOrNull(metrics.comparisons) ?? 0,
103
+ agreement: numberOrNull(metrics.agreement),
104
+ precision: numberOrNull(metrics.precision),
105
+ recall: numberOrNull(metrics.recall),
106
+ });
107
+ }
108
+ if (!sections.length && numberOrNull(record.selectorAccuracy) === null) return null;
109
+ return {
110
+ computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
111
+ selectorAccuracy: numberOrNull(record.selectorAccuracy),
112
+ selectorComparisons: numberOrNull(record.comparisons) ?? 0,
113
+ sections,
114
+ };
115
+ }
116
+
117
+ /** Agreement figures are worth showing only once enough editor decisions followed a suggestion. */
118
+ export function sectionAgreementIsMeasured(agreement: SectionAgreement | undefined): boolean {
119
+ return Boolean(agreement) && (agreement as SectionAgreement).comparisons >= MINIMUM_COMPARISONS_FOR_AGREEMENT && (agreement as SectionAgreement).agreement !== null;
120
+ }
package/package.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "./components/archive-shell": "./components/archive-shell.tsx",
77
77
  "./components/article-video": "./components/article-video.tsx",
78
78
  "./components/blog-page-background": "./components/blog-page-background.tsx",
79
- "./components/board-card-excerpts": "./components/board-card-excerpts.tsx",
80
79
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
81
80
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
82
81
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
@@ -116,6 +115,7 @@
116
115
  "./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
117
116
  "./components/reference-priming": "./components/reference-priming.tsx",
118
117
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
118
+ "./components/reference-section-suggestion": "./components/reference-section-suggestion.tsx",
119
119
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
120
120
  "./components/settings-page": "./components/settings-page.tsx",
121
121
  "./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
@@ -197,6 +197,7 @@
197
197
  "./lib/layout-scenarios": "./lib/layout-scenarios.ts",
198
198
  "./lib/layout-types": "./lib/layout-types.ts",
199
199
  "./lib/layout-warnings": "./lib/layout-warnings.ts",
200
+ "./lib/local-preview-section-fixtures": "./lib/local-preview-section-fixtures.ts",
200
201
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
201
202
  "./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
202
203
  "./lib/local-preview": "./lib/local-preview.ts",
@@ -270,6 +271,7 @@
270
271
  "./lib/site-brand": "./lib/site-brand.ts",
271
272
  "./lib/site-env": "./lib/site-env.ts",
272
273
  "./lib/site-renderer": "./lib/site-renderer.ts",
274
+ "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
273
275
  "./lib/site-stack": "./lib/site-stack.ts",
274
276
  "./lib/staging-gate": "./lib/staging-gate.ts",
275
277
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
@@ -374,7 +376,7 @@
374
376
  "optional": true
375
377
  }
376
378
  },
377
- "version": "1.4.0-next.8",
379
+ "version": "1.4.0-next.9",
378
380
  "dependencies": {
379
381
  "@aws-amplify/adapter-nextjs": "^1.7.3",
380
382
  "@aws-crypto/sha256-js": "5.2.0",
@@ -387,7 +389,7 @@
387
389
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
388
390
  "@base-ui/react": "^1.5.0",
389
391
  "@chenglou/pretext": "^0.0.7",
390
- "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.2.0/kanbus-ui-0.2.0.tgz",
392
+ "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.3.0/kanbus-ui-0.3.0.tgz",
391
393
  "@radix-ui/colors": "3.0.0",
392
394
  "@smithy/protocol-http": "5.4.1",
393
395
  "@smithy/signature-v4": "5.7.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.4.0-next.8",
2
+ "version": "1.4.0-next.9",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -1,61 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect } from "react";
4
-
5
- type Excerpt = { issueId: string; text: string };
6
-
7
- const EXCERPT_TOGGLE_THRESHOLD = 130;
8
-
9
- function buildExcerptElement(text: string): HTMLElement {
10
- const container = document.createElement("div");
11
- container.className = "board-card-excerpt";
12
- container.dataset.boardCardExcerpt = "";
13
- container.dataset.expanded = "false";
14
- const paragraph = document.createElement("p");
15
- paragraph.textContent = text;
16
- container.append(paragraph);
17
- if (text.length > EXCERPT_TOGGLE_THRESHOLD) {
18
- const toggle = document.createElement("button");
19
- toggle.type = "button";
20
- toggle.textContent = "More";
21
- toggle.setAttribute("aria-expanded", "false");
22
- const flip = (event: Event) => {
23
- event.stopPropagation();
24
- const expanded = container.dataset.expanded !== "true";
25
- container.dataset.expanded = String(expanded);
26
- toggle.textContent = expanded ? "Less" : "More";
27
- toggle.setAttribute("aria-expanded", String(expanded));
28
- };
29
- toggle.addEventListener("click", flip);
30
- toggle.addEventListener("keydown", event => event.stopPropagation());
31
- container.append(toggle);
32
- }
33
- return container;
34
- }
35
-
36
- /**
37
- * Adds a short expandable excerpt under each card's headline. The Kanbus card has no excerpt slot,
38
- * so each excerpt is written into the card's content block once the card is on the page.
39
- */
40
- export function BoardCardExcerpts({ excerpts, scope }: { excerpts: readonly Excerpt[]; scope: HTMLElement | null }) {
41
- useEffect(() => {
42
- if (!scope) return;
43
- const textByIssueId = new Map(excerpts.map(({ issueId, text }) => [issueId, text]));
44
- const place = () => {
45
- for (const card of Array.from(scope.querySelectorAll<HTMLElement>(".issue-card"))) {
46
- const text = textByIssueId.get(card.dataset.issueId ?? "");
47
- const content = card.querySelector<HTMLElement>(".issue-card-content");
48
- if (!text || !content) continue;
49
- const existing = content.querySelector<HTMLElement>(":scope > [data-board-card-excerpt]");
50
- if (existing && existing.querySelector("p")?.textContent === text) continue;
51
- existing?.remove();
52
- content.querySelector("h3")?.after(buildExcerptElement(text));
53
- }
54
- };
55
- place();
56
- const observer = new MutationObserver(place);
57
- observer.observe(scope, { childList: true, subtree: true });
58
- return () => observer.disconnect();
59
- }, [excerpts, scope]);
60
- return null;
61
- }