@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.
Files changed (54) hide show
  1. package/amplify/functions/content-actions/handler.py +17 -2
  2. package/amplify/plugins/newsletter/config.js +195 -0
  3. package/amplify/plugins/newsletter/config.ts +240 -0
  4. package/amplify/plugins/newsletter/function/handler.py +6 -0
  5. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  6. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  7. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  8. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  9. package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
  10. package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
  11. package/amplify/plugins/newsletter/schema.js +56 -0
  12. package/amplify/plugins/newsletter/schema.ts +61 -0
  13. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  14. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  15. package/amplify/plugins/newsletter/sending.js +140 -0
  16. package/amplify/plugins/newsletter/sending.ts +186 -0
  17. package/amplify/plugins/reader-steering/config.js +52 -1
  18. package/amplify/plugins/reader-steering/config.ts +91 -1
  19. package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
  20. package/amplify/plugins/reader-steering/function/handler.py +829 -10
  21. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  22. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  23. package/amplify/plugins/reader-steering/function/resource.js +1 -1
  24. package/amplify/plugins/reader-steering/function/resource.ts +1 -1
  25. package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
  26. package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
  27. package/amplify/plugins/reader-steering/schema.js +101 -1
  28. package/amplify/plugins/reader-steering/schema.ts +101 -1
  29. package/amplify/plugins/registry.js +2 -0
  30. package/amplify/plugins/registry.ts +2 -0
  31. package/amplify/plugins/types.ts +2 -0
  32. package/app/api/newsletter/confirm/route.ts +13 -0
  33. package/app/api/newsletter/preferences/route.ts +13 -0
  34. package/app/api/newsletter/subscribe/route.ts +9 -0
  35. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  36. package/app/api/reader/comment/delete/route.ts +9 -0
  37. package/app/api/reader/comment/edit/route.ts +9 -0
  38. package/app/api/reader/comment/route.ts +9 -0
  39. package/app/api/reader/report/route.ts +9 -0
  40. package/app/api/reader/submit/route.ts +9 -0
  41. package/components/moderation-panel.tsx +248 -0
  42. package/components/newsletter-panel.tsx +474 -0
  43. package/components/newsletter-sections.tsx +163 -0
  44. package/components/newsroom-organizer.css +14 -0
  45. package/components/topic-steering-workspace.tsx +42 -5
  46. package/lib/newsletter-api.ts +391 -0
  47. package/lib/newsletter-client.ts +46 -0
  48. package/lib/newsletter-fixture.ts +184 -0
  49. package/lib/newsletter-model.ts +202 -0
  50. package/lib/newsletter-pages.ts +264 -0
  51. package/lib/reader-api.ts +7 -5
  52. package/lib/reader-moderation.ts +220 -0
  53. package/package.json +19 -1
  54. 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