@anthusai/papyrus 5.20.0 → 5.21.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/functions/content-actions/handler.py +17 -2
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -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 +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -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 +52 -1
- package/amplify/plugins/reader-steering/config.ts +91 -1
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- 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/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -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/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +42 -5
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +19 -1
- package/routes.manifest.json +103 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
2
2
|
export const RESERVED_DIMENSIONS = ["up", "used-it"];
|
|
3
3
|
export const READER_HANDLE_NAME = /^[a-z0-9_-]{3,24}$/;
|
|
4
|
+
export const MODERATION_MODES = ["off", "human", "ai"];
|
|
5
|
+
export const AI_KEY_PARAMETER = /^\/papyrus\/[a-z0-9][a-z0-9-]{0,62}\/openai-api-key$/;
|
|
6
|
+
export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
7
|
+
export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
4
8
|
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"]);
|
|
9
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
|
|
6
10
|
function object(raw, label, known) {
|
|
7
11
|
if (raw === undefined)
|
|
8
12
|
return {};
|
|
@@ -42,6 +46,12 @@ function names(raw, label, pattern) {
|
|
|
42
46
|
}
|
|
43
47
|
return [...new Set(raw)];
|
|
44
48
|
}
|
|
49
|
+
function lowercaseName(raw, label, fallback) {
|
|
50
|
+
const value = raw === undefined ? fallback : raw;
|
|
51
|
+
if (typeof value !== "string" || !SUBMISSION_KIND_NAME.test(value))
|
|
52
|
+
throw new Error(`reader-steering plugin ${label} must be a lowercase name matching ${SUBMISSION_KIND_NAME}`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
45
55
|
function resolveHypeGap(raw, dimensions) {
|
|
46
56
|
if (raw === undefined)
|
|
47
57
|
return undefined;
|
|
@@ -75,6 +85,28 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
75
85
|
const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
|
|
76
86
|
const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
|
|
77
87
|
const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
|
|
88
|
+
const submissions = object(raw.submissions, "submissions", ["perDay", "similarHours", "kinds", "itemType", "section"]);
|
|
89
|
+
const moderation = object(raw.moderation, "moderation", ["mode", "preModeration", "reportsPerDay", "ai"]);
|
|
90
|
+
const mode = moderation.mode === undefined ? "human" : moderation.mode;
|
|
91
|
+
if (!MODERATION_MODES.includes(mode)) {
|
|
92
|
+
throw new Error(`reader-steering plugin moderation.mode must be one of: ${MODERATION_MODES.join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
const preModeration = flag(moderation.preModeration, "moderation.preModeration");
|
|
95
|
+
if (preModeration && mode === "off")
|
|
96
|
+
throw new Error("reader-steering plugin moderation.preModeration needs a moderation.mode other than off");
|
|
97
|
+
const ai = object(moderation.ai, "moderation.ai", ["model", "apiKeyParameter"]);
|
|
98
|
+
if (mode !== "ai" && moderation.ai !== undefined)
|
|
99
|
+
throw new Error("reader-steering plugin moderation.ai needs moderation.mode: ai");
|
|
100
|
+
if (mode === "ai") {
|
|
101
|
+
if (typeof ai.apiKeyParameter !== "string" || !AI_KEY_PARAMETER.test(ai.apiKeyParameter)) {
|
|
102
|
+
throw new Error("reader-steering plugin moderation.ai.apiKeyParameter must be the SSM SecureString name /papyrus/<site>/openai-api-key");
|
|
103
|
+
}
|
|
104
|
+
if (ai.model !== undefined && (typeof ai.model !== "string" || !AI_MODEL_NAME.test(ai.model))) {
|
|
105
|
+
throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
109
|
+
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
78
110
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
79
111
|
const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
|
|
80
112
|
if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
|
|
@@ -104,6 +136,25 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
104
136
|
intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
|
|
105
137
|
hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
|
|
106
138
|
},
|
|
139
|
+
submissions: {
|
|
140
|
+
perDay: integer(submissions.perDay, "submissions.perDay", 10, 1, 1000),
|
|
141
|
+
similarHours: integer(submissions.similarHours, "submissions.similarHours", 48, 1, 24 * 30),
|
|
142
|
+
kinds: kinds.length > 0 ? kinds : ["link"],
|
|
143
|
+
itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
|
|
144
|
+
section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
|
|
145
|
+
},
|
|
146
|
+
moderation: {
|
|
147
|
+
mode: mode,
|
|
148
|
+
preModeration,
|
|
149
|
+
reportsPerDay: integer(moderation.reportsPerDay, "moderation.reportsPerDay", 20, 1, 1000),
|
|
150
|
+
...(mode === "ai" ? { ai: { model: ai.model ?? "gpt-6-luna", apiKeyParameter: ai.apiKeyParameter } } : {}),
|
|
151
|
+
},
|
|
152
|
+
comments: {
|
|
153
|
+
perHour: integer(comments.perHour, "comments.perHour", 30, 1, 1000),
|
|
154
|
+
editWindowMinutes: integer(comments.editWindowMinutes, "comments.editWindowMinutes", 10, 0, 24 * 60),
|
|
155
|
+
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
156
|
+
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
157
|
+
},
|
|
107
158
|
retirementPoll,
|
|
108
159
|
...(hypeGap ? { hypeGap } : {}),
|
|
109
160
|
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
@@ -3,6 +3,10 @@ import type { PluginRequest } from "../types";
|
|
|
3
3
|
export const READER_DIMENSION_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
4
4
|
export const RESERVED_DIMENSIONS = ["up", "used-it"];
|
|
5
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}$/;
|
|
6
10
|
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
7
11
|
|
|
8
12
|
export type ReaderSteeringConfig = {
|
|
@@ -37,6 +41,47 @@ export type ReaderSteeringConfig = {
|
|
|
37
41
|
/** A hype gap needs at least this many ratings on every dimension involved. */
|
|
38
42
|
hypeMinimum: number;
|
|
39
43
|
};
|
|
44
|
+
submissions: {
|
|
45
|
+
/** Submissions one reader may make in a UTC day. Accounts younger than `limits.newAccountHours` get `perDay / limits.newAccountDivisor`. */
|
|
46
|
+
perDay: number;
|
|
47
|
+
/** Another submission from the same domain inside this many hours is returned as a `similar` warning. */
|
|
48
|
+
similarHours: number;
|
|
49
|
+
/** The kinds a reader may pick (a radio on the form). A single kind hides the radio. */
|
|
50
|
+
kinds: string[];
|
|
51
|
+
/** The item type of the draft an editor makes by promoting a submission. */
|
|
52
|
+
itemType: string;
|
|
53
|
+
/** The section of that draft. */
|
|
54
|
+
section: string;
|
|
55
|
+
};
|
|
56
|
+
moderation: {
|
|
57
|
+
/**
|
|
58
|
+
* `human`: new submissions and comments are public at once and wait in the Administration queue (the default for a pilot).
|
|
59
|
+
* `off`: no queue; moderators can still hide, ban and undo. Turning human review off is the whole of "trust graduation".
|
|
60
|
+
* `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.
|
|
61
|
+
*/
|
|
62
|
+
mode: (typeof MODERATION_MODES)[number];
|
|
63
|
+
/** Hold new submissions and comments hidden until a moderator approves them. Needs a mode other than `off`. */
|
|
64
|
+
preModeration: boolean;
|
|
65
|
+
/** Reports one reader may file in a UTC day (a third of it for a new account). */
|
|
66
|
+
reportsPerDay: number;
|
|
67
|
+
/** Present when `mode` is `ai`. */
|
|
68
|
+
ai?: {
|
|
69
|
+
/** The model that judges each new item. */
|
|
70
|
+
model: string;
|
|
71
|
+
/** The SSM SecureString that holds the API key: `/papyrus/<site>/openai-api-key`. The plugin only grants read access to it. */
|
|
72
|
+
apiKeyParameter: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
comments: {
|
|
76
|
+
/** Comments, replies and edits one reader may post in a clock hour (a third of it for new accounts). */
|
|
77
|
+
perHour: number;
|
|
78
|
+
/** The author may edit a comment for this many minutes after posting it. */
|
|
79
|
+
editWindowMinutes: number;
|
|
80
|
+
/** Most characters in a comment. */
|
|
81
|
+
maximumLength: number;
|
|
82
|
+
/** Most links (http, https or www) in a comment. */
|
|
83
|
+
maximumLinks: number;
|
|
84
|
+
};
|
|
40
85
|
/**
|
|
41
86
|
* The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
|
|
42
87
|
* Absent means no hype gap is published. Shipped sets plus = notable, novel and minus = real, useful.
|
|
@@ -54,7 +99,7 @@ export type ReaderSteeringConfig = {
|
|
|
54
99
|
retirementPoll: boolean;
|
|
55
100
|
};
|
|
56
101
|
|
|
57
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap"]);
|
|
102
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation"]);
|
|
58
103
|
|
|
59
104
|
function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
60
105
|
if (raw === undefined) return {};
|
|
@@ -94,6 +139,12 @@ function names(raw: unknown, label: string, pattern: RegExp): string[] {
|
|
|
94
139
|
return [...new Set(raw as string[])];
|
|
95
140
|
}
|
|
96
141
|
|
|
142
|
+
function lowercaseName(raw: unknown, label: string, fallback: string): string {
|
|
143
|
+
const value = raw === undefined ? fallback : raw;
|
|
144
|
+
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}`);
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
function resolveHypeGap(raw: unknown, dimensions: string[]): { plus: string[]; minus: string[] } | undefined {
|
|
98
149
|
if (raw === undefined) return undefined;
|
|
99
150
|
const groups = object(raw, "hypeGap", ["plus", "minus"]);
|
|
@@ -122,6 +173,26 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
122
173
|
const karma = object(raw.karma, "karma", ["upvoteWeight", "usedItWeight"]);
|
|
123
174
|
const ranking = object(raw.ranking, "ranking", ["halfLifeHours", "risingWindowHours", "readerMinimum", "editorWeight"]);
|
|
124
175
|
const snapshot = object(raw.snapshot, "snapshot", ["schedule", "intervalHours", "hypeMinimum"]);
|
|
176
|
+
const submissions = object(raw.submissions, "submissions", ["perDay", "similarHours", "kinds", "itemType", "section"]);
|
|
177
|
+
const moderation = object(raw.moderation, "moderation", ["mode", "preModeration", "reportsPerDay", "ai"]);
|
|
178
|
+
const mode = moderation.mode === undefined ? "human" : moderation.mode;
|
|
179
|
+
if (!MODERATION_MODES.includes(mode as (typeof MODERATION_MODES)[number])) {
|
|
180
|
+
throw new Error(`reader-steering plugin moderation.mode must be one of: ${MODERATION_MODES.join(", ")}`);
|
|
181
|
+
}
|
|
182
|
+
const preModeration = flag(moderation.preModeration, "moderation.preModeration");
|
|
183
|
+
if (preModeration && mode === "off") throw new Error("reader-steering plugin moderation.preModeration needs a moderation.mode other than off");
|
|
184
|
+
const ai = object(moderation.ai, "moderation.ai", ["model", "apiKeyParameter"]);
|
|
185
|
+
if (mode !== "ai" && moderation.ai !== undefined) throw new Error("reader-steering plugin moderation.ai needs moderation.mode: ai");
|
|
186
|
+
if (mode === "ai") {
|
|
187
|
+
if (typeof ai.apiKeyParameter !== "string" || !AI_KEY_PARAMETER.test(ai.apiKeyParameter)) {
|
|
188
|
+
throw new Error("reader-steering plugin moderation.ai.apiKeyParameter must be the SSM SecureString name /papyrus/<site>/openai-api-key");
|
|
189
|
+
}
|
|
190
|
+
if (ai.model !== undefined && (typeof ai.model !== "string" || !AI_MODEL_NAME.test(ai.model))) {
|
|
191
|
+
throw new Error(`reader-steering plugin moderation.ai.model must be a model name matching ${AI_MODEL_NAME}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
195
|
+
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
125
196
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
126
197
|
const retirementPoll = flag(raw.retirementPoll, "retirementPoll");
|
|
127
198
|
if (retirementPoll && !flag(snapshot.schedule, "snapshot.schedule")) {
|
|
@@ -151,6 +222,25 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
151
222
|
intervalHours: integer(snapshot.intervalHours, "snapshot.intervalHours", 6, 1, 24),
|
|
152
223
|
hypeMinimum: integer(snapshot.hypeMinimum, "snapshot.hypeMinimum", 5, 1, 10000),
|
|
153
224
|
},
|
|
225
|
+
submissions: {
|
|
226
|
+
perDay: integer(submissions.perDay, "submissions.perDay", 10, 1, 1000),
|
|
227
|
+
similarHours: integer(submissions.similarHours, "submissions.similarHours", 48, 1, 24 * 30),
|
|
228
|
+
kinds: kinds.length > 0 ? kinds : ["link"],
|
|
229
|
+
itemType: lowercaseName(submissions.itemType, "submissions.itemType", "brief"),
|
|
230
|
+
section: lowercaseName(submissions.section, "submissions.section", "unsectioned"),
|
|
231
|
+
},
|
|
232
|
+
moderation: {
|
|
233
|
+
mode: mode as (typeof MODERATION_MODES)[number],
|
|
234
|
+
preModeration,
|
|
235
|
+
reportsPerDay: integer(moderation.reportsPerDay, "moderation.reportsPerDay", 20, 1, 1000),
|
|
236
|
+
...(mode === "ai" ? { ai: { model: (ai.model as string | undefined) ?? "gpt-6-luna", apiKeyParameter: ai.apiKeyParameter as string } } : {}),
|
|
237
|
+
},
|
|
238
|
+
comments: {
|
|
239
|
+
perHour: integer(comments.perHour, "comments.perHour", 30, 1, 1000),
|
|
240
|
+
editWindowMinutes: integer(comments.editWindowMinutes, "comments.editWindowMinutes", 10, 0, 24 * 60),
|
|
241
|
+
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
242
|
+
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
243
|
+
},
|
|
154
244
|
retirementPoll,
|
|
155
245
|
...(hypeGap ? { hypeGap } : {}),
|
|
156
246
|
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
class ModelOutputError(ValueError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Verdict:
|
|
47
|
+
decision: str
|
|
48
|
+
reason: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ModerationModel(Protocol):
|
|
52
|
+
def judge(self, item: dict[str, Any]) -> Verdict: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_prompt(path: str = PROMPT_FILE) -> str:
|
|
56
|
+
with open(path, encoding="utf-8") as handle:
|
|
57
|
+
return handle.read().strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_verdict(text: Any) -> Verdict:
|
|
61
|
+
"""A strict reading of the model's answer: one JSON object, ``decision`` allow or hide, a short plain reason."""
|
|
62
|
+
if not isinstance(text, str):
|
|
63
|
+
raise ModelOutputError("the model answered nothing")
|
|
64
|
+
start, end = text.find("{"), text.rfind("}")
|
|
65
|
+
if start < 0 or end < start:
|
|
66
|
+
raise ModelOutputError("the model did not answer with a JSON object")
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(text[start : end + 1])
|
|
69
|
+
except ValueError as error:
|
|
70
|
+
raise ModelOutputError("the model's answer is not valid JSON") from error
|
|
71
|
+
decision, reason = data.get("decision") if isinstance(data, dict) else None, data.get("reason") if isinstance(data, dict) else None
|
|
72
|
+
if decision not in (ALLOW, HIDE):
|
|
73
|
+
raise ModelOutputError("the decision is neither allow nor hide")
|
|
74
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
75
|
+
raise ModelOutputError("the model gave no reason")
|
|
76
|
+
return Verdict(decision, " ".join(reason.split())[:MAXIMUM_REASON])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def response_text(body: Any) -> str | None:
|
|
80
|
+
"""The text of a Responses API answer: ``output_text`` when present, else the text parts of the output messages."""
|
|
81
|
+
if not isinstance(body, dict):
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(body.get("output_text"), str):
|
|
84
|
+
return body["output_text"]
|
|
85
|
+
parts = [
|
|
86
|
+
part.get("text")
|
|
87
|
+
for message in body.get("output") or []
|
|
88
|
+
if isinstance(message, dict)
|
|
89
|
+
for part in message.get("content") or []
|
|
90
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str)
|
|
91
|
+
]
|
|
92
|
+
return "".join(parts) if parts else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def urllib_transport(url: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
|
|
97
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: # noqa: S310 - a fixed https endpoint
|
|
98
|
+
return json.loads(response.read().decode("utf-8"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OpenAIModerationModel:
|
|
102
|
+
"""GPT-6 Luna (``moderation.ai.model``) through the OpenAI Responses API. The key is held here and nowhere else."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
api_key: str,
|
|
107
|
+
*,
|
|
108
|
+
model: str = DEFAULT_MODEL,
|
|
109
|
+
prompt: str | None = None,
|
|
110
|
+
transport: Callable[[str, dict[str, str], dict[str, Any]], dict[str, Any]] = urllib_transport,
|
|
111
|
+
) -> None:
|
|
112
|
+
if not api_key:
|
|
113
|
+
raise ValueError("an API key is required")
|
|
114
|
+
self._api_key = api_key
|
|
115
|
+
self._model = model
|
|
116
|
+
self._prompt = prompt if prompt is not None else load_prompt()
|
|
117
|
+
self._transport = transport
|
|
118
|
+
|
|
119
|
+
def request_payload(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"model": self._model,
|
|
122
|
+
"instructions": self._prompt,
|
|
123
|
+
"input": json.dumps(item, ensure_ascii=False, separators=(",", ":")),
|
|
124
|
+
"text": {"format": {"type": "json_object"}},
|
|
125
|
+
"max_output_tokens": 300,
|
|
126
|
+
"store": False,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def judge(self, item: dict[str, Any]) -> Verdict:
|
|
130
|
+
body = self._transport(RESPONSES_URL, {"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}, self.request_payload(item))
|
|
131
|
+
return parse_verdict(response_text(body))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def model_input(kind: str, row: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
+
"""What the model sees: the content, the automatic signals and the report count. Never a reader id, a handle or a siteSubject."""
|
|
136
|
+
signals = row.get("signals")
|
|
137
|
+
if isinstance(signals, str):
|
|
138
|
+
try:
|
|
139
|
+
signals = json.loads(signals)
|
|
140
|
+
except ValueError:
|
|
141
|
+
signals = []
|
|
142
|
+
listed = [
|
|
143
|
+
{"policy": str(signal.get("policy", ""))[:60], "detector": str(signal.get("detector", ""))[:60], "severity": str(signal.get("severity", ""))[:12]}
|
|
144
|
+
for signal in (signals if isinstance(signals, list) else [])
|
|
145
|
+
if isinstance(signal, dict)
|
|
146
|
+
][:20]
|
|
147
|
+
if kind == rules.TARGET_SUBMISSION:
|
|
148
|
+
item: dict[str, Any] = {"kind": "submission", "title": row["title"], "url": row["canonicalUrl"], "domain": row["domain"], "text": row.get("note") or ""}
|
|
149
|
+
else:
|
|
150
|
+
item = {"kind": "comment", "text": row["body"]}
|
|
151
|
+
item["text"] = str(item["text"])[:MAXIMUM_TEXT]
|
|
152
|
+
item["signals"] = listed
|
|
153
|
+
item["reports"] = int(row.get("reportCount") or 0)
|
|
154
|
+
return item
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class AiReport:
|
|
159
|
+
judged: int = 0
|
|
160
|
+
allowed: int = 0
|
|
161
|
+
hidden: int = 0
|
|
162
|
+
skipped_human: int = 0
|
|
163
|
+
errors: dict[str, int] = field(default_factory=dict)
|
|
164
|
+
stopped: bool = False
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict[str, Any]:
|
|
167
|
+
return {"judged": self.judged, "allowed": self.allowed, "hidden": self.hidden, "skippedHuman": self.skipped_human, "errors": dict(self.errors), "stopped": self.stopped}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AiStore(Protocol):
|
|
171
|
+
def new_content(self) -> list[tuple[str, dict[str, Any]]]: ...
|
|
172
|
+
|
|
173
|
+
def human_acted_on(self) -> set[str]: ...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_ai_moderation(
|
|
177
|
+
store: Any,
|
|
178
|
+
model: ModerationModel,
|
|
179
|
+
*,
|
|
180
|
+
now: Callable[[], float],
|
|
181
|
+
settings: rules.ModerationSettings,
|
|
182
|
+
deadline: float,
|
|
183
|
+
limit: int = BATCH_LIMIT,
|
|
184
|
+
iso: Callable[[float], str],
|
|
185
|
+
) -> AiReport:
|
|
186
|
+
"""Judges the oldest new items first, until the batch limit, the deadline, or three failures in a row."""
|
|
187
|
+
report = AiReport()
|
|
188
|
+
human_targets = store.human_acted_on()
|
|
189
|
+
failures_in_a_row = 0
|
|
190
|
+
candidates = sorted(store.new_content(), key=lambda entry: (entry[1]["createdAt"], entry[1]["id"]))
|
|
191
|
+
for kind, row in candidates:
|
|
192
|
+
if report.judged >= limit or now() >= deadline:
|
|
193
|
+
break
|
|
194
|
+
if row["id"] in human_targets:
|
|
195
|
+
report.skipped_human += 1
|
|
196
|
+
continue
|
|
197
|
+
if row.get("state") == "deleted":
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
verdict = model.judge(model_input(kind, row))
|
|
201
|
+
except Exception as error: # noqa: BLE001 - reported by class name only: a message could carry the key or the content
|
|
202
|
+
report.errors[type(error).__name__] = report.errors.get(type(error).__name__, 0) + 1
|
|
203
|
+
failures_in_a_row += 1
|
|
204
|
+
if failures_in_a_row >= CONSECUTIVE_FAILURES_BEFORE_STOPPING:
|
|
205
|
+
report.stopped = True
|
|
206
|
+
break
|
|
207
|
+
continue
|
|
208
|
+
failures_in_a_row = 0
|
|
209
|
+
action = rules.APPROVE if verdict.decision == ALLOW else rules.HIDE
|
|
210
|
+
outcome = rules.apply_action(
|
|
211
|
+
store,
|
|
212
|
+
action=action,
|
|
213
|
+
target_type=kind,
|
|
214
|
+
target=row["id"],
|
|
215
|
+
reason=REASON_PREFIX + verdict.reason,
|
|
216
|
+
undo_of=None,
|
|
217
|
+
actor_sub=AI_ACTOR,
|
|
218
|
+
by=rules.BY_AI,
|
|
219
|
+
now_iso=iso(now()),
|
|
220
|
+
settings=settings,
|
|
221
|
+
)
|
|
222
|
+
if outcome.status != "ok":
|
|
223
|
+
report.errors[outcome.error or "refused"] = report.errors.get(outcome.error or "refused", 0) + 1
|
|
224
|
+
continue
|
|
225
|
+
report.judged += 1
|
|
226
|
+
if action == rules.APPROVE:
|
|
227
|
+
report.allowed += 1
|
|
228
|
+
else:
|
|
229
|
+
report.hidden += 1
|
|
230
|
+
return report
|