@anthusai/papyrus 1.13.0 → 1.14.0
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 +2 -0
- package/amplify/data/resource.ts +2 -0
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +8 -1
- package/amplify/functions/shared/reference-curation-metadata.ts +31 -0
- package/components/news-desk-taxonomy-client.ts +12 -0
- package/components/newsroom-board-view.tsx +3 -5
- package/components/newsroom-newsprint-board.css +5 -54
- package/components/newsroom-references-view.tsx +47 -5
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/topic-steering-workspace.tsx +39 -1
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/package.json +5 -3
- package/routes.manifest.json +1 -1
- package/components/board-card-excerpts.tsx +0 -61
package/amplify/data/resource.js
CHANGED
|
@@ -298,6 +298,7 @@ const schema = a.schema({
|
|
|
298
298
|
reasonCode: a.string(),
|
|
299
299
|
decisionRelationId: a.id(),
|
|
300
300
|
shareable: a.boolean(),
|
|
301
|
+
sectionId: a.id(),
|
|
301
302
|
})
|
|
302
303
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
303
304
|
.authorization((allow) => [
|
|
@@ -346,6 +347,7 @@ const schema = a.schema({
|
|
|
346
347
|
reasonCode: a.string(),
|
|
347
348
|
decisionRelationId: a.id(),
|
|
348
349
|
shareable: a.boolean(),
|
|
350
|
+
sectionId: a.id(),
|
|
349
351
|
})
|
|
350
352
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
351
353
|
.authorization((allow) => [
|
package/amplify/data/resource.ts
CHANGED
|
@@ -324,6 +324,7 @@ const schema = a.schema({
|
|
|
324
324
|
reasonCode: a.string(),
|
|
325
325
|
decisionRelationId: a.id(),
|
|
326
326
|
shareable: a.boolean(),
|
|
327
|
+
sectionId: a.id(),
|
|
327
328
|
})
|
|
328
329
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
329
330
|
.authorization((allow) => [
|
|
@@ -376,6 +377,7 @@ const schema = a.schema({
|
|
|
376
377
|
reasonCode: a.string(),
|
|
377
378
|
decisionRelationId: a.id(),
|
|
378
379
|
shareable: a.boolean(),
|
|
380
|
+
sectionId: a.id(),
|
|
379
381
|
})
|
|
380
382
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
381
383
|
.authorization((allow) => [
|
package/amplify/data/schema.js
CHANGED
|
@@ -256,6 +256,7 @@ const baseDefinitions = {
|
|
|
256
256
|
reasonCode: a.string(),
|
|
257
257
|
decisionRelationId: a.id(),
|
|
258
258
|
shareable: a.boolean(),
|
|
259
|
+
sectionId: a.id(),
|
|
259
260
|
})
|
|
260
261
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
261
262
|
.authorization((allow) => [
|
|
@@ -304,6 +305,7 @@ const baseDefinitions = {
|
|
|
304
305
|
reasonCode: a.string(),
|
|
305
306
|
decisionRelationId: a.id(),
|
|
306
307
|
shareable: a.boolean(),
|
|
308
|
+
sectionId: a.id(),
|
|
307
309
|
})
|
|
308
310
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
309
311
|
.authorization((allow) => [
|
package/amplify/data/schema.ts
CHANGED
|
@@ -280,6 +280,7 @@ const baseDefinitions = {
|
|
|
280
280
|
reasonCode: a.string(),
|
|
281
281
|
decisionRelationId: a.id(),
|
|
282
282
|
shareable: a.boolean(),
|
|
283
|
+
sectionId: a.id(),
|
|
283
284
|
})
|
|
284
285
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
285
286
|
.authorization((allow) => [
|
|
@@ -332,6 +333,7 @@ const baseDefinitions = {
|
|
|
332
333
|
reasonCode: a.string(),
|
|
333
334
|
decisionRelationId: a.id(),
|
|
334
335
|
shareable: a.boolean(),
|
|
336
|
+
sectionId: a.id(),
|
|
335
337
|
})
|
|
336
338
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
337
339
|
.authorization((allow) => [
|
|
@@ -4,7 +4,7 @@ import { Amplify } from "aws-amplify";
|
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
6
|
import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
|
|
7
|
-
import { curationActorKind, referenceCurationMetadata } from "../shared/reference-curation-metadata";
|
|
7
|
+
import { curationActorKind, referenceCurationMetadata, validatedCurationSectionId } from "../shared/reference-curation-metadata";
|
|
8
8
|
import {
|
|
9
9
|
CURATION_DELEGATION_OWNER_TYPE,
|
|
10
10
|
CURATION_DELEGATION_PAYLOAD_KIND,
|
|
@@ -193,6 +193,11 @@ async function reviewReferenceCuration(
|
|
|
193
193
|
const nextStatus = referenceCurationStatusForAction(action);
|
|
194
194
|
const reasonCode = normalizeReferenceRejectionReasonCode(event.arguments.reasonCode, action === "reject");
|
|
195
195
|
const reference = await getRequiredRecord(client.models.Reference, referenceId, "Reference");
|
|
196
|
+
const requestedSectionId = normalizeOptionalString(event.arguments.sectionId);
|
|
197
|
+
const sectionRecord = requestedSectionId
|
|
198
|
+
? (await client.models.NewsroomSection.get({ id: requestedSectionId })).data ?? null
|
|
199
|
+
: null;
|
|
200
|
+
const sectionId = validatedCurationSectionId(requestedSectionId, action, sectionRecord);
|
|
196
201
|
const now = new Date().toISOString();
|
|
197
202
|
const actorSub = normalizeOptionalString(event.arguments.actorSub) ?? getIdentitySub(event);
|
|
198
203
|
const actorLabel = normalizeOptionalString(event.arguments.actorLabel) ?? getIdentityLabel(event);
|
|
@@ -259,6 +264,7 @@ async function reviewReferenceCuration(
|
|
|
259
264
|
decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
|
|
260
265
|
shareable: event.arguments.shareable ?? null,
|
|
261
266
|
actorKind: delegation ? "human" : curationActorKind(event.identity),
|
|
267
|
+
sectionId,
|
|
262
268
|
delegation,
|
|
263
269
|
}),
|
|
264
270
|
{ filename: "metadata.json", now },
|
|
@@ -463,6 +469,7 @@ async function recordDelegatedCuration(event: Parameters<RecordDelegatedCuration
|
|
|
463
469
|
reasonCode: event.arguments.reasonCode,
|
|
464
470
|
decisionRelationId: event.arguments.decisionRelationId,
|
|
465
471
|
shareable: event.arguments.shareable,
|
|
472
|
+
sectionId: event.arguments.sectionId,
|
|
466
473
|
actorSub: delegation.grantorSub,
|
|
467
474
|
actorLabel: delegation.grantorLabel,
|
|
468
475
|
},
|
|
@@ -13,6 +13,7 @@ export type ReferenceCurationMetadataInput = {
|
|
|
13
13
|
decisionRelationId?: string | null
|
|
14
14
|
shareable?: boolean | null
|
|
15
15
|
actorKind?: CurationActorKind | null
|
|
16
|
+
sectionId?: string | null
|
|
16
17
|
delegation?: { delegationId: string; relayedBy: string; instruction: string } | null
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -39,6 +40,7 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
|
|
|
39
40
|
referenceId: input.referenceId,
|
|
40
41
|
referenceLineageId: input.referenceLineageId,
|
|
41
42
|
...(input.actorKind ? {actorKind: input.actorKind} : {}),
|
|
43
|
+
...(input.sectionId?.trim() ? {sectionId: input.sectionId.trim()} : {}),
|
|
42
44
|
...(input.delegation
|
|
43
45
|
? {attestation: "delegated", relayedBy: input.delegation.relayedBy, delegationId: input.delegation.delegationId,
|
|
44
46
|
instruction: input.delegation.instruction}
|
|
@@ -47,3 +49,32 @@ export function referenceCurationMetadata(input: ReferenceCurationMetadataInput)
|
|
|
47
49
|
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
export type CurationSectionCandidate = {
|
|
54
|
+
id?: string | null
|
|
55
|
+
enabled?: boolean | null
|
|
56
|
+
enabledStatus?: string | null
|
|
57
|
+
} | null | undefined
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A curation may name the newsroom Section the Reference belongs in, but only
|
|
61
|
+
* when it accepts the Reference and the Section exists and is enabled.
|
|
62
|
+
*/
|
|
63
|
+
export function validatedCurationSectionId(
|
|
64
|
+
requestedSectionId: string | null | undefined,
|
|
65
|
+
action: string,
|
|
66
|
+
section: CurationSectionCandidate,
|
|
67
|
+
): string | null {
|
|
68
|
+
const sectionId = requestedSectionId?.trim() || null
|
|
69
|
+
if (!sectionId) return null
|
|
70
|
+
if (action !== "accept") {
|
|
71
|
+
throw new Error("A section can be recorded only when accepting a reference.")
|
|
72
|
+
}
|
|
73
|
+
if (!section || section.id !== sectionId) {
|
|
74
|
+
throw new Error(`Newsroom section ${sectionId} was not found.`)
|
|
75
|
+
}
|
|
76
|
+
if (section.enabled === false || section.enabledStatus === "disabled") {
|
|
77
|
+
throw new Error(`Newsroom section ${sectionId} is disabled.`)
|
|
78
|
+
}
|
|
79
|
+
return sectionId
|
|
80
|
+
}
|
|
@@ -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={
|
|
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
|
-
--
|
|
3
|
-
--
|
|
4
|
-
--
|
|
5
|
-
--
|
|
6
|
-
--
|
|
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
|
-
|
|
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.
|
|
379
|
+
"version": "1.14.0",
|
|
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.
|
|
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",
|
package/routes.manifest.json
CHANGED
|
@@ -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
|
-
}
|