@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
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/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -7,8 +7,12 @@ export type RelevanceProvenance = {
|
|
|
7
7
|
labelSet: string[];
|
|
8
8
|
doctrineFingerprint: string | null;
|
|
9
9
|
teachingFingerprint: string | null;
|
|
10
|
-
teachingLabels: number;
|
|
11
|
-
primingExamples: number;
|
|
10
|
+
teachingLabels: number | null;
|
|
11
|
+
primingExamples: number | null;
|
|
12
|
+
/** Recorded by the shared monitoring router, absent on local sweep provenance. */
|
|
13
|
+
system?: string;
|
|
14
|
+
publicationId?: string;
|
|
15
|
+
output?: string;
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
/** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
|
|
@@ -28,6 +32,7 @@ export type RelevanceDecisionView = {
|
|
|
28
32
|
/** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
|
|
29
33
|
reviewControl: "thumbs" | "labels";
|
|
30
34
|
cyclotronId: string | null;
|
|
35
|
+
classifierId?: string;
|
|
31
36
|
/** When the cyclotron made the suggestion, if the decision recorded it. */
|
|
32
37
|
decidedAt: string | null;
|
|
33
38
|
/** Null for decisions made before provenance was recorded. */
|
|
@@ -44,6 +49,7 @@ export type RelevanceReview = {
|
|
|
44
49
|
|
|
45
50
|
/** The existing curation action an editor's review becomes. */
|
|
46
51
|
export type RelevanceCuration = {
|
|
52
|
+
sectionId?: string | null;
|
|
47
53
|
action: "accept" | "reject";
|
|
48
54
|
reasonCode: string | null;
|
|
49
55
|
note: string | null;
|
|
@@ -65,7 +71,7 @@ function parseMetadata(value: unknown): Record<string, unknown> {
|
|
|
65
71
|
function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
66
72
|
const record = parseMetadata(value);
|
|
67
73
|
if (!Object.keys(record).length) return null;
|
|
68
|
-
const count = (entry: unknown) =>
|
|
74
|
+
const count = (entry: unknown) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 ? entry : null;
|
|
69
75
|
const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
|
|
70
76
|
return {
|
|
71
77
|
labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
|
|
@@ -73,27 +79,52 @@ function parseProvenance(value: unknown): RelevanceProvenance | null {
|
|
|
73
79
|
teachingFingerprint: text(record.teachingFingerprint),
|
|
74
80
|
teachingLabels: count(record.teachingLabels),
|
|
75
81
|
primingExamples: count(record.primingExamples),
|
|
82
|
+
...(text(record.system) ? { system: text(record.system)! } : {}),
|
|
83
|
+
...(text(record.publicationId) ? { publicationId: text(record.publicationId)! } : {}),
|
|
84
|
+
...(text(record.output) ? { output: text(record.output)! } : {}),
|
|
76
85
|
};
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function recordedVersion(value: unknown): number | null {
|
|
89
|
+
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
|
|
90
|
+
const version = Number(value);
|
|
91
|
+
return Number.isInteger(version) && version >= 0 ? version : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decisionTime(relation: SemanticRelationRecord): number {
|
|
95
|
+
const metadata = parseMetadata(relation.metadata);
|
|
96
|
+
for (const value of [metadata.decidedAt, relation.importedAt, relation.createdAt]) {
|
|
97
|
+
if (typeof value !== "string") continue;
|
|
98
|
+
const timestamp = Date.parse(value);
|
|
99
|
+
if (Number.isFinite(timestamp)) return timestamp;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/**
|
|
80
105
|
* Current decisions keyed by reference lineage, from relations the Newsroom
|
|
81
106
|
* already loads: no extra request per row.
|
|
82
107
|
*/
|
|
83
108
|
export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
|
|
84
109
|
const decisions = new Map<string, RelevanceDecisionView>();
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
// Realtime events can briefly expose both sides of a superseding write. The
|
|
111
|
+
// result must not depend on list pagination or subscription arrival order.
|
|
112
|
+
const ordered = [...relations].sort((left, right) => decisionTime(left) - decisionTime(right) || left.id.localeCompare(right.id));
|
|
113
|
+
for (const relation of ordered) {
|
|
114
|
+
if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current" || relation.subjectKind !== "reference") continue;
|
|
87
115
|
const metadata = parseMetadata(relation.metadata);
|
|
116
|
+
if (!relation.subjectLineageId || typeof metadata.label !== "string" || !metadata.label.trim()
|
|
117
|
+
|| typeof metadata.decisionId !== "string" || !metadata.decisionId.trim()) continue;
|
|
88
118
|
const probabilities = parseMetadata(metadata.probabilities);
|
|
89
119
|
const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
|
|
90
|
-
const version =
|
|
120
|
+
const version = recordedVersion(relation.modelVersion ?? metadata.version);
|
|
91
121
|
decisions.set(relation.subjectLineageId, {
|
|
92
122
|
decisionRelationId: relation.id,
|
|
93
|
-
decisionId:
|
|
94
|
-
label:
|
|
95
|
-
confidence: typeof relation.confidence === "number"
|
|
96
|
-
|
|
123
|
+
decisionId: metadata.decisionId,
|
|
124
|
+
label: metadata.label,
|
|
125
|
+
confidence: typeof relation.confidence === "number" && Number.isFinite(relation.confidence)
|
|
126
|
+
&& relation.confidence >= 0 && relation.confidence <= 1 ? relation.confidence : null,
|
|
127
|
+
version,
|
|
97
128
|
reviewRecommended: Boolean(relation.reviewRecommended),
|
|
98
129
|
reviewReason: reason,
|
|
99
130
|
reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
|
|
@@ -104,6 +135,7 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
|
|
|
104
135
|
question: typeof metadata.question === "string" ? metadata.question : null,
|
|
105
136
|
reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
|
|
106
137
|
cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
|
|
138
|
+
...(relation.classifierId ? { classifierId: relation.classifierId } : {}),
|
|
107
139
|
decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
|
|
108
140
|
provenance: parseProvenance(metadata.provenance),
|
|
109
141
|
});
|
package/lib/relevance-status.ts
CHANGED
|
@@ -52,10 +52,10 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
52
52
|
},
|
|
53
53
|
calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
|
|
54
54
|
reviewRate: {
|
|
55
|
-
state: "onboarding", rate: 1, reason: "
|
|
55
|
+
state: "onboarding", rate: 1, reason: "Getting started: every suggestion is reviewed until suggestions earn a lower review rate.",
|
|
56
56
|
auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
|
|
57
57
|
nextCheckAfterDecisions: 99,
|
|
58
|
-
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a
|
|
58
|
+
nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a gap under 5 points between how sure suggestions say they are and how often they are right. Next check in 99 decisions.",
|
|
59
59
|
expectedReviewsPerWeek: 1,
|
|
60
60
|
},
|
|
61
61
|
lastChange: null,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import { getCurrentUser } from "aws-amplify/auth";
|
|
5
|
+
import {
|
|
6
|
+
answerUpdate, isDue, normalizeReviewRow, timeoutUpdate,
|
|
7
|
+
type AnswerInput, type ReviewRequest,
|
|
8
|
+
} from "./review-requests";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Review requests through the site's own authenticated API (user pool; editors and admins). The condition `state = open`
|
|
12
|
+
* on every change means two people answering at once, or an answer racing the deadline, cannot both win.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FIELDS = "id subject mode reason ask raisedBy raisedAt deadline state answer links history appliedIn updatedAt";
|
|
16
|
+
const MAX_PAGES = 10;
|
|
17
|
+
|
|
18
|
+
type GraphQLResult = { data?: Record<string, any>; errors?: { message?: string; errorType?: string }[] };
|
|
19
|
+
|
|
20
|
+
async function run(query: string, variables: Record<string, unknown>): Promise<GraphQLResult> {
|
|
21
|
+
const client = generateClient({ authMode: "userPool" });
|
|
22
|
+
try {
|
|
23
|
+
return (await client.graphql({ query, variables })) as GraphQLResult;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// Amplify throws the response when AppSync returns errors.
|
|
26
|
+
return (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ReviewLoad = { state: "ready"; requests: ReviewRequest[]; readIds: string[]; userKey: string } | { state: "forbidden" } | { state: "unavailable" };
|
|
31
|
+
|
|
32
|
+
export async function currentUserKey(): Promise<string> {
|
|
33
|
+
return (await getCurrentUser()).userId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Loads every request (newest pages first), marks due soft reviews as timed out ("proceeded without review"), and reads this user's marks. */
|
|
37
|
+
export async function loadReviews(now: Date = new Date()): Promise<ReviewLoad> {
|
|
38
|
+
let userKey: string;
|
|
39
|
+
try {
|
|
40
|
+
userKey = await currentUserKey();
|
|
41
|
+
} catch {
|
|
42
|
+
return { state: "unavailable" };
|
|
43
|
+
}
|
|
44
|
+
const rows: Record<string, unknown>[] = [];
|
|
45
|
+
let nextToken: string | null = null;
|
|
46
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
47
|
+
const result = await run(
|
|
48
|
+
`query ListReviews($limit: Int, $nextToken: String) { listReviewRequests(limit: $limit, nextToken: $nextToken) { items { ${FIELDS} } nextToken } }`,
|
|
49
|
+
{ limit: 500, nextToken },
|
|
50
|
+
);
|
|
51
|
+
if (result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) return { state: "forbidden" };
|
|
52
|
+
const connection = result.data?.listReviewRequests;
|
|
53
|
+
if (result.errors?.length || !connection) return { state: "unavailable" };
|
|
54
|
+
rows.push(...connection.items.filter(Boolean));
|
|
55
|
+
nextToken = connection.nextToken ?? null;
|
|
56
|
+
if (!nextToken) break;
|
|
57
|
+
}
|
|
58
|
+
const requests = rows.map(normalizeReviewRow);
|
|
59
|
+
for (const [index, request] of requests.entries()) {
|
|
60
|
+
if (!isDue(request, now)) continue;
|
|
61
|
+
const update = timeoutUpdate(request, now);
|
|
62
|
+
const written = await run(
|
|
63
|
+
`mutation Expire($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
64
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
65
|
+
);
|
|
66
|
+
const row = written.data?.updateReviewRequest;
|
|
67
|
+
requests[index] = row ? normalizeReviewRow(row) : { ...request, state: "timed_out", updatedAt: String(update.updatedAt), history: JSON.parse(String(update.history)) };
|
|
68
|
+
}
|
|
69
|
+
const marks = await run(
|
|
70
|
+
`query Marks($filter: ModelReviewReadMarkFilterInput, $limit: Int) { listReviewReadMarks(filter: $filter, limit: $limit) { items { requestId } } }`,
|
|
71
|
+
{ filter: { userKey: { eq: userKey } }, limit: 5000 },
|
|
72
|
+
);
|
|
73
|
+
const readIds = (marks.data?.listReviewReadMarks?.items ?? []).filter(Boolean).map((mark: { requestId: string }) => mark.requestId);
|
|
74
|
+
return { state: "ready", requests, readIds, userKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Records an answer. Throws an Error with a sentence for the page when the request is closed or the write loses a race. */
|
|
78
|
+
export async function answerReviewRequest(request: ReviewRequest, input: AnswerInput, by: string, now: Date = new Date()): Promise<ReviewRequest> {
|
|
79
|
+
const update = answerUpdate(request, input, by, now);
|
|
80
|
+
const result = await run(
|
|
81
|
+
`mutation Answer($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
82
|
+
{ input: update, condition: { state: { eq: "open" } } },
|
|
83
|
+
);
|
|
84
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
85
|
+
throw new Error("Someone else answered this, or it proceeded at its deadline. Refresh to see the result.");
|
|
86
|
+
}
|
|
87
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Could not save the answer. Nothing was changed.");
|
|
88
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This user has seen the request. Create the mark; if it exists, refresh it. */
|
|
92
|
+
export async function markReviewRead(userKey: string, requestId: string, now: Date = new Date()): Promise<void> {
|
|
93
|
+
const input = { id: `${userKey}#${requestId}`, requestId, userKey, readAt: now.toISOString() };
|
|
94
|
+
const created = await run(
|
|
95
|
+
`mutation Mark($input: CreateReviewReadMarkInput!) { createReviewReadMark(input: $input) { id } }`, { input });
|
|
96
|
+
if (created.errors?.length) {
|
|
97
|
+
await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReviewRequest } from "./review-requests";
|
|
2
|
+
|
|
3
|
+
/** Demo and test data: one of each mode, a timed-out soft review, and an answered one whose steering was applied. */
|
|
4
|
+
export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "rr-hard-1", subject: { type: "story", id: "story-ruling-1" }, mode: "hard_gate", reason: "Approve this ruling summary before it is published",
|
|
7
|
+
ask: "Check the holding and the quoted passages.", raisedBy: "distinctive.ttab.ruling-analysis", raisedAt: "2026-10-23T08:00:00Z", deadline: null,
|
|
8
|
+
state: "open", answer: null, links: [], history: [{ at: "2026-10-23T08:00:00Z", event: "raised", by: "distinctive.ttab.ruling-analysis", note: "hard_gate" }],
|
|
9
|
+
appliedIn: null, updatedAt: "2026-10-23T08:00:00Z",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "rr-soft-2", subject: { type: "batch", id: "2026-W43" }, mode: "soft_review", reason: "3 new story ideas to review",
|
|
13
|
+
ask: "Approve to keep them all, reject to drop the batch, or steer with a note.", raisedBy: "idea-pipeline", raisedAt: "2026-10-23T07:00:00Z",
|
|
14
|
+
deadline: "2026-10-30T07:00:00Z", state: "open", answer: null,
|
|
15
|
+
links: [{ type: "proposal", id: "proposal-1", label: "story idea 1" }, { type: "proposal", id: "proposal-2", label: "story idea 2" }, { type: "proposal", id: "proposal-3", label: "story idea 3" }],
|
|
16
|
+
history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend story draft is ready",
|
|
20
|
+
ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
|
|
21
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "rr-flag-1", subject: { type: "reference", id: "ref-1" }, mode: "flag", reason: "Two sources disagree about the filing date",
|
|
25
|
+
ask: null, raisedBy: "reference-intake", raisedAt: "2026-10-22T10:00:00Z", deadline: null, state: "open", answer: null, links: [], history: [],
|
|
26
|
+
appliedIn: null, updatedAt: "2026-10-22T10:00:00Z",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "rr-late-1", subject: { type: "story", id: "story-old" }, mode: "soft_review", reason: "Check the headline", ask: null, raisedBy: "copy-editor",
|
|
30
|
+
raisedAt: "2026-10-10T07:00:00Z", deadline: "2026-10-17T07:00:00Z", state: "timed_out", answer: null, links: [],
|
|
31
|
+
history: [{ at: "2026-10-17T07:00:00Z", event: "timed_out", by: "system", note: "proceeded without review" }], appliedIn: null, updatedAt: "2026-10-17T07:00:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "rr-done-1", subject: { type: "batch", id: "2026-W42" }, mode: "soft_review", reason: "4 new story ideas to review", ask: null, raisedBy: "idea-pipeline",
|
|
35
|
+
raisedAt: "2026-10-16T07:00:00Z", deadline: "2026-10-23T07:00:00Z", state: "answered",
|
|
36
|
+
answer: { decision: "feedback", feedback: "Fewer listicles", steer: "not_this_kind", by: "Ed Itor", at: "2026-10-17T09:00:00Z" },
|
|
37
|
+
links: [], history: [], appliedIn: { runId: "run-w43", by: "idea-pipeline", at: "2026-10-20T07:00:00Z" }, updatedAt: "2026-10-17T09:00:00Z",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "rr-done-2", subject: { type: "proposal", id: "proposal-4" }, mode: "soft_review", reason: "A concept rename was proposed", ask: null, raisedBy: "concept-bot",
|
|
41
|
+
raisedAt: "2026-10-18T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "answered",
|
|
42
|
+
answer: { decision: "feedback", feedback: "Keep the old name for sports", steer: "adjust", by: "Ed Itor", at: "2026-10-19T09:00:00Z" },
|
|
43
|
+
links: [], history: [], appliedIn: null, updatedAt: "2026-10-19T09:00:00Z",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Notification Center's model (PPY-1cd261, PPY-ab6a50): review requests, the Cyclotron items that wait for a person,
|
|
3
|
+
* the auto-decided stream, and the words the page says about each. Pure functions only, so the page and its tests share them.
|
|
4
|
+
*
|
|
5
|
+
* Principle (docs/notification-center.md): everything except a hard gate keeps moving if nobody acts. Each entry says in words
|
|
6
|
+
* what happens next without the reader, and only a hard gate says "Waiting for you".
|
|
7
|
+
*/
|
|
8
|
+
import type { ReferenceRecord } from "./category-repository";
|
|
9
|
+
import { getNewsroomNavHref } from "./newsroom-nav";
|
|
10
|
+
import { referenceDisplayTitle, referenceLineageId } from "./newsroom-references";
|
|
11
|
+
import { normalizeReferenceCurationStatus } from "./reference-policy";
|
|
12
|
+
import type { RelevanceDecisionView } from "./relevance-decisions";
|
|
13
|
+
|
|
14
|
+
export type ReviewMode = "flag" | "soft_review" | "hard_gate";
|
|
15
|
+
export type ReviewState = "open" | "answered" | "timed_out" | "withdrawn";
|
|
16
|
+
export type ReviewDecision = "approve" | "reject" | "feedback";
|
|
17
|
+
export type ReviewSteer = "adjust" | "not_this_kind";
|
|
18
|
+
|
|
19
|
+
export type ReviewAnswer = { decision: ReviewDecision; feedback: string | null; steer: ReviewSteer | null; by: string; at: string };
|
|
20
|
+
export type ReviewHistoryEntry = { at: string; event: string; by?: string; note?: string };
|
|
21
|
+
export type ReviewLink = { label?: string; url?: string; type?: string; id?: string };
|
|
22
|
+
|
|
23
|
+
export type ReviewRequest = {
|
|
24
|
+
id: string;
|
|
25
|
+
subject: { type: string; id: string };
|
|
26
|
+
mode: ReviewMode;
|
|
27
|
+
reason: string;
|
|
28
|
+
ask: string | null;
|
|
29
|
+
raisedBy: string;
|
|
30
|
+
raisedAt: string;
|
|
31
|
+
deadline: string | null;
|
|
32
|
+
state: ReviewState;
|
|
33
|
+
answer: ReviewAnswer | null;
|
|
34
|
+
links: ReviewLink[];
|
|
35
|
+
history: ReviewHistoryEntry[];
|
|
36
|
+
appliedIn: { runId: string; by: string; at: string; note?: string | null } | null;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PROCEEDED_WITHOUT_REVIEW = "proceeded without review";
|
|
41
|
+
const MAX_HISTORY = 50;
|
|
42
|
+
|
|
43
|
+
function parseJson<T>(value: unknown, fallback: T): T {
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value) as T;
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return value === null || value === undefined ? fallback : (value as T);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A row from the API (JSON fields arrive as strings) as a request. */
|
|
55
|
+
export function normalizeReviewRow(row: Record<string, unknown>): ReviewRequest {
|
|
56
|
+
return {
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
subject: parseJson(row.subject, { type: "unknown", id: "" }),
|
|
59
|
+
mode: row.mode as ReviewMode,
|
|
60
|
+
reason: String(row.reason ?? ""),
|
|
61
|
+
ask: typeof row.ask === "string" ? row.ask : null,
|
|
62
|
+
raisedBy: String(row.raisedBy ?? ""),
|
|
63
|
+
raisedAt: String(row.raisedAt ?? ""),
|
|
64
|
+
deadline: typeof row.deadline === "string" ? row.deadline : null,
|
|
65
|
+
state: row.state as ReviewState,
|
|
66
|
+
answer: parseJson<ReviewAnswer | null>(row.answer, null),
|
|
67
|
+
links: parseJson<ReviewLink[]>(row.links, []),
|
|
68
|
+
history: parseJson<ReviewHistoryEntry[]>(row.history, []),
|
|
69
|
+
appliedIn: parseJson(row.appliedIn, null),
|
|
70
|
+
updatedAt: String(row.updatedAt ?? row.raisedAt ?? ""),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- the deadline (checked on read) ---------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function isDue(request: ReviewRequest, now: Date): boolean {
|
|
77
|
+
return request.mode === "soft_review" && request.state === "open" && request.deadline !== null && new Date(request.deadline).getTime() <= now.getTime();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The update that marks a due soft review as timed out, "proceeded without review". Applied with the condition state = open. */
|
|
81
|
+
export function timeoutUpdate(request: ReviewRequest, now: Date): Record<string, unknown> {
|
|
82
|
+
const at = now.toISOString();
|
|
83
|
+
const history = [...request.history, { at, event: "timed_out", by: "system", note: PROCEEDED_WITHOUT_REVIEW }].slice(-MAX_HISTORY);
|
|
84
|
+
return { id: request.id, state: "timed_out", updatedAt: at, history: JSON.stringify(history) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AnswerInput = { decision: ReviewDecision; feedback?: string | null; steer?: ReviewSteer | null };
|
|
88
|
+
|
|
89
|
+
/** The update for an answer, or an Error saying why it is refused. Applied with the condition state = open. */
|
|
90
|
+
export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: string, now: Date): Record<string, unknown> {
|
|
91
|
+
if (request.state !== "open") throw new Error(`This request is ${request.state.replace("_", " ")}${request.state === "timed_out" ? ` (${PROCEEDED_WITHOUT_REVIEW})` : ""} and can no longer be answered.`);
|
|
92
|
+
const feedback = input.feedback?.trim() || null;
|
|
93
|
+
const steer = input.steer ?? null;
|
|
94
|
+
if (input.decision === "feedback" && !feedback && !steer) throw new Error("Write a note, or choose \"Not this kind of thing\".");
|
|
95
|
+
const at = now.toISOString();
|
|
96
|
+
const answer: ReviewAnswer = { decision: input.decision, feedback, steer, by, at };
|
|
97
|
+
const history = [...request.history, { at, event: "answered", by, note: input.decision }].slice(-MAX_HISTORY);
|
|
98
|
+
return { id: request.id, state: "answered", answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- words ------------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export function formatWhen(iso: string | null | undefined): string {
|
|
104
|
+
if (!iso) return "";
|
|
105
|
+
const date = new Date(iso);
|
|
106
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
107
|
+
return `${date.toISOString().slice(0, 10)} ${date.toISOString().slice(11, 16)} UTC`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function modeLabel(mode: ReviewMode): string {
|
|
111
|
+
return mode === "hard_gate" ? "Hard gate" : mode === "soft_review" ? "Soft review" : "Flag";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A mark and the words, never colour alone. */
|
|
115
|
+
export function modeMark(mode: ReviewMode): string {
|
|
116
|
+
return mode === "hard_gate" ? "■" : mode === "soft_review" ? "◐" : "⚑";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What happens next without the reader. Only a hard gate says "Waiting for you". */
|
|
120
|
+
export function nextText(request: ReviewRequest): string {
|
|
121
|
+
if (request.state === "open") {
|
|
122
|
+
if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you approve or reject.";
|
|
123
|
+
if (request.mode === "soft_review") return `Proceeds automatically on ${formatWhen(request.deadline)} unless you steer it.`;
|
|
124
|
+
return "Nothing is waiting on this. It stays here until you acknowledge it.";
|
|
125
|
+
}
|
|
126
|
+
if (request.state === "timed_out") return `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
|
|
127
|
+
if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
|
|
128
|
+
const answer = request.answer;
|
|
129
|
+
return answer ? `${answerWords(answer)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function answerWords(answer: ReviewAnswer): string {
|
|
133
|
+
if (answer.steer === "not_this_kind") return "Steered: not this kind of thing";
|
|
134
|
+
if (answer.decision === "approve") return "Approved";
|
|
135
|
+
if (answer.decision === "reject") return "Rejected";
|
|
136
|
+
return "Steered with a note";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "You steered X, applied in run Y": what became of a person's input. */
|
|
140
|
+
export function steeringText(request: ReviewRequest): string | null {
|
|
141
|
+
const answer = request.answer;
|
|
142
|
+
if (request.state !== "answered" || !answer || !(answer.feedback || answer.steer || answer.decision === "reject")) return null;
|
|
143
|
+
const said = answer.feedback ? `"${answer.feedback}"` : answerWords(answer).toLowerCase();
|
|
144
|
+
if (request.appliedIn) return `You steered ${said}. Applied in run ${request.appliedIn.runId} on ${formatWhen(request.appliedIn.at)}.`;
|
|
145
|
+
return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
149
|
+
const id = encodeURIComponent(subject.id);
|
|
150
|
+
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
|
151
|
+
if (subject.type === "reference" || subject.type === "cyclotron_decision") return getNewsroomNavHref(`/newsroom/references/${id}`, demo);
|
|
152
|
+
if (subject.type === "proposal") return getNewsroomNavHref(`/newsroom/topics?proposal=${id}`, demo);
|
|
153
|
+
if (subject.type === "automation_run") return getNewsroomNavHref("/newsroom/administration/automations", demo);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Cyclotron items ---------------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export type CyclotronEntry = {
|
|
160
|
+
lineageId: string;
|
|
161
|
+
title: string;
|
|
162
|
+
label: string;
|
|
163
|
+
confidence: number | null;
|
|
164
|
+
decidedAt: string | null;
|
|
165
|
+
reviewDetail: string | null;
|
|
166
|
+
reviewedByPerson: boolean;
|
|
167
|
+
href: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function cyclotronEntry(reference: ReferenceRecord, decision: RelevanceDecisionView, reviewed: ReadonlySet<string>, demo: boolean): CyclotronEntry {
|
|
171
|
+
const lineageId = referenceLineageId(reference);
|
|
172
|
+
return {
|
|
173
|
+
lineageId, title: referenceDisplayTitle(reference), label: decision.label, confidence: decision.confidence,
|
|
174
|
+
decidedAt: decision.decidedAt, reviewDetail: decision.reviewDetail, reviewedByPerson: reviewed.has(lineageId),
|
|
175
|
+
href: getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Items the Cyclotron routed to a person (the References tab's own rule: review recommended and still pending). Least confident first. */
|
|
180
|
+
export function cyclotronHeldForReview(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, demo = false): CyclotronEntry[] {
|
|
181
|
+
const entries: CyclotronEntry[] = [];
|
|
182
|
+
for (const reference of references) {
|
|
183
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
184
|
+
if (decision?.reviewRecommended && normalizeReferenceCurationStatus(reference.curationStatus) === "pending") {
|
|
185
|
+
entries.push(cyclotronEntry(reference, decision, new Set(), demo));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return entries.sort((a, b) => (a.confidence ?? 1) - (b.confidence ?? 1));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Recent classifications the Cyclotron made on its own confidence. Read only here; correcting happens on the reference. Newest first. */
|
|
192
|
+
export function cyclotronAutoDecided(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView>, reviewed: ReadonlySet<string>, demo = false, limit = 20): CyclotronEntry[] {
|
|
193
|
+
const entries: CyclotronEntry[] = [];
|
|
194
|
+
for (const reference of references) {
|
|
195
|
+
const decision = decisions.get(referenceLineageId(reference));
|
|
196
|
+
if (decision && !decision.reviewRecommended) entries.push(cyclotronEntry(reference, decision, reviewed, demo));
|
|
197
|
+
}
|
|
198
|
+
return entries.sort((a, b) => (b.decidedAt ?? "").localeCompare(a.decidedAt ?? "")).slice(0, limit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- the page's shape -------------------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
export type NotificationSections = {
|
|
204
|
+
hardGates: ReviewRequest[];
|
|
205
|
+
softReviews: ReviewRequest[];
|
|
206
|
+
flags: ReviewRequest[];
|
|
207
|
+
/** Answered, timed out or withdrawn, newest first: what people said and what became of it. */
|
|
208
|
+
decided: ReviewRequest[];
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export function sectionReviews(requests: ReviewRequest[]): NotificationSections {
|
|
212
|
+
const open = requests.filter((request) => request.state === "open");
|
|
213
|
+
const byRaised = (a: ReviewRequest, b: ReviewRequest) => a.raisedAt.localeCompare(b.raisedAt);
|
|
214
|
+
return {
|
|
215
|
+
hardGates: open.filter((request) => request.mode === "hard_gate").sort(byRaised),
|
|
216
|
+
softReviews: open.filter((request) => request.mode === "soft_review").sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? "")),
|
|
217
|
+
flags: open.filter((request) => request.mode === "flag").sort((a, b) => b.raisedAt.localeCompare(a.raisedAt)),
|
|
218
|
+
decided: requests.filter((request) => request.state !== "open").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export type NotificationSummary = { waiting: number; proceedingSoon: number; decidedBySystem: number; unread: number };
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* "N waiting for you · N proceeding soon · N recently decided by the system".
|
|
226
|
+
* Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). Proceeding soon = open soft reviews.
|
|
227
|
+
*/
|
|
228
|
+
export function summarize(sections: NotificationSections, held: CyclotronEntry[], autoDecided: CyclotronEntry[], unread: number): NotificationSummary {
|
|
229
|
+
return { waiting: sections.hardGates.length + held.length, proceedingSoon: sections.softReviews.length, decidedBySystem: autoDecided.length, unread };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function summaryText(summary: NotificationSummary): string {
|
|
233
|
+
return `${summary.waiting} waiting for you · ${summary.proceedingSoon} proceeding soon · ${summary.decidedBySystem} recently decided by the system`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
|
|
237
|
+
return !readIds.has(request.id);
|
|
238
|
+
}
|
package/lib/semantic-graph.ts
CHANGED
|
@@ -57,6 +57,7 @@ export type SemanticPredicateId =
|
|
|
57
57
|
| "related_to"
|
|
58
58
|
| "broader_than"
|
|
59
59
|
| "narrower_than"
|
|
60
|
+
| "member_of"
|
|
60
61
|
| "supports"
|
|
61
62
|
| "contradicts";
|
|
62
63
|
|
|
@@ -96,6 +97,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
|
|
|
96
97
|
{ id: "related_to", label: "related to", group: "generic", inverseLabel: "related from" },
|
|
97
98
|
{ id: "broader_than", label: "broader than", group: "ontology", inverseLabel: "narrower concept" },
|
|
98
99
|
{ id: "narrower_than", label: "narrower than", group: "ontology", inverseLabel: "broader concept" },
|
|
100
|
+
{ id: "member_of", label: "member of", group: "ontology", inverseLabel: "members" },
|
|
99
101
|
{ id: "supports", label: "supports", group: "evidence", inverseLabel: "supported by" },
|
|
100
102
|
{ id: "contradicts", label: "contradicts", group: "evidence", inverseLabel: "contradicted by" },
|
|
101
103
|
];
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -9,14 +9,19 @@ export function resolveBrandConfigPath(relativePath: string): string {
|
|
|
9
9
|
return path.isAbsolute(relativePath) ? relativePath : path.join(process.cwd(), relativePath);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Optional repo files a brand may still name; undefined means the site keeps that configuration as backend data and nothing is looked up. */
|
|
13
|
+
function optionalBrandConfigPath(relativePath: string | undefined): string | undefined {
|
|
14
|
+
return relativePath ? resolveBrandConfigPath(relativePath) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
17
|
+
export function getBrandSteeringConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
18
|
+
return optionalBrandConfigPath(brand.steeringConfigPath);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
21
|
+
export function getBrandNewsroomSectionsConfigPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
22
|
+
return optionalBrandConfigPath(brand.newsroomSectionsConfigPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBrandAnalysisProfilesPath(brand: SiteBrand = resolveActiveSiteBrand()): string | undefined {
|
|
26
|
+
return optionalBrandConfigPath(brand.analysisProfilesPath);
|
|
22
27
|
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -142,9 +142,12 @@ export type SiteBrand = {
|
|
|
142
142
|
readerBasePath?: string;
|
|
143
143
|
opsChrome: OpsChrome;
|
|
144
144
|
corpusKey: string;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
/** Optional repo file for topic steering. Editorial and steering configuration is backend data; a data-driven site sets none and nothing is looked up. */
|
|
146
|
+
steeringConfigPath?: string;
|
|
147
|
+
/** Optional repo file for the local-preview demo sections only. Sections live in the backend. */
|
|
148
|
+
newsroomSectionsConfigPath?: string;
|
|
149
|
+
/** Optional repo file listing analysis profiles. */
|
|
150
|
+
analysisProfilesPath?: string;
|
|
148
151
|
publicationName: string;
|
|
149
152
|
components?: SiteBrandComponents;
|
|
150
153
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
import { getSiteEnv } from "./site-env";
|
|
3
|
+
|
|
4
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
|
|
10
|
+
* there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
|
|
11
|
+
* It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
|
|
12
|
+
* The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
|
|
13
|
+
*/
|
|
14
|
+
export class SiteOriginNotConfiguredError extends Error {
|
|
15
|
+
readonly status = 503;
|
|
16
|
+
readonly code = "site_origin_not_configured";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
|
|
20
|
+
return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
24
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function configuredOrigin(value: string): string {
|
|
28
|
+
const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
|
|
29
|
+
let parsed: URL;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw invalid();
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
|
|
36
|
+
return parsed.origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function siteEnvOrNull(environment: EnvironmentVariables): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return getSiteEnv(environment);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The site's public origin, from trusted configuration.
|
|
49
|
+
* - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
|
|
50
|
+
* - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
|
|
51
|
+
* and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
|
|
52
|
+
* SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
|
|
53
|
+
*/
|
|
54
|
+
export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
|
|
55
|
+
const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
|
|
56
|
+
if (configured) return configuredOrigin(configured);
|
|
57
|
+
const { hostname, origin } = request.nextUrl;
|
|
58
|
+
// SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
|
|
59
|
+
const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
|
|
60
|
+
if (isLoopbackHostname(hostname) && developing) return origin;
|
|
61
|
+
throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
|
|
65
|
+
export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
|
|
66
|
+
return new URL(path, publicSiteOrigin(request, environment));
|
|
67
|
+
}
|