@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.
- package/amplify/data/resource.js +1 -0
- package/amplify/data/resource.ts +1 -0
- package/amplify/data/schema.js +1 -0
- package/amplify/data/schema.ts +1 -0
- package/amplify/functions/assignment-action/data-access.js +2 -0
- package/amplify/functions/assignment-action/data-access.ts +2 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +3 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/plugins/reader-steering/config.js +111 -0
- package/amplify/plugins/reader-steering/config.ts +158 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +139 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +162 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/components/reference-cyclotron-priming.tsx +54 -15
- package/infra/amplify-app-shell.js +15 -1
- package/infra/amplify-app-shell.ts +17 -1
- package/infra/site-config.js +12 -2
- package/infra/site-config.ts +19 -2
- package/lib/editorial-checks.ts +6 -2
- package/lib/prospectus-session.ts +1 -0
- package/lib/prospectus-sign-in-routes.ts +2 -2
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +143 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/package.json +9 -1
- package/routes.manifest.json +56 -1
package/amplify/data/resource.js
CHANGED
|
@@ -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)])
|
package/amplify/data/resource.ts
CHANGED
|
@@ -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)])
|
package/amplify/data/schema.js
CHANGED
|
@@ -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)])
|
package/amplify/data/schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|