@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.81
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/auth/resource.js +16 -1
- package/amplify/auth/resource.ts +19 -1
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +45 -3
- package/amplify/site-backend.ts +51 -3
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,290 @@
|
|
|
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 MODERATION_MODES = ["off", "human", "ai"] as const;
|
|
7
|
+
export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
|
|
8
|
+
export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
9
|
+
export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
10
|
+
export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"] as const;
|
|
11
|
+
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
12
|
+
|
|
13
|
+
export type ReaderSteeringConfig = {
|
|
14
|
+
/** The IAM role of the CMS app's server runtime (the `CmsComputeRoleArn` output of the app-shell stack): the only caller of reader-actions. */
|
|
15
|
+
computeRoleArn: string;
|
|
16
|
+
/** Rating dimensions of the publication, named by the site (lowercase). `up` and `used-it` are always available. */
|
|
17
|
+
dimensions: string[];
|
|
18
|
+
limits: {
|
|
19
|
+
/** Votes and ratings one reader may make in a clock hour. */
|
|
20
|
+
actionsPerHour: number;
|
|
21
|
+
/** Readers whose profile is younger than this many hours get `actionsPerHour / newAccountDivisor`. */
|
|
22
|
+
newAccountHours: number;
|
|
23
|
+
newAccountDivisor: number;
|
|
24
|
+
};
|
|
25
|
+
/** Handles nobody may take, on top of the built-in list. Lowercase. */
|
|
26
|
+
reservedHandles: string[];
|
|
27
|
+
karma: { upvoteWeight: number; usedItWeight: number };
|
|
28
|
+
ranking: {
|
|
29
|
+
/** Front: the editor composite loses half its weight every this many hours. */
|
|
30
|
+
halfLifeHours: number;
|
|
31
|
+
/** Rising counts upvotes in this window. */
|
|
32
|
+
risingWindowHours: number;
|
|
33
|
+
/** Reader ratings per item needed before the reader composite counts in Front. */
|
|
34
|
+
readerMinimum: number;
|
|
35
|
+
/** Weight of the editor composite in Front once readers have rated (the reader composite gets the rest). Used by `blend` only. */
|
|
36
|
+
editorWeight: number;
|
|
37
|
+
/** `blend`: editor composite, then a fixed-weight blend after `readerMinimum` ratings (the original rule). `shrinkage`: per dimension, the editor score is a prior worth `shrinkageK` ratings and the reader share is capped. */
|
|
38
|
+
combination: "blend" | "shrinkage";
|
|
39
|
+
/** Shrinkage: the editor score counts as this many reader ratings. */
|
|
40
|
+
shrinkageK: number;
|
|
41
|
+
/** Shrinkage: the reader mean never gets more than this share of the distance from the editor score. */
|
|
42
|
+
readerShareCap: number;
|
|
43
|
+
/** A rating from a profile younger than `limits.newAccountHours` counts for this fraction in the reader mean and count. Default 1/3 under `shrinkage`, 1 under `blend`. */
|
|
44
|
+
newAccountWeight: number;
|
|
45
|
+
/** Rising and the used-it reporter count only take profiles at least this many hours old; 0 takes all. Default 24 under `shrinkage`, 0 under `blend`. */
|
|
46
|
+
risingMinimumAccountHours: number;
|
|
47
|
+
};
|
|
48
|
+
snapshot: {
|
|
49
|
+
/** An EventBridge rule runs the snapshot every `intervalHours`. Off by default. */
|
|
50
|
+
schedule: boolean;
|
|
51
|
+
intervalHours: number;
|
|
52
|
+
/** A hype gap needs at least this many ratings on every dimension involved. */
|
|
53
|
+
hypeMinimum: number;
|
|
54
|
+
};
|
|
55
|
+
submissions: {
|
|
56
|
+
/** Submissions one reader may make in a UTC day. Accounts younger than `limits.newAccountHours` get `perDay / limits.newAccountDivisor`. */
|
|
57
|
+
perDay: number;
|
|
58
|
+
/** Another submission from the same domain inside this many hours is returned as a `similar` warning. */
|
|
59
|
+
similarHours: number;
|
|
60
|
+
/** The kinds a reader may pick (a radio on the form). A single kind hides the radio. */
|
|
61
|
+
kinds: string[];
|
|
62
|
+
/** The item type of the draft an editor makes by promoting a submission. */
|
|
63
|
+
itemType: string;
|
|
64
|
+
/** The section of that draft. */
|
|
65
|
+
section: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The kinds of subject a signed-in reader may vote on and flag, by `{type, id}`. Default: items and submissions, which is
|
|
69
|
+
* what readers could vote on before subject types existed. `story`, `proposal` and `concept` are opt-in.
|
|
70
|
+
*/
|
|
71
|
+
subjects: { types: (typeof SUBJECT_TYPES)[number][] };
|
|
72
|
+
moderation: {
|
|
73
|
+
/**
|
|
74
|
+
* `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
|
|
75
|
+
* `off`: no queue; moderators can still hide, ban and undo. Turning human review off is the whole of "trust graduation".
|
|
76
|
+
* `ai`: a worker asks a model to allow or hide each new item and logs it as the AI moderator; a human decision always stands.
|
|
77
|
+
*/
|
|
78
|
+
mode: (typeof MODERATION_MODES)[number];
|
|
79
|
+
/** Hold new submissions and comments hidden until a moderator approves them. Needs a mode other than `off`. */
|
|
80
|
+
preModeration: boolean;
|
|
81
|
+
/** Reports one reader may file in a UTC day (a third of it for a new account). */
|
|
82
|
+
reportsPerDay: number;
|
|
83
|
+
/** Present when `mode` is `ai`. */
|
|
84
|
+
ai?: {
|
|
85
|
+
/** The model that judges each new item. */
|
|
86
|
+
model: string;
|
|
87
|
+
/** The SSM SecureString that holds the API key: `/papyrus/<site>/openai-api-key`. The plugin only grants read access to it. */
|
|
88
|
+
apiKeyParameter: string;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
comments: {
|
|
92
|
+
/** Comments, replies and edits one reader may post in a clock hour (a third of it for new accounts). */
|
|
93
|
+
perHour: number;
|
|
94
|
+
/** The author may edit a comment for this many minutes after posting it. */
|
|
95
|
+
editWindowMinutes: number;
|
|
96
|
+
/** Most characters in a comment. */
|
|
97
|
+
maximumLength: number;
|
|
98
|
+
/** Most links (http, https or www) in a comment. */
|
|
99
|
+
maximumLinks: number;
|
|
100
|
+
};
|
|
101
|
+
proposals: {
|
|
102
|
+
/**
|
|
103
|
+
* Publish every proposal, in every state (drafts included), as public `_data/proposals/` files in each snapshot run.
|
|
104
|
+
* Off by default: proposals stay private records until a site opts in. Turning it off stops further updates; it does not
|
|
105
|
+
* delete files already published.
|
|
106
|
+
*/
|
|
107
|
+
publish: boolean;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
|
|
111
|
+
* Absent means no hype gap is published. The site names the dimensions in each group.
|
|
112
|
+
*/
|
|
113
|
+
hypeGap?: { plus: string[]; minus: string[] };
|
|
114
|
+
/**
|
|
115
|
+
* Reserved concurrency for reader-actions. Unset by default, which reserves nothing: an account with a small total
|
|
116
|
+
* Lambda concurrency quota (papyrus-production has 10) cannot reserve any without dropping below its unreserved minimum.
|
|
117
|
+
*/
|
|
118
|
+
reservedConcurrency?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Poll Prospectus for retired reader subjects in each scheduled snapshot run (requires `snapshot.schedule`, and a site
|
|
121
|
+
* whose app-shell sets `cms.readerRetirementPoll`). Off by default.
|
|
122
|
+
*/
|
|
123
|
+
retirementPoll: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
|
|
127
|
+
|
|
128
|
+
function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
129
|
+
if (raw === undefined) return {};
|
|
130
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`reader-steering plugin ${label} must be an object`);
|
|
131
|
+
const unknown = Object.keys(raw).filter((key) => !known.includes(key));
|
|
132
|
+
if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin ${label} settings: ${unknown.join(", ")}`);
|
|
133
|
+
return raw as Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function integer(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
|
|
137
|
+
const value = raw === undefined ? fallback : raw;
|
|
138
|
+
if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
139
|
+
throw new Error(`reader-steering plugin ${label} must be an integer from ${minimum} to ${maximum}`);
|
|
140
|
+
}
|
|
141
|
+
return value as number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function number(raw: unknown, label: string, fallback: number, minimum: number, maximum: number): number {
|
|
145
|
+
const value = raw === undefined ? fallback : raw;
|
|
146
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
|
147
|
+
throw new Error(`reader-steering plugin ${label} must be a number from ${minimum} to ${maximum}`);
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function flag(raw: unknown, label: string): boolean {
|
|
153
|
+
if (raw === undefined) return false;
|
|
154
|
+
if (typeof raw !== "boolean") throw new Error(`reader-steering plugin ${label} must be a boolean`);
|
|
155
|
+
return raw;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function names(raw: unknown, label: string, pattern: RegExp): string[] {
|
|
159
|
+
if (raw === undefined) return [];
|
|
160
|
+
if (!Array.isArray(raw) || !raw.every((entry) => typeof entry === "string" && pattern.test(entry))) {
|
|
161
|
+
throw new Error(`reader-steering plugin ${label} must be a list of lowercase names matching ${pattern}`);
|
|
162
|
+
}
|
|
163
|
+
return [...new Set(raw as string[])];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function lowercaseName(raw: unknown, label: string, fallback: string): string {
|
|
167
|
+
const value = raw === undefined ? fallback : raw;
|
|
168
|
+
if (typeof value !== "string" || !SUBMISSION_KIND_NAME.test(value)) throw new Error(`reader-steering plugin ${label} must be a lowercase name matching ${SUBMISSION_KIND_NAME}`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
|
|
173
|
+
if (raw === undefined) return undefined;
|
|
174
|
+
const groups = object(raw, "hypeGap", ["plus", "minus"]);
|
|
175
|
+
const plus = names(groups.plus, "hypeGap.plus", READER_DIMENSION_NAME);
|
|
176
|
+
const minus = names(groups.minus, "hypeGap.minus", READER_DIMENSION_NAME);
|
|
177
|
+
if (plus.length === 0 || minus.length === 0) throw new Error("reader-steering plugin hypeGap needs at least one plus and one minus dimension");
|
|
178
|
+
const unknown = [...plus, ...minus].filter((dimension) => !dimensions.includes(dimension));
|
|
179
|
+
if (unknown.length > 0) throw new Error(`reader-steering plugin hypeGap uses dimensions the site does not rate: ${unknown.join(", ")}`);
|
|
180
|
+
const both = plus.filter((dimension) => minus.includes(dimension));
|
|
181
|
+
if (both.length > 0) throw new Error(`reader-steering plugin hypeGap dimensions cannot be both plus and minus: ${both.join(", ")}`);
|
|
182
|
+
return { plus, minus };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteeringConfig {
|
|
186
|
+
const raw = typeof request === "string" ? { name: request } : request;
|
|
187
|
+
const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.has(key));
|
|
188
|
+
if (unknown.length > 0) throw new Error(`Unknown reader-steering plugin settings: ${unknown.join(", ")}`);
|
|
189
|
+
const computeRoleArn = raw.computeRoleArn;
|
|
190
|
+
if (typeof computeRoleArn !== "string" || !COMPUTE_ROLE_ARN.test(computeRoleArn)) {
|
|
191
|
+
throw new Error("reader-steering plugin computeRoleArn must be the IAM role ARN of the CMS app's compute role (the CmsComputeRoleArn output)");
|
|
192
|
+
}
|
|
193
|
+
const dimensions = names(raw.dimensions, "dimensions", READER_DIMENSION_NAME);
|
|
194
|
+
const reserved = dimensions.filter((dimension) => RESERVED_DIMENSIONS.includes(dimension));
|
|
195
|
+
if (reserved.length > 0) throw new Error(`reader-steering plugin dimensions cannot use the reserved names: ${reserved.join(", ")}`);
|
|
196
|
+
const limits = object(raw.limits, "limits", ["actionsPerHour", "newAccountHours", "newAccountDivisor"]);
|
|
197
|
+
const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
|
|
198
|
+
const ranking = object(raw.ranking, "ranking", [
|
|
199
|
+
"halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight", "combination", "shrinkageK", "readerShareCap", "newAccountWeight", "risingMinimumAccountHours",
|
|
200
|
+
]);
|
|
201
|
+
const combination = ranking.combination === undefined ? "blend" : ranking.combination;
|
|
202
|
+
if (combination !== "blend" && combination !== "shrinkage") throw new Error("reader-steering plugin ranking.combination must be blend or shrinkage");
|
|
203
|
+
const shrinking = combination === "shrinkage";
|
|
204
|
+
const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
|
|
205
|
+
const submissions = object(raw.submissions, "submissions", ["perDay", "similarHours", "kinds", "itemType", "section"]);
|
|
206
|
+
const moderation = object(raw.moderation, "moderation", ["mode", "preModeration", "reportsPerDay", "ai"]);
|
|
207
|
+
const mode = moderation.mode === undefined ? "human" : moderation.mode;
|
|
208
|
+
if (!MODERATION_MODES.includes(mode as (typeof MODERATION_MODES)[number])) {
|
|
209
|
+
throw new Error(`reader-steering plugin moderation.mode must be one of: ${MODERATION_MODES.join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
const preModeration = flag(moderation.preModeration, "moderation.preModeration");
|
|
212
|
+
if (preModeration && mode === "off") throw new Error("reader-steering plugin moderation.preModeration needs a moderation.mode other than off");
|
|
213
|
+
const ai = object(moderation.ai, "moderation.ai", ["model", "apiKeyParameter"]);
|
|
214
|
+
if (mode !== "ai" && moderation.ai !== undefined) throw new Error("reader-steering plugin moderation.ai needs moderation.mode: ai");
|
|
215
|
+
if (mode === "ai") {
|
|
216
|
+
if (typeof ai.apiKeyParameter !== "string" || !AI_KEY_PARAMETER.test(ai.apiKeyParameter)) {
|
|
217
|
+
throw new Error("reader-steering plugin moderation.ai.apiKeyParameter must be the SSM SecureString name /papyrus/<site>/openai-api-key");
|
|
218
|
+
}
|
|
219
|
+
if (ai.model !== undefined && (typeof ai.model !== "string" || !AI_MODEL_NAME.test(ai.model))) {
|
|
220
|
+
throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const subjects = object(raw.subjects, "subjects", ["types"]);
|
|
224
|
+
const subjectTypes = subjects.types === undefined ? ["item", "submission"] : subjects.types;
|
|
225
|
+
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
|
|
226
|
+
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
227
|
+
}
|
|
228
|
+
const proposals = object(raw.proposals, "proposals", ["publish"]);
|
|
229
|
+
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
230
|
+
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
231
|
+
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
232
|
+
const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
|
|
233
|
+
if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
|
|
234
|
+
throw new Error("reader-steering plugin retirementPoll runs inside the scheduled snapshot, so it needs snapshot.schedule: true");
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
computeRoleArn,
|
|
238
|
+
dimensions,
|
|
239
|
+
limits: {
|
|
240
|
+
actionsPerHour: integer(limits.actionsPerHour, "limits.actionsPerHour", 120, 1, 10000),
|
|
241
|
+
newAccountHours: integer(limits.newAccountHours, "limits.newAccountHours", 24, 0, 24 * 90),
|
|
242
|
+
newAccountDivisor: integer(limits.newAccountDivisor, "limits.newAccountDivisor", 3, 1, 100),
|
|
243
|
+
},
|
|
244
|
+
reservedHandles: names(raw.reservedHandles, "reservedHandles", READER_HANDLE_NAME),
|
|
245
|
+
karma: {
|
|
246
|
+
upvoteWeight: number(karma.upvoteWeight, "karma.upvoteWeight", 1, 0, 1000),
|
|
247
|
+
usedItWeight: number(karma.usedItWeight, "karma.usedItWeight", 2, 0, 1000),
|
|
248
|
+
},
|
|
249
|
+
ranking: {
|
|
250
|
+
halfLifeHours: number(ranking.halfLifeHours, "ranking.halfLifeHours", 48, 1, 24 * 365),
|
|
251
|
+
risingWindowHours: number(ranking.risingWindowHours, "ranking.risingWindowHours", 6, 1, 24 * 30),
|
|
252
|
+
readerMinimum: integer(ranking.readerMinimum, "ranking.readerMinimum", 5, 1, 10000),
|
|
253
|
+
editorWeight: number(ranking.editorWeight, "ranking.editorWeight", 0.5, 0, 1),
|
|
254
|
+
combination,
|
|
255
|
+
shrinkageK: number(ranking.shrinkageK, "ranking.shrinkageK", 20, 0.01, 10000),
|
|
256
|
+
readerShareCap: number(ranking.readerShareCap, "ranking.readerShareCap", 0.4, 0, 1),
|
|
257
|
+
newAccountWeight: number(ranking.newAccountWeight, "ranking.newAccountWeight", shrinking ? 1 / 3 : 1, 0, 1),
|
|
258
|
+
risingMinimumAccountHours: number(ranking.risingMinimumAccountHours, "ranking.risingMinimumAccountHours", shrinking ? 24 : 0, 0, 24 * 90),
|
|
259
|
+
},
|
|
260
|
+
snapshot: {
|
|
261
|
+
schedule: flag(snapshot.schedule, "snapshot.schedule"),
|
|
262
|
+
intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
|
|
263
|
+
hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
|
|
264
|
+
},
|
|
265
|
+
submissions: {
|
|
266
|
+
perDay: integer(submissions.perDay, "submissions.perDay", 10, 1, 1000),
|
|
267
|
+
similarHours: integer(submissions.similarHours, "submissions.similarHours", 48, 1, 24 * 30),
|
|
268
|
+
kinds: kinds.length > 0 ? kinds : ["link"],
|
|
269
|
+
itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
|
|
270
|
+
section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
|
|
271
|
+
},
|
|
272
|
+
subjects: { types: [...new Set(subjectTypes)] as (typeof SUBJECT_TYPES)[number][] },
|
|
273
|
+
moderation: {
|
|
274
|
+
mode: mode as (typeof MODERATION_MODES)[number],
|
|
275
|
+
preModeration,
|
|
276
|
+
reportsPerDay: integer(moderation.reportsPerDay, "moderation.reportsPerDay", 20, 1, 1000),
|
|
277
|
+
...(mode === "ai" ? { ai: { model: (ai.model as string | undefined) ?? "gpt-6-luna", apiKeyParameter: ai.apiKeyParameter as string } } : {}),
|
|
278
|
+
},
|
|
279
|
+
comments: {
|
|
280
|
+
perHour: integer(comments.perHour, "comments.perHour", 30, 1, 1000),
|
|
281
|
+
editWindowMinutes: integer(comments.editWindowMinutes, "comments.editWindowMinutes", 10, 0, 24 * 60),
|
|
282
|
+
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
283
|
+
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
284
|
+
},
|
|
285
|
+
proposals: { publish: flag(proposals.publish, "proposals.publish") },
|
|
286
|
+
retirementPoll,
|
|
287
|
+
...(hypeGap ? { hypeGap } : {}),
|
|
288
|
+
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""AI moderation: a model reads each new submission and comment and answers allow or hide, with a reason.
|
|
2
|
+
|
|
3
|
+
The decision is applied as an ordinary moderation action marked ``by: "ai"``, so it appears in the log, shows in the panel,
|
|
4
|
+
and a human can undo it or decide differently. A human decision always stands: an item a person has acted on is never
|
|
5
|
+
judged by the model again. A model that fails, times out or answers something that is not a valid verdict leaves the item
|
|
6
|
+
in the human queue.
|
|
7
|
+
|
|
8
|
+
Secrets: the API key is read from an SSM SecureString at the start of a run and lives only in the object that makes the
|
|
9
|
+
request. It is never logged, never put in a returned value and never in an error message (errors are reported by class name).
|
|
10
|
+
Tests stub the model; nothing in the tests, CI or this module's defaults makes a paid call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
import moderation as rules
|
|
26
|
+
|
|
27
|
+
PROMPT_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "moderation-prompt.md")
|
|
28
|
+
DEFAULT_MODEL = "gpt-6-luna"
|
|
29
|
+
RESPONSES_URL = "https://api.openai.com/v1/responses"
|
|
30
|
+
ALLOW, HIDE = "allow", "hide"
|
|
31
|
+
MAXIMUM_REASON = 200
|
|
32
|
+
MAXIMUM_TEXT = 4000
|
|
33
|
+
BATCH_LIMIT = 25
|
|
34
|
+
CONSECUTIVE_FAILURES_BEFORE_STOPPING = 3
|
|
35
|
+
REQUEST_TIMEOUT_SECONDS = 20
|
|
36
|
+
AI_ACTOR = "ai"
|
|
37
|
+
REASON_PREFIX = "AI: "
|
|
38
|
+
PARAMETER_NAME = re.compile(r"/papyrus/[a-z0-9][a-z0-9-]{0,62}/openai-api-key")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Structured Outputs: the API itself constrains the answer to this schema (strict), so no "json" wording is needed in the input.
|
|
42
|
+
VERDICT_FORMAT: dict[str, Any] = {
|
|
43
|
+
"type": "json_schema",
|
|
44
|
+
"name": "moderation_verdict",
|
|
45
|
+
"strict": True,
|
|
46
|
+
"schema": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {"verdict": {"type": "string", "enum": [ALLOW, HIDE]}, "reason": {"type": "string"}},
|
|
49
|
+
"required": ["verdict", "reason"],
|
|
50
|
+
"additionalProperties": False,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ModelOutputError(ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Verdict:
|
|
61
|
+
decision: str
|
|
62
|
+
reason: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ModerationModel(Protocol):
|
|
66
|
+
def judge(self, item: dict[str, Any]) -> Verdict: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_prompt(path: str = PROMPT_FILE) -> str:
|
|
70
|
+
with open(path, encoding="utf-8") as handle:
|
|
71
|
+
return handle.read().strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_verdict(text: Any) -> Verdict:
|
|
75
|
+
"""A strict reading of the model's answer: one JSON object, ``verdict`` (or the older ``decision``) allow or hide, a short plain reason."""
|
|
76
|
+
if not isinstance(text, str):
|
|
77
|
+
raise ModelOutputError("the model answered nothing")
|
|
78
|
+
start, end = text.find("{"), text.rfind("}")
|
|
79
|
+
if start < 0 or end < start:
|
|
80
|
+
raise ModelOutputError("the model did not answer with a JSON object")
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(text[start : end + 1])
|
|
83
|
+
except ValueError as error:
|
|
84
|
+
raise ModelOutputError("the model's answer is not valid JSON") from error
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ModelOutputError("the decision is neither allow nor hide")
|
|
87
|
+
decision, reason = data.get("verdict", data.get("decision")), data.get("reason")
|
|
88
|
+
if decision not in (ALLOW, HIDE):
|
|
89
|
+
raise ModelOutputError("the decision is neither allow nor hide")
|
|
90
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
91
|
+
raise ModelOutputError("the model gave no reason")
|
|
92
|
+
return Verdict(decision, " ".join(reason.split())[:MAXIMUM_REASON])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def response_text(body: Any) -> str | None:
|
|
96
|
+
"""The text of a Responses API answer: ``output_text`` when present, else the text parts of the output messages."""
|
|
97
|
+
if not isinstance(body, dict):
|
|
98
|
+
return None
|
|
99
|
+
if isinstance(body.get("output_text"), str):
|
|
100
|
+
return body["output_text"]
|
|
101
|
+
parts = [
|
|
102
|
+
part.get("text")
|
|
103
|
+
for message in body.get("output") or []
|
|
104
|
+
if isinstance(message, dict)
|
|
105
|
+
for part in message.get("content") or []
|
|
106
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str)
|
|
107
|
+
]
|
|
108
|
+
return "".join(parts) if parts else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def urllib_transport(url: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
+
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
|
|
113
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: # noqa: S310 - a fixed https endpoint
|
|
114
|
+
return json.loads(response.read().decode("utf-8"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class OpenAIModerationModel:
|
|
118
|
+
"""GPT-6 Luna (``moderation.ai.model``) through the OpenAI Responses API. The key is held here and nowhere else."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
api_key: str,
|
|
123
|
+
*,
|
|
124
|
+
model: str = DEFAULT_MODEL,
|
|
125
|
+
prompt: str | None = None,
|
|
126
|
+
transport: Callable[[str, dict[str, str], dict[str, Any]], dict[str, Any]] = urllib_transport,
|
|
127
|
+
) -> None:
|
|
128
|
+
if not api_key:
|
|
129
|
+
raise ValueError("an API key is required")
|
|
130
|
+
self._api_key = api_key
|
|
131
|
+
self._model = model
|
|
132
|
+
self._prompt = prompt if prompt is not None else load_prompt()
|
|
133
|
+
self._transport = transport
|
|
134
|
+
|
|
135
|
+
def request_payload(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"model": self._model,
|
|
138
|
+
"instructions": self._prompt,
|
|
139
|
+
"input": json.dumps(item, ensure_ascii=False, separators=(",", ":")),
|
|
140
|
+
"text": {"format": VERDICT_FORMAT},
|
|
141
|
+
"max_output_tokens": 300,
|
|
142
|
+
"store": False,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def judge(self, item: dict[str, Any]) -> Verdict:
|
|
146
|
+
body = self._transport(RESPONSES_URL, {"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}, self.request_payload(item))
|
|
147
|
+
return parse_verdict(response_text(body))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def model_input(kind: str, row: dict[str, Any]) -> dict[str, Any]:
|
|
151
|
+
"""What the model sees: the content, the automatic signals and the report count. Never a reader id, a handle or a siteSubject."""
|
|
152
|
+
signals = row.get("signals")
|
|
153
|
+
if isinstance(signals, str):
|
|
154
|
+
try:
|
|
155
|
+
signals = json.loads(signals)
|
|
156
|
+
except ValueError:
|
|
157
|
+
signals = []
|
|
158
|
+
listed = [
|
|
159
|
+
{"policy": str(signal.get("policy", ""))[:60], "detector": str(signal.get("detector", ""))[:60], "severity": str(signal.get("severity", ""))[:12]}
|
|
160
|
+
for signal in (signals if isinstance(signals, list) else [])
|
|
161
|
+
if isinstance(signal, dict)
|
|
162
|
+
][:20]
|
|
163
|
+
if kind == rules.TARGET_SUBMISSION:
|
|
164
|
+
item: dict[str, Any] = {"kind": "submission", "title": row["title"], "url": row["canonicalUrl"], "domain": row["domain"], "text": row.get("note") or ""}
|
|
165
|
+
else:
|
|
166
|
+
item = {"kind": "comment", "text": row["body"]}
|
|
167
|
+
item["text"] = str(item["text"])[:MAXIMUM_TEXT]
|
|
168
|
+
item["signals"] = listed
|
|
169
|
+
item["reports"] = int(row.get("reportCount") or 0)
|
|
170
|
+
return item
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class AiReport:
|
|
175
|
+
judged: int = 0
|
|
176
|
+
allowed: int = 0
|
|
177
|
+
hidden: int = 0
|
|
178
|
+
skipped_human: int = 0
|
|
179
|
+
errors: dict[str, int] = field(default_factory=dict)
|
|
180
|
+
stopped: bool = False
|
|
181
|
+
|
|
182
|
+
def to_dict(self) -> dict[str, Any]:
|
|
183
|
+
return {"judged": self.judged, "allowed": self.allowed, "hidden": self.hidden, "skippedHuman": self.skipped_human, "errors": dict(self.errors), "stopped": self.stopped}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class AiStore(Protocol):
|
|
187
|
+
def new_content(self) -> list[tuple[str, dict[str, Any]]]: ...
|
|
188
|
+
|
|
189
|
+
def human_acted_on(self) -> set[str]: ...
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_ai_moderation(
|
|
193
|
+
store: Any,
|
|
194
|
+
model: ModerationModel,
|
|
195
|
+
*,
|
|
196
|
+
now: Callable[[], float],
|
|
197
|
+
settings: rules.ModerationSettings,
|
|
198
|
+
deadline: float,
|
|
199
|
+
limit: int = BATCH_LIMIT,
|
|
200
|
+
iso: Callable[[float], str],
|
|
201
|
+
) -> AiReport:
|
|
202
|
+
"""Judges the oldest new items first, until the batch limit, the deadline, or three failures in a row."""
|
|
203
|
+
report = AiReport()
|
|
204
|
+
human_targets = store.human_acted_on()
|
|
205
|
+
failures_in_a_row = 0
|
|
206
|
+
candidates = sorted(store.new_content(), key=lambda entry: (entry[1]["createdAt"], entry[1]["id"]))
|
|
207
|
+
for kind, row in candidates:
|
|
208
|
+
if report.judged >= limit or now() >= deadline:
|
|
209
|
+
break
|
|
210
|
+
if row["id"] in human_targets:
|
|
211
|
+
report.skipped_human += 1
|
|
212
|
+
continue
|
|
213
|
+
if row.get("state") == "deleted":
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
verdict = model.judge(model_input(kind, row))
|
|
217
|
+
except Exception as error: # noqa: BLE001 - reported by class name only: a message could carry the key or the content
|
|
218
|
+
report.errors[type(error).__name__] = report.errors.get(type(error).__name__, 0) + 1
|
|
219
|
+
failures_in_a_row += 1
|
|
220
|
+
if failures_in_a_row >= CONSECUTIVE_FAILURES_BEFORE_STOPPING:
|
|
221
|
+
report.stopped = True
|
|
222
|
+
break
|
|
223
|
+
continue
|
|
224
|
+
failures_in_a_row = 0
|
|
225
|
+
action = rules.APPROVE if verdict.decision == ALLOW else rules.HIDE
|
|
226
|
+
outcome = rules.apply_action(
|
|
227
|
+
store,
|
|
228
|
+
action=action,
|
|
229
|
+
target_type=kind,
|
|
230
|
+
target=row["id"],
|
|
231
|
+
reason=REASON_PREFIX + verdict.reason,
|
|
232
|
+
undo_of=None,
|
|
233
|
+
actor_sub=AI_ACTOR,
|
|
234
|
+
by=rules.BY_AI,
|
|
235
|
+
now_iso=iso(now()),
|
|
236
|
+
settings=settings,
|
|
237
|
+
)
|
|
238
|
+
if outcome.status != "ok":
|
|
239
|
+
report.errors[outcome.error or "refused"] = report.errors.get(outcome.error or "refused", 0) + 1
|
|
240
|
+
continue
|
|
241
|
+
report.judged += 1
|
|
242
|
+
if action == rules.APPROVE:
|
|
243
|
+
report.allowed += 1
|
|
244
|
+
else:
|
|
245
|
+
report.hidden += 1
|
|
246
|
+
return report
|