@anthusai/papyrus 5.19.1 → 5.20.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.
Files changed (43) hide show
  1. package/amplify/data/resource.js +1 -0
  2. package/amplify/data/resource.ts +1 -0
  3. package/amplify/data/schema.js +1 -0
  4. package/amplify/data/schema.ts +1 -0
  5. package/amplify/functions/assignment-action/data-access.js +2 -0
  6. package/amplify/functions/assignment-action/data-access.ts +2 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +3 -0
  9. package/amplify/functions/shared/editorial-context.ts +63 -4
  10. package/amplify/plugins/reader-steering/config.js +111 -0
  11. package/amplify/plugins/reader-steering/config.ts +158 -0
  12. package/amplify/plugins/reader-steering/function/handler.py +544 -0
  13. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  14. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +139 -0
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +162 -0
  17. package/amplify/plugins/reader-steering/schema.js +40 -0
  18. package/amplify/plugins/reader-steering/schema.ts +42 -0
  19. package/amplify/plugins/registry.js +2 -0
  20. package/amplify/plugins/registry.ts +2 -0
  21. package/amplify/plugins/types.ts +3 -0
  22. package/amplify/plugins/wire.js +1 -0
  23. package/amplify/plugins/wire.ts +1 -0
  24. package/app/api/reader/handle/route.ts +9 -0
  25. package/app/api/reader/me/route.ts +9 -0
  26. package/app/api/reader/rating/route.ts +9 -0
  27. package/app/api/reader/retired/route.ts +9 -0
  28. package/app/api/reader/vote/route.ts +9 -0
  29. package/components/reference-cyclotron-priming.tsx +54 -15
  30. package/infra/amplify-app-shell.js +15 -1
  31. package/infra/amplify-app-shell.ts +17 -1
  32. package/infra/site-config.js +12 -2
  33. package/infra/site-config.ts +19 -2
  34. package/lib/editorial-checks.ts +6 -2
  35. package/lib/prospectus-session.ts +1 -0
  36. package/lib/prospectus-sign-in-routes.ts +2 -2
  37. package/lib/prospectus-site-identity.ts +24 -1
  38. package/lib/reader-api.ts +143 -0
  39. package/lib/reader-retirement-route.ts +89 -0
  40. package/lib/reader-subject.md +29 -0
  41. package/lib/reader-subject.ts +53 -0
  42. package/package.json +9 -1
  43. package/routes.manifest.json +56 -1
@@ -856,6 +856,7 @@ const schema = a.schema({
856
856
  storyLineageId: a.id(),
857
857
  assignmentId: a.id(),
858
858
  maxCharacters: a.integer(),
859
+ optionalPublicationDoctrine: a.boolean(),
859
860
  })
860
861
  .returns(a.json().required())
861
862
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
@@ -931,6 +931,7 @@ const schema = a.schema({
931
931
  storyLineageId: a.id(),
932
932
  assignmentId: a.id(),
933
933
  maxCharacters: a.integer(),
934
+ optionalPublicationDoctrine: a.boolean(),
934
935
  })
935
936
  .returns(a.json().required())
936
937
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
@@ -781,6 +781,7 @@ function buildBaseDefinitions(includeEditions) {
781
781
  storyLineageId: a.id(),
782
782
  assignmentId: a.id(),
783
783
  maxCharacters: a.integer(),
784
+ optionalPublicationDoctrine: a.boolean(),
784
785
  })
785
786
  .returns(a.json().required())
786
787
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
@@ -854,6 +854,7 @@ const definitions = {
854
854
  storyLineageId: a.id(),
855
855
  assignmentId: a.id(),
856
856
  maxCharacters: a.integer(),
857
+ optionalPublicationDoctrine: a.boolean(),
857
858
  })
858
859
  .returns(a.json().required())
859
860
  .authorization((allow) => [allow.groups(categoryWriteGroups)])
@@ -9,6 +9,8 @@ export const ASSIGNMENT_DATA_FIELDS = [
9
9
  "Query/fields/getProcedureDefinition",
10
10
  "Query/fields/getModelAttachment",
11
11
  "Query/fields/getNewsroomSection",
12
+ "Query/fields/listNewsroomSections",
13
+ "Query/fields/listNewsroomSectionsByTenantAndSortOrder",
12
14
  "Query/fields/getKnowledgeCorpus",
13
15
  "Query/fields/getKnowledgeImportRun",
14
16
  "Query/fields/getReference",
@@ -11,6 +11,8 @@ export const ASSIGNMENT_DATA_FIELDS = [
11
11
  "Query/fields/getProcedureDefinition",
12
12
  "Query/fields/getModelAttachment",
13
13
  "Query/fields/getNewsroomSection",
14
+ "Query/fields/listNewsroomSections",
15
+ "Query/fields/listNewsroomSectionsByTenantAndSortOrder",
14
16
  "Query/fields/getKnowledgeCorpus",
15
17
  "Query/fields/getKnowledgeImportRun",
16
18
  "Query/fields/getReference",
@@ -316,7 +316,7 @@ async function listAssignmentsForObject(event: Parameters<Schema["listAssignment
316
316
  return contexts;
317
317
  }
318
318
 
319
- async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null }) {
319
+ async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null; optionalPublicationDoctrine?: boolean | null }) {
320
320
  if (!editorialEndpoint) throw new Error("Editorial context requires the configured backend endpoint.");
321
321
  return resolveEditorialContext(client as unknown as EditorialClient, {
322
322
  ...scope, endpoint: editorialEndpoint, tenantId: tenantIdFromEnvironment(), readAttachment: readEditorialAttachment,
@@ -9,6 +9,7 @@ from papyrus_content.graphql_authoring import create_authoring_client
9
9
  from papyrus_content.markus_renderer.derive import derive_body
10
10
  from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
11
11
  from papyrus_content.projection.engine import project, project_all, project_item_pages
12
+ from papyrus_content.reader_steering.task import TASK_NAME as READER_SNAPSHOT_TASK, run_reader_snapshot
12
13
  from papyrus_content.projection.runtime import (
13
14
  DEAD_LETTER_QUEUE_ENV,
14
15
  context_from_environment,
@@ -187,6 +188,8 @@ def run_projection_task(task: dict) -> dict:
187
188
  report = project_all(projection_context(client))
188
189
  elif task.get("task") == "deploy-render":
189
190
  return run_deploy_render_task(client)
191
+ elif task.get("task") == READER_SNAPSHOT_TASK:
192
+ return run_reader_snapshot(projection_context(client), client, poll_retirements=task.get("retirementPoll") is True).to_dict()
190
193
  elif task.get("task") == "invalidate":
191
194
  invalidate = projection_context(client).invalidate
192
195
  if invalidate is None:
@@ -13,6 +13,10 @@ export type EditorialOptions = {
13
13
  storyLineageId?: string | null;
14
14
  assignmentId?: string | null;
15
15
  maxCharacters?: number | null;
16
+ /** A publication without a current mission or policy record yields missingPublicationDoctrine instead of an error, and a
17
+ * section value that matches no NewsroomSection yields missingSectionConfiguration instead of an error.
18
+ * Only the editorial check asks for this (its rule lane needs neither); more than one current record is still an error. */
19
+ optionalPublicationDoctrine?: boolean | null;
16
20
  readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
21
  };
18
22
 
@@ -38,6 +42,47 @@ async function get(client: EditorialClient, model: string, id: string) {
38
42
  return row;
39
43
  }
40
44
 
45
+ const slugOf = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46
+ const localPart = (id: string) => id.split("#").at(-1) ?? id;
47
+
48
+ async function allSections(client: EditorialClient) {
49
+ const rows: EditorialRecord[] = [];
50
+ let nextToken: string | null | undefined;
51
+ do {
52
+ const response = checked(await client.models.NewsroomSection.list({ limit: 100, nextToken }), "read NewsroomSection list");
53
+ rows.push(...(response.data as EditorialRecord[] ?? []));
54
+ nextToken = response.nextToken;
55
+ } while (nextToken);
56
+ return rows;
57
+ }
58
+
59
+ /** An item stores its section as an id, a slug or a name; find the NewsroomSection of this publication it means.
60
+ * Order: id (as given, then tenant-scoped), then slug (the id's local part), then name (title or short title).
61
+ * Returns null when nothing matches; two sections matching at the same step is an error. */
62
+ async function findSection(client: EditorialClient, value: string, tenantId?: string) {
63
+ const direct = checked(await client.models.NewsroomSection.get({ id: value }), "read NewsroomSection").data as EditorialRecord | null;
64
+ if (direct) return direct;
65
+ if (tenantId) {
66
+ const scoped = checked(await client.models.NewsroomSection.get({ id: tenantScopedValue(tenantId, value) }), "read NewsroomSection").data as EditorialRecord | null;
67
+ if (scoped) return scoped;
68
+ }
69
+ const all = tenantId
70
+ ? await pages(client, "NewsroomSection", "listNewsroomSectionsByTenantAndSortOrder", { tenantId })
71
+ : (await allSections(client)).filter(row => !row.tenantId);
72
+ const wanted = slugOf(value);
73
+ const steps: ((row: EditorialRecord) => boolean)[] = [
74
+ row => slugOf(localPart(String(row.id ?? ""))) === wanted,
75
+ row => [row.title, row.shortTitle].some(name => typeof name === "string" && name.trim().toLowerCase() === value.trim().toLowerCase()),
76
+ row => [row.title, row.shortTitle].some(name => typeof name === "string" && slugOf(name) === wanted),
77
+ ];
78
+ for (const step of steps) {
79
+ const matches = all.filter(step);
80
+ if (matches.length > 1) throw new Error(`Section "${value}" is ambiguous: ${matches.map(row => row.id).join(", ")}.`);
81
+ if (matches.length === 1) return matches[0];
82
+ }
83
+ return null;
84
+ }
85
+
41
86
  function tenant(row: EditorialRecord, tenantId?: string) {
42
87
  if ((row.tenantId || null) !== (tenantId || null)) throw new Error("Editorial record tenant does not match the configured authoring scope.");
43
88
  }
@@ -55,7 +100,7 @@ function entry(scope: string, kind: string, text: string, record: EditorialRecor
55
100
  } };
56
101
  }
57
102
 
58
- export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
103
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0, missingPublicationDoctrine: string[] = [], missingSectionConfiguration: string[] = []) {
59
104
  const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
105
  "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
106
  "Private operating guidance; not evidence or reader-facing copy."];
@@ -65,7 +110,9 @@ export function buildEditorialContext(publication: EditorialRecord, scope: Edito
65
110
  if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
111
  if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
112
  const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
68
- precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
113
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters,
114
+ ...(missingPublicationDoctrine.length ? { missingPublicationDoctrine } : {}),
115
+ ...(missingSectionConfiguration.length ? { missingSectionConfiguration } : {}) };
69
116
  return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
117
  }
71
118
 
@@ -83,7 +130,10 @@ export async function resolveEditorialContext(client: EditorialClient, options:
83
130
  corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
131
  const entries: EditorialRecord[] = [];
85
132
  const warnings: string[] = [];
133
+ const missingPublicationDoctrine: string[] = [];
86
134
  let sectionId = options.sectionId || null;
135
+ let requestedSection: string | null = null;
136
+ const missingSectionConfiguration: string[] = [];
87
137
  let storyLineageId = options.storyLineageId || null;
88
138
  const assignmentId = options.assignmentId || null;
89
139
  let assignment: EditorialRecord | undefined;
@@ -110,6 +160,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
110
160
  const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
161
  options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
162
  const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
163
+ if (current.length === 0 && options.optionalPublicationDoctrine) { missingPublicationDoctrine.push(kind); continue; }
113
164
  if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
165
  const record = current[0];
115
166
  tenant(record, options.tenantId);
@@ -118,13 +169,19 @@ export async function resolveEditorialContext(client: EditorialClient, options:
118
169
  entries.push(entry("publication", kind, text, record));
119
170
  }
120
171
  if (sectionId) {
121
- const section = await get(client, "NewsroomSection", sectionId);
172
+ const section = await findSection(client, sectionId, options.tenantId);
173
+ if (!section && !options.optionalPublicationDoctrine) throw new Error(`NewsroomSection not found: ${sectionId}`);
174
+ if (!section) { missingSectionConfiguration.push(sectionId); requestedSection = sectionId; sectionId = null; }
175
+ else {
122
176
  tenant(section, options.tenantId);
177
+ if (section.id !== sectionId) requestedSection = sectionId;
178
+ sectionId = section.id;
123
179
  for (const [kind, key] of [["mission", "editorialMission"], ["policy", "editorialPolicy"], ["guidance", "assignmentGuidance"], ["kill criteria", "killCriteria"]]) {
124
180
  const text = String(section[key] || "").trim();
125
181
  if (text) entries.push(entry("section", kind, text, section));
126
182
  else if (kind === "mission" || kind === "policy") warnings.push(`Section ${section.id} intentionally has an empty ${kind} slot; publication doctrine still applies.`);
127
183
  }
184
+ }
128
185
  }
129
186
  if (assignment) {
130
187
  if (options.tenantId && !storyLineageId && !sectionId) throw new Error("A tenant assignment needs a scoped story or section to establish its publication.");
@@ -147,7 +204,9 @@ export async function resolveEditorialContext(client: EditorialClient, options:
147
204
  warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
205
  }
149
206
  }
150
- return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
207
+ // requestedSection: the value the caller asked for when it was not the section's id (a slug or name, or nothing matched).
208
+ const scope = { sectionId, storyLineageId, assignmentId, ...(requestedSection ? { requestedSection } : {}) };
209
+ return buildEditorialContext(publication, scope, entries, warnings, options.maxCharacters ?? 0, missingPublicationDoctrine, missingSectionConfiguration);
151
210
  }
152
211
 
153
212
  export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
@@ -0,0 +1,111 @@
1
+ export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
2
+ export const RESERVED_DIMENSIONS = ["up", "used-it"];
3
+ export const READER_HANDLE_NAME = /^[a-z0-9_-]{3,24}$/;
4
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
5
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
6
+ function object(raw, label, known) {
7
+ if (raw === undefined)
8
+ return {};
9
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
10
+ throw new Error(`reader-steering plugin ${label} must be an object`);
11
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
12
+ if (unknown.length > 0)
13
+ throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
14
+ return raw;
15
+ }
16
+ function integer(raw, label, fallback, minimum, maximum) {
17
+ const value = raw === undefined ? fallback : raw;
18
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
19
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
20
+ }
21
+ return value;
22
+ }
23
+ function number(raw, label, fallback, minimum, maximum) {
24
+ const value = raw === undefined ? fallback : raw;
25
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
26
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
27
+ }
28
+ return value;
29
+ }
30
+ function flag(raw, label) {
31
+ if (raw === undefined)
32
+ return false;
33
+ if (typeof raw !== "boolean")
34
+ throw new Error(`reader-steering plugin ${label} must be a boolean`);
35
+ return raw;
36
+ }
37
+ function names(raw, label, pattern) {
38
+ if (raw === undefined)
39
+ return [];
40
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
41
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
42
+ }
43
+ return [...new Set(raw)];
44
+ }
45
+ function resolveHypeGap(raw, dimensions) {
46
+ if (raw === undefined)
47
+ return undefined;
48
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
49
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
50
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
51
+ if (plus.length === 0 || minus.length === 0)
52
+ throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
53
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
54
+ if (unknown.length > 0)
55
+ throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
56
+ const both = plus.filter((dimension) => minus.includes(dimension));
57
+ if (both.length > 0)
58
+ throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
59
+ return { plus, minus };
60
+ }
61
+ export function resolveReaderSteeringConfig(request) {
62
+ const raw = typeof request === "string" ? { name: request } : request;
63
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
64
+ if (unknown.length > 0)
65
+ throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
66
+ const computeRoleArn = raw.computeRoleArn;
67
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
68
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
69
+ }
70
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
71
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
72
+ if (reserved.length > 0)
73
+ throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
74
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
75
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
76
+ const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
77
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
78
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
79
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
80
+ if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
81
+ throw new Error("reader-steering plugin retirementPoll runs inside the scheduled snapshot, so it needs snapshot.schedule: true");
82
+ }
83
+ return {
84
+ computeRoleArn,
85
+ dimensions,
86
+ limits: {
87
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
88
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
89
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
90
+ },
91
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
92
+ karma: {
93
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
94
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
95
+ },
96
+ ranking: {
97
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
98
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
99
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
100
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
101
+ },
102
+ snapshot: {
103
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
104
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
105
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
106
+ },
107
+ retirementPoll,
108
+ ...(hypeGap ? { hypeGap } : {}),
109
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
110
+ };
111
+ }
@@ -0,0 +1,158 @@
1
+ import type { PluginRequest } from "../types";
2
+
3
+ export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
4
+ export const RESERVED_DIMENSIONS = ["up", "used-it"];
5
+ export const READER_HANDLE_NAME = /^[a-z0-9_-]{3,24}$/;
6
+ export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
7
+
8
+ export type ReaderSteeringConfig = {
9
+ /** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of reader-actions. */
10
+ computeRoleArn: string;
11
+ /** Rating dimensions of the publication, such as `real` and `useful`. `up` and `used-it` are always available. */
12
+ dimensions: string[];
13
+ limits: {
14
+ /** Votes and ratings one reader may make in a clock hour. */
15
+ actionsPerHour: number;
16
+ /** Readers whose profile is younger than this many hours get `actionsPerHour / newAccountDivisor`. */
17
+ newAccountHours: number;
18
+ newAccountDivisor: number;
19
+ };
20
+ /** Handles nobody may take, on top of the built-in list. Lowercase. */
21
+ reservedHandles: string[];
22
+ karma: { upvoteWeight: number; usedItWeight: number };
23
+ ranking: {
24
+ /** Front: the editor composite loses half its weight every this many hours. */
25
+ halfLifeHours: number;
26
+ /** Rising counts upvotes in this window. */
27
+ risingWindowHours: number;
28
+ /** Reader ratings per item needed before the reader composite counts in Front. */
29
+ readerMinimum: number;
30
+ /** Weight of the editor composite in Front once readers have rated (the reader composite gets the rest). */
31
+ editorWeight: number;
32
+ };
33
+ snapshot: {
34
+ /** An EventBridge rule runs the snapshot every `intervalHours`. Off by default. */
35
+ schedule: boolean;
36
+ intervalHours: number;
37
+ /** A hype gap needs at least this many ratings on every dimension involved. */
38
+ hypeMinimum: number;
39
+ };
40
+ /**
41
+ * The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
42
+ * Absent means no hype gap is published. Shipped sets plus = notable, novel and minus = real, useful.
43
+ */
44
+ hypeGap?: { plus: string[]; minus: string[] };
45
+ /**
46
+ * Reserved concurrency for reader-actions. Unset by default, which reserves nothing: an account with a small total
47
+ * Lambda concurrency quota (papyrus-production has 10) cannot reserve any without dropping below its unreserved minimum.
48
+ */
49
+ reservedConcurrency?: number;
50
+ /**
51
+ * Poll Prospectus for retired reader subjects in each scheduled snapshot run (requires `snapshot.schedule`, and a site
52
+ * whose app-shell sets `cms.readerRetirementPoll`). Off by default.
53
+ */
54
+ retirementPoll: boolean;
55
+ };
56
+
57
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
58
+
59
+ function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
60
+ if (raw === undefined) return {};
61
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`reader-steering plugin ${label} must be an object`);
62
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
63
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
64
+ return raw as Record<string, unknown>;
65
+ }
66
+
67
+ function integer(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
68
+ const value = raw === undefined ? fallback : raw;
69
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
70
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
71
+ }
72
+ return value as number;
73
+ }
74
+
75
+ function number(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
76
+ const value = raw === undefined ? fallback : raw;
77
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
78
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ function flag(raw: unknown, label: string): boolean {
84
+ if (raw === undefined) return false;
85
+ if (typeof raw !== "boolean") throw new Error(`reader-steering plugin ${label} must be a boolean`);
86
+ return raw;
87
+ }
88
+
89
+ function names(raw: unknown, label: string, pattern: RegExp): string[] {
90
+ if (raw === undefined) return [];
91
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
92
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
93
+ }
94
+ return [...new Set(raw as string[])];
95
+ }
96
+
97
+ function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
98
+ if (raw === undefined) return undefined;
99
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
100
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
101
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
102
+ if (plus.length === 0 || minus.length === 0) throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
103
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
104
+ if (unknown.length > 0) throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
105
+ const both = plus.filter((dimension) => minus.includes(dimension));
106
+ if (both.length > 0) throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
107
+ return { plus, minus };
108
+ }
109
+
110
+ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteeringConfig {
111
+ const raw = typeof request === "string" ? { name: request } : request;
112
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
113
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
114
+ const computeRoleArn = raw.computeRoleArn;
115
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
116
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
117
+ }
118
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
119
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
120
+ if (reserved.length > 0) throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
121
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
122
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
123
+ const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
124
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
125
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
126
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
127
+ if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
128
+ throw new Error("reader-steering plugin retirementPoll runs inside the scheduled snapshot, so it needs snapshot.schedule: true");
129
+ }
130
+ return {
131
+ computeRoleArn,
132
+ dimensions,
133
+ limits: {
134
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
135
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
136
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
137
+ },
138
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
139
+ karma: {
140
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
141
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
142
+ },
143
+ ranking: {
144
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
145
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
146
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
147
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
148
+ },
149
+ snapshot: {
150
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
151
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
152
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
153
+ },
154
+ retirementPoll,
155
+ ...(hypeGap ? { hypeGap } : {}),
156
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
157
+ };
158
+ }