@anthusai/papyrus 5.33.1 → 5.34.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/site-backend-config.ts +1 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +1 -1
- package/amplify/site-backend.ts +1 -1
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +4 -2
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-ops-overview.tsx +12 -100
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +2 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +179 -36
- package/components/notification-center.tsx +13 -2
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-cyclotron-suggestion.tsx +9 -5
- package/components/reference-relevance-review.tsx +3 -3
- package/components/topic-steering-workspace.tsx +144 -123
- package/infra/amplify-app-shell.js +4 -0
- package/infra/amplify-app-shell.ts +4 -0
- 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/site-config.js +4 -2
- package/infra/site-config.ts +7 -2
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsroom-organizer-nav.ts +2 -1
- 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/use-reference-review-data.ts +44 -0
- package/package.json +5 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { App } from "aws-cdk-lib";
|
|
3
|
+
import { Template } from "aws-cdk-lib/assertions";
|
|
4
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell";
|
|
5
|
+
import { parseSiteConfig } from "./site-config";
|
|
6
|
+
|
|
7
|
+
const base = () => ({
|
|
8
|
+
siteId: "example", repository: "https://github.com/example/publication", brand: "example",
|
|
9
|
+
frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
|
|
10
|
+
cms: { cognitoDomainPrefix: "example-cms", staging: false,
|
|
11
|
+
environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
|
|
12
|
+
});
|
|
13
|
+
function synth(raw: unknown) {
|
|
14
|
+
const app = new App();
|
|
15
|
+
return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
|
|
16
|
+
env: { account: "123456789012", region: "us-east-1" },
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
const checks: Record<string, () => void> = {
|
|
20
|
+
managed() {
|
|
21
|
+
const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
|
|
22
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 1);
|
|
23
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
|
|
24
|
+
template.resourceCountIs("AWS::Route53::RecordSet", 0);
|
|
25
|
+
template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
|
|
26
|
+
template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
|
|
27
|
+
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
|
|
28
|
+
template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
|
|
29
|
+
EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
30
|
+
CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
|
|
31
|
+
DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
|
|
32
|
+
});
|
|
33
|
+
const text = JSON.stringify(template.toJSON());
|
|
34
|
+
assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
|
|
35
|
+
assert.ok(text.includes("notifications@news.example.test"));
|
|
36
|
+
assert.ok(text.includes("EmailDkimRecord1Name"));
|
|
37
|
+
assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
|
|
38
|
+
},
|
|
39
|
+
existing() {
|
|
40
|
+
const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
|
|
41
|
+
configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
|
|
42
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
43
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
44
|
+
template.resourceCountIs("AWS::SNS::Topic", 0);
|
|
45
|
+
assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
|
|
46
|
+
},
|
|
47
|
+
static() {
|
|
48
|
+
const raw: Record<string, unknown> = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
|
|
49
|
+
delete raw.cms;
|
|
50
|
+
const template = synth(raw);
|
|
51
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
52
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
53
|
+
template.resourceCountIs("AWS::SES::ReceiptRule", 0);
|
|
54
|
+
},
|
|
55
|
+
missing() {
|
|
56
|
+
assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
|
|
57
|
+
assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
export function runCloudEmailTests(selected?: string): void {
|
|
61
|
+
if (selected) { assert.ok(checks[selected], selected); checks[selected](); }
|
|
62
|
+
else for (const check of Object.values(checks)) check();
|
|
63
|
+
console.log("cloud email infrastructure: ok");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
runCloudEmailTests(process.argv[2]);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
|
|
6
|
+
import type { CloudEmailConfig } from "./cloud-email-config";
|
|
7
|
+
|
|
8
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
9
|
+
export function addCloudEmail(stack: Stack, config: CloudEmailConfig): Record<string, string> {
|
|
10
|
+
if (config.mode === "managed") {
|
|
11
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
12
|
+
name: config.configurationSet,
|
|
13
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
14
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
15
|
+
sendingOptions: { sendingEnabled: true },
|
|
16
|
+
});
|
|
17
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
18
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
19
|
+
emailIdentity: config.sendingDomain,
|
|
20
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
21
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
22
|
+
});
|
|
23
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
24
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
25
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
26
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
27
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
28
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
29
|
+
}));
|
|
30
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
31
|
+
configurationSetName: configurationSet.ref,
|
|
32
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
33
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
34
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
35
|
+
});
|
|
36
|
+
events.node.addDependency(topic);
|
|
37
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
38
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
39
|
+
configurationSetName: configurationSet.ref,
|
|
40
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
41
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
42
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
43
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
44
|
+
});
|
|
45
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
46
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
47
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
48
|
+
records.forEach(([name, value], index) => {
|
|
49
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
50
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
54
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
55
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
56
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
57
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
58
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
59
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
60
|
+
}
|
package/infra/site-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
1
2
|
export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
|
|
2
3
|
export function hasCmsApp(config) {
|
|
3
4
|
return config.cms !== undefined;
|
|
@@ -35,7 +36,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
35
36
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
36
37
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
37
38
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
38
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
39
40
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
40
41
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
41
42
|
"GET/v1/identities/me",
|
|
@@ -350,7 +351,7 @@ function parseAws(value) {
|
|
|
350
351
|
}
|
|
351
352
|
export function parseSiteConfig(raw) {
|
|
352
353
|
const record = requireObject(raw, "site");
|
|
353
|
-
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
354
|
+
rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"], "site");
|
|
354
355
|
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
355
356
|
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
356
357
|
const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
|
|
@@ -460,6 +461,7 @@ export function parseSiteConfig(raw) {
|
|
|
460
461
|
stackName,
|
|
461
462
|
aws,
|
|
462
463
|
cms,
|
|
464
|
+
email: parseCloudEmail(record.email, cms !== undefined, siteId),
|
|
463
465
|
reader,
|
|
464
466
|
papyrusVersion,
|
|
465
467
|
storagePreviewPrefix,
|
package/infra/site-config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseCloudEmail, type CloudEmailConfig } from "./cloud-email-config";
|
|
2
|
+
|
|
1
3
|
export type CmsRedirect = {
|
|
2
4
|
source: string;
|
|
3
5
|
status: 301 | 302;
|
|
@@ -63,6 +65,8 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
63
65
|
stackName?: string;
|
|
64
66
|
aws?: SiteAwsConfig;
|
|
65
67
|
cms?: CmsConfig;
|
|
68
|
+
/** Required for cloud CMS sites; independent of newsletter feature enablement. */
|
|
69
|
+
email?: CloudEmailConfig;
|
|
66
70
|
reader?: {
|
|
67
71
|
appName?: string;
|
|
68
72
|
domainName?: string;
|
|
@@ -157,7 +161,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
|
157
161
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
158
162
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
159
163
|
const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
|
|
160
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
164
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_EMAIL_FROM_ADDRESS", "PAPYRUS_EMAIL_IDENTITY_ARN", "PAPYRUS_EMAIL_CONFIGURATION_SET", "PAPYRUS_EMAIL_EVENT_TOPIC_NAME", "PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_READER_POLL_SECRET_PARAMETER", "PAPYRUS_SITE_ORIGIN"];
|
|
161
165
|
|
|
162
166
|
/** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
|
|
163
167
|
export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
|
|
@@ -490,7 +494,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
490
494
|
const record = requireObject(raw, "site");
|
|
491
495
|
rejectUnknownKeys(
|
|
492
496
|
record,
|
|
493
|
-
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
497
|
+
["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "email", "reader", "papyrusVersion", "storagePreviewPrefix", "knowledgeRun", "autoDeploy", "github"],
|
|
494
498
|
"site",
|
|
495
499
|
);
|
|
496
500
|
|
|
@@ -600,6 +604,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
600
604
|
stackName,
|
|
601
605
|
aws,
|
|
602
606
|
cms,
|
|
607
|
+
email: parseCloudEmail(record.email, cms !== undefined, siteId),
|
|
603
608
|
reader,
|
|
604
609
|
papyrusVersion,
|
|
605
610
|
storagePreviewPrefix,
|
|
@@ -187,8 +187,8 @@ export function curationForSuggestionChoice(decision: RelevanceDecisionView, cho
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
export type ProvenanceSummary = {
|
|
190
|
-
teachingLabelsSentence: string;
|
|
191
|
-
primingExamplesSentence: string;
|
|
190
|
+
teachingLabelsSentence: string | null;
|
|
191
|
+
primingExamplesSentence: string | null;
|
|
192
192
|
doctrineShortHash: string | null;
|
|
193
193
|
modelVersion: string | null;
|
|
194
194
|
decidedAt: string | null;
|
|
@@ -202,8 +202,8 @@ export function provenanceSummary(decision: RelevanceDecisionView): ProvenanceSu
|
|
|
202
202
|
if (!provenance) return null;
|
|
203
203
|
const plural = (count: number, singular: string) => `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
204
204
|
return {
|
|
205
|
-
teachingLabelsSentence: `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
-
primingExamplesSentence: `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
205
|
+
teachingLabelsSentence: provenance.teachingLabels === null ? null : `Based on ${plural(provenance.teachingLabels, "decision")} that editors made themselves`,
|
|
206
|
+
primingExamplesSentence: provenance.primingExamples === null ? null : `${plural(provenance.primingExamples, "example reference")} supplied as starting examples`,
|
|
207
207
|
doctrineShortHash: provenance.doctrineFingerprint ? provenance.doctrineFingerprint.slice(0, SHORT_HASH_LENGTH) : null,
|
|
208
208
|
modelVersion: decision.version === null ? null : `revision ${decision.version}`,
|
|
209
209
|
decidedAt: decision.decidedAt,
|
package/lib/news-desk-session.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { CategorySteeringDashboard } from "./category-repository";
|
|
2
|
-
import { createEmptyCategorySteeringDashboard } from "./category-dashboard";
|
|
3
2
|
import type { ReaderAuthSnapshot } from "../components/reader-auth-state";
|
|
4
3
|
|
|
5
4
|
export type NewsDeskShellPhase =
|
|
@@ -59,26 +58,6 @@ export function beginDeskLoad(state: NewsDeskShellState, auth: ReaderAuthSnapsho
|
|
|
59
58
|
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
export function beginAuthenticatedDeskHydration(
|
|
63
|
-
state: NewsDeskShellState,
|
|
64
|
-
auth: ReaderAuthSnapshot,
|
|
65
|
-
options: { canManageUsers: boolean },
|
|
66
|
-
): NewsDeskShellState {
|
|
67
|
-
const dashboard = state.dashboard ?? {
|
|
68
|
-
...createEmptyCategorySteeringDashboard(),
|
|
69
|
-
isPublicSkeleton: false,
|
|
70
|
-
canManageUsers: options.canManageUsers,
|
|
71
|
-
};
|
|
72
|
-
return {
|
|
73
|
-
...state,
|
|
74
|
-
phase: state.dashboard ? "refreshing" : "loadingDesk",
|
|
75
|
-
auth,
|
|
76
|
-
dashboard,
|
|
77
|
-
stale: false,
|
|
78
|
-
error: null,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
export function resolveSignedOut(state: NewsDeskShellState, auth: ReaderAuthSnapshot): NewsDeskShellState {
|
|
83
62
|
return {
|
|
84
63
|
...state,
|
|
@@ -143,6 +122,18 @@ export function resolveDashboardFailure(
|
|
|
143
122
|
};
|
|
144
123
|
}
|
|
145
124
|
|
|
125
|
+
/** An unverified session must never leave private records on screen. */
|
|
126
|
+
export function resolveAccessFailure(state: NewsDeskShellState, error: string): NewsDeskShellState {
|
|
127
|
+
return {
|
|
128
|
+
...state,
|
|
129
|
+
phase: "error",
|
|
130
|
+
auth: { status: "signedOut", label: "Signed out" },
|
|
131
|
+
dashboard: null,
|
|
132
|
+
stale: false,
|
|
133
|
+
error,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
146
137
|
export function patchDashboard(
|
|
147
138
|
state: NewsDeskShellState,
|
|
148
139
|
patch: (dashboard: CategorySteeringDashboard) => CategorySteeringDashboard,
|
|
@@ -8,6 +8,7 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
+
{ key: "overview", label: "Overview", description: "See notifications and work needing attention.", path: "/newsroom", primary: true },
|
|
11
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
12
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
13
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
@@ -23,7 +24,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
23
24
|
] },
|
|
24
25
|
];
|
|
25
26
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
26
|
-
export const ORGANIZER_PRIMARY_NAV = ["articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
27
28
|
|
|
28
29
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
29
30
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HydratedModelPayload, MessageRecord, SemanticRelationRecord } from "./category-repository";
|
|
2
|
+
import { normalizeReferenceRejectionReasonCode, type ReferenceRejectionReasonCode } from "./reference-policy";
|
|
3
|
+
|
|
4
|
+
function metadataRecord(value: unknown): Record<string, unknown> {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
try { return metadataRecord(JSON.parse(value)); } catch { return {}; }
|
|
7
|
+
}
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The selected Reference's index is authoritative even when the global feed is truncated. */
|
|
12
|
+
export async function loadReferenceReviewDetails(lineageId: string, read: {
|
|
13
|
+
relations: (direction: "incoming" | "outgoing") => Promise<SemanticRelationRecord[]>;
|
|
14
|
+
message: (id: string) => Promise<MessageRecord | null>;
|
|
15
|
+
payloads: (id: string) => Promise<HydratedModelPayload[]>;
|
|
16
|
+
}): Promise<{ relations: SemanticRelationRecord[]; messages: MessageRecord[] }> {
|
|
17
|
+
const [outgoing, incoming] = await Promise.all([read.relations("outgoing"), read.relations("incoming")]);
|
|
18
|
+
const relations = outgoing.filter((relation) => relation.subjectKind === "reference" && relation.subjectLineageId === lineageId);
|
|
19
|
+
const latest = incoming.filter((relation) => relation.predicate === "comment" && relation.relationState === "current"
|
|
20
|
+
&& relation.subjectKind === "message" && relation.objectKind === "reference" && relation.objectLineageId === lineageId
|
|
21
|
+
&& metadataRecord(relation.metadata).messageKind === "reference_curation")
|
|
22
|
+
.sort((a, b) => String(b.createdAt ?? b.importedAt ?? "").localeCompare(String(a.createdAt ?? a.importedAt ?? "")))[0];
|
|
23
|
+
if (!latest) return { relations, messages: [] };
|
|
24
|
+
const message = await read.message(latest.subjectId);
|
|
25
|
+
if (!message || message.messageKind !== "reference_curation") throw new Error("The latest reference review could not be loaded.");
|
|
26
|
+
const payload = (await read.payloads(message.id))
|
|
27
|
+
.filter((entry) => entry.attachment.role === "metadata" && entry.attachment.ownerKind === "message" && entry.attachment.ownerId === message.id)
|
|
28
|
+
.sort((a, b) => String(b.attachment.updatedAt ?? "").localeCompare(String(a.attachment.updatedAt ?? "")))[0];
|
|
29
|
+
if (!payload) throw new Error("The latest reference review metadata could not be loaded.");
|
|
30
|
+
if (payload.error) throw new Error(payload.error);
|
|
31
|
+
if (!payload.json || typeof payload.json !== "object" || Array.isArray(payload.json)) {
|
|
32
|
+
throw new Error("The latest reference review metadata is invalid.");
|
|
33
|
+
}
|
|
34
|
+
const hydrated = { ...message, metadata: payload.json };
|
|
35
|
+
const recordedLineage = metadataRecord(hydrated.metadata).referenceLineageId;
|
|
36
|
+
if (recordedLineage !== lineageId) throw new Error("The latest review metadata does not match this reference lineage.");
|
|
37
|
+
return { relations, messages: [hydrated] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function latestReferenceReviews(messages: MessageRecord[]): Map<string, MessageRecord> {
|
|
41
|
+
const latest = new Map<string, MessageRecord>();
|
|
42
|
+
for (const message of messages) {
|
|
43
|
+
if (message.messageKind !== "reference_curation") continue;
|
|
44
|
+
const lineage = metadataRecord(message.metadata).referenceLineageId;
|
|
45
|
+
if (typeof lineage !== "string" || !lineage) continue;
|
|
46
|
+
if (!latest.has(lineage) || String(message.createdAt).localeCompare(String(latest.get(lineage)!.createdAt)) > 0) latest.set(lineage, message);
|
|
47
|
+
}
|
|
48
|
+
return latest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A later acceptance or archive clears any earlier rejection reason. */
|
|
52
|
+
export function latestReferenceReviewReasons(messages: MessageRecord[]): Map<string, ReferenceRejectionReasonCode> {
|
|
53
|
+
const reasons = new Map<string, ReferenceRejectionReasonCode>();
|
|
54
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
55
|
+
const metadata = metadataRecord(message.metadata);
|
|
56
|
+
const reason = metadata.action === "reject" ? normalizeReferenceRejectionReasonCode(metadata.reasonCode) : null;
|
|
57
|
+
if (reason) reasons.set(lineage, reason);
|
|
58
|
+
}
|
|
59
|
+
return reasons;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Preserve only a Section explicitly recorded on the newest acceptance, never a prediction. */
|
|
63
|
+
export function latestReferenceReviewSections(messages: MessageRecord[]): Map<string, string> {
|
|
64
|
+
const sections = new Map<string, string>();
|
|
65
|
+
for (const [lineage, message] of latestReferenceReviews(messages)) {
|
|
66
|
+
const metadata = metadataRecord(message.metadata);
|
|
67
|
+
const sectionId = metadata.action === "accept" && typeof metadata.sectionId === "string" ? metadata.sectionId.trim() : "";
|
|
68
|
+
if (sectionId) sections.set(lineage, sectionId);
|
|
69
|
+
}
|
|
70
|
+
return sections;
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Hold the lock before invoking a save, including before React can rerender. */
|
|
2
|
+
export function runReferenceReviewRequest(
|
|
3
|
+
pending: Map<string, Promise<void>>,
|
|
4
|
+
lineageId: string,
|
|
5
|
+
save: () => Promise<void>,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
const existing = pending.get(lineageId);
|
|
8
|
+
if (existing) return existing;
|
|
9
|
+
const request = Promise.resolve().then(save).finally(() => {
|
|
10
|
+
if (pending.get(lineageId) === request) pending.delete(lineageId);
|
|
11
|
+
});
|
|
12
|
+
pending.set(lineageId, request);
|
|
13
|
+
return request;
|
|
14
|
+
}
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** Load persisted predictions and attributed review history without cancelling on a loading rerender. */
|
|
6
|
+
export function useReferenceReviewData<Relation, Message>({
|
|
7
|
+
enabled,
|
|
8
|
+
loadRelations,
|
|
9
|
+
loadMessages,
|
|
10
|
+
onRelations,
|
|
11
|
+
onMessages,
|
|
12
|
+
}: {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
loadRelations: () => Promise<Relation[]>;
|
|
15
|
+
loadMessages: () => Promise<Message[]>;
|
|
16
|
+
onRelations: (relations: Relation[]) => void;
|
|
17
|
+
onMessages: (messages: Message[]) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [state, setState] = useState<"loading" | "ready" | "error">(enabled ? "loading" : "ready");
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [attempt, setAttempt] = useState(0);
|
|
22
|
+
const retry = useCallback(() => setAttempt((current) => current + 1), []);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
let active = true;
|
|
26
|
+
setState("loading");
|
|
27
|
+
setError(null);
|
|
28
|
+
void Promise.allSettled([loadRelations(), loadMessages()]).then(([relations, history]) => {
|
|
29
|
+
if (!active) return;
|
|
30
|
+
if (relations.status === "fulfilled") onRelations(relations.value);
|
|
31
|
+
if (history.status === "fulfilled") onMessages(history.value);
|
|
32
|
+
const failure = relations.status === "rejected" ? relations.reason : history.status === "rejected" ? history.reason : null;
|
|
33
|
+
if (failure) {
|
|
34
|
+
const subject = relations.status === "rejected" ? "Predictions" : "Review history";
|
|
35
|
+
setError(`${subject} could not be loaded. ${failure instanceof Error ? failure.message : "Try again."}`);
|
|
36
|
+
setState("error");
|
|
37
|
+
} else {
|
|
38
|
+
setState("ready");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return () => { active = false; };
|
|
42
|
+
}, [enabled, loadRelations, loadMessages, onRelations, onMessages, attempt]);
|
|
43
|
+
return { state, error, retry };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
"./components/newsroom-curation-provider": "./components/newsroom-curation-provider.tsx",
|
|
109
109
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
110
110
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
111
|
+
"./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
|
|
111
112
|
"./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
|
|
112
113
|
"./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
|
|
113
114
|
"./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
|
|
@@ -298,6 +299,8 @@
|
|
|
298
299
|
"./lib/reader-subject": "./lib/reader-subject.ts",
|
|
299
300
|
"./lib/reference-display": "./lib/reference-display.ts",
|
|
300
301
|
"./lib/reference-policy": "./lib/reference-policy.ts",
|
|
302
|
+
"./lib/reference-review-details": "./lib/reference-review-details.ts",
|
|
303
|
+
"./lib/reference-review-request": "./lib/reference-review-request.ts",
|
|
301
304
|
"./lib/reference-source-preview": "./lib/reference-source-preview.ts",
|
|
302
305
|
"./lib/relevance-decisions": "./lib/relevance-decisions.ts",
|
|
303
306
|
"./lib/relevance-status": "./lib/relevance-status.ts",
|
|
@@ -329,6 +332,7 @@
|
|
|
329
332
|
"./lib/test-editor-override": "./lib/test-editor-override.ts",
|
|
330
333
|
"./lib/text-columns": "./lib/text-columns.ts",
|
|
331
334
|
"./lib/themed-image": "./lib/themed-image.ts",
|
|
335
|
+
"./lib/use-reference-review-data": "./lib/use-reference-review-data.ts",
|
|
332
336
|
"./lib/utils": "./lib/utils.ts",
|
|
333
337
|
"./lib/video-loading": "./lib/video-loading.ts",
|
|
334
338
|
"./lib/video-mode": "./lib/video-mode.ts",
|
|
@@ -444,7 +448,7 @@
|
|
|
444
448
|
"optional": true
|
|
445
449
|
}
|
|
446
450
|
},
|
|
447
|
-
"version": "5.
|
|
451
|
+
"version": "5.34.0",
|
|
448
452
|
"dependencies": {
|
|
449
453
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
450
454
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED