@anthusai/papyrus 2.0.0-next.40 → 2.0.0-next.41

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 (41) 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/handler.ts +1 -1
  6. package/amplify/functions/content-actions/handler.py +3 -0
  7. package/amplify/functions/shared/editorial-context.ts +9 -3
  8. package/amplify/plugins/reader-steering/config.js +110 -0
  9. package/amplify/plugins/reader-steering/config.ts +153 -0
  10. package/amplify/plugins/reader-steering/function/handler.py +544 -0
  11. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  12. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  13. package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
  14. package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
  15. package/amplify/plugins/reader-steering/schema.js +40 -0
  16. package/amplify/plugins/reader-steering/schema.ts +42 -0
  17. package/amplify/plugins/registry.js +2 -0
  18. package/amplify/plugins/registry.ts +2 -0
  19. package/amplify/plugins/types.ts +3 -0
  20. package/amplify/plugins/wire.js +1 -0
  21. package/amplify/plugins/wire.ts +1 -0
  22. package/app/api/reader/handle/route.ts +9 -0
  23. package/app/api/reader/me/route.ts +9 -0
  24. package/app/api/reader/rating/route.ts +9 -0
  25. package/app/api/reader/retired/route.ts +9 -0
  26. package/app/api/reader/vote/route.ts +9 -0
  27. package/components/reference-cyclotron-priming.tsx +54 -15
  28. package/infra/amplify-app-shell.js +15 -1
  29. package/infra/amplify-app-shell.ts +17 -1
  30. package/infra/site-config.js +12 -2
  31. package/infra/site-config.ts +19 -2
  32. package/lib/editorial-checks.ts +3 -1
  33. package/lib/prospectus-session.ts +1 -0
  34. package/lib/prospectus-sign-in-routes.ts +2 -2
  35. package/lib/prospectus-site-identity.ts +24 -1
  36. package/lib/reader-api.ts +143 -0
  37. package/lib/reader-retirement-route.ts +89 -0
  38. package/lib/reader-subject.md +29 -0
  39. package/lib/reader-subject.ts +53 -0
  40. package/package.json +9 -1
  41. 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)])
@@ -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).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,9 @@ 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.
17
+ * Only the editorial check asks for this (its rule lane needs no doctrine); more than one current record is still an error. */
18
+ optionalPublicationDoctrine?: boolean | null;
16
19
  readAttachment: (attachment: EditorialRecord) => Promise<string>;
17
20
  };
18
21
 
@@ -55,7 +58,7 @@ function entry(scope: string, kind: string, text: string, record: EditorialRecor
55
58
  } };
56
59
  }
57
60
 
58
- export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
61
+ export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0, missingPublicationDoctrine: string[] = []) {
59
62
  const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
60
63
  "Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
61
64
  "Private operating guidance; not evidence or reader-facing copy."];
@@ -65,7 +68,8 @@ export function buildEditorialContext(publication: EditorialRecord, scope: Edito
65
68
  if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
66
69
  if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
67
70
  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 };
71
+ precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters,
72
+ ...(missingPublicationDoctrine.length ? { missingPublicationDoctrine } : {}) };
69
73
  return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
70
74
  }
71
75
 
@@ -83,6 +87,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
83
87
  corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
84
88
  const entries: EditorialRecord[] = [];
85
89
  const warnings: string[] = [];
90
+ const missingPublicationDoctrine: string[] = [];
86
91
  let sectionId = options.sectionId || null;
87
92
  let storyLineageId = options.storyLineageId || null;
88
93
  const assignmentId = options.assignmentId || null;
@@ -110,6 +115,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
110
115
  const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
111
116
  options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
112
117
  const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
118
+ if (current.length === 0 && options.optionalPublicationDoctrine) { missingPublicationDoctrine.push(kind); continue; }
113
119
  if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
114
120
  const record = current[0];
115
121
  tenant(record, options.tenantId);
@@ -147,7 +153,7 @@ export async function resolveEditorialContext(client: EditorialClient, options:
147
153
  warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
148
154
  }
149
155
  }
150
- return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
156
+ return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0, missingPublicationDoctrine);
151
157
  }
152
158
 
153
159
  export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
@@ -0,0 +1,110 @@
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)
81
+ throw new Error("reader-steering plugin retirementPoll is not available yet: Prospectus does not serve the retired-subject route");
82
+ return {
83
+ computeRoleArn,
84
+ dimensions,
85
+ limits: {
86
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
87
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
88
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
89
+ },
90
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
91
+ karma: {
92
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
93
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
94
+ },
95
+ ranking: {
96
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
97
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
98
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
99
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
100
+ },
101
+ snapshot: {
102
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
103
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
104
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
105
+ },
106
+ retirementPoll,
107
+ ...(hypeGap ? { hypeGap } : {}),
108
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
109
+ };
110
+ }
@@ -0,0 +1,153 @@
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
+ /** The Prospectus retired-subject poll is not live: this flag stays false and the run's step is a no-op. */
51
+ retirementPoll: boolean;
52
+ };
53
+
54
+ const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
55
+
56
+ function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
57
+ if (raw === undefined) return {};
58
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`reader-steering plugin ${label} must be an object`);
59
+ const unknown = Object.keys(raw).filter((key) => !known.includes(key));
60
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
61
+ return raw as Record<string, unknown>;
62
+ }
63
+
64
+ function integer(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
65
+ const value = raw === undefined ? fallback : raw;
66
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
67
+ throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
68
+ }
69
+ return value as number;
70
+ }
71
+
72
+ function number(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
73
+ const value = raw === undefined ? fallback : raw;
74
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
75
+ throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function flag(raw: unknown, label: string): boolean {
81
+ if (raw === undefined) return false;
82
+ if (typeof raw !== "boolean") throw new Error(`reader-steering plugin ${label} must be a boolean`);
83
+ return raw;
84
+ }
85
+
86
+ function names(raw: unknown, label: string, pattern: RegExp): string[] {
87
+ if (raw === undefined) return [];
88
+ if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
89
+ throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
90
+ }
91
+ return [...new Set(raw as string[])];
92
+ }
93
+
94
+ function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
95
+ if (raw === undefined) return undefined;
96
+ const groups = object(raw, "hypeGap", ["plus", "minus"]);
97
+ const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
98
+ const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
99
+ if (plus.length === 0 || minus.length === 0) throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
100
+ const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
101
+ if (unknown.length > 0) throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
102
+ const both = plus.filter((dimension) => minus.includes(dimension));
103
+ if (both.length > 0) throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
104
+ return { plus, minus };
105
+ }
106
+
107
+ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteeringConfig {
108
+ const raw = typeof request === "string" ? { name: request } : request;
109
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
110
+ if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
111
+ const computeRoleArn = raw.computeRoleArn;
112
+ if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
113
+ throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
114
+ }
115
+ const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
116
+ const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
117
+ if (reserved.length > 0) throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
118
+ const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
119
+ const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
120
+ const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
121
+ const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
122
+ const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
123
+ const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
124
+ if (retirementPoll) throw new Error("reader-steering plugin retirementPoll is not available yet: Prospectus does not serve the retired-subject route");
125
+ return {
126
+ computeRoleArn,
127
+ dimensions,
128
+ limits: {
129
+ actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
130
+ newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
131
+ newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
132
+ },
133
+ reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
134
+ karma: {
135
+ upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
136
+ usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
137
+ },
138
+ ranking: {
139
+ halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
140
+ risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
141
+ readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
142
+ editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
143
+ },
144
+ snapshot: {
145
+ schedule: flag(snapshot.schedule, "snapshot.schedule"),
146
+ intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
147
+ hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
148
+ },
149
+ retirementPoll,
150
+ ...(hypeGap ? { hypeGap } : {}),
151
+ ...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
152
+ };
153
+ }