jevmod 0.2.0__py3-none-any.whl
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.
- jevmod/__init__.py +67 -0
- jevmod/__main__.py +50 -0
- jevmod/adapters/__init__.py +0 -0
- jevmod/adapters/discord_bot.py +324 -0
- jevmod/adapters/reddit_bot.py +91 -0
- jevmod/adapters/telegram_bot.py +206 -0
- jevmod/api/__init__.py +0 -0
- jevmod/api/demo.py +199 -0
- jevmod/api/server.py +185 -0
- jevmod/categories.json +69 -0
- jevmod/cli.py +97 -0
- jevmod/core/__init__.py +17 -0
- jevmod/core/policy.py +155 -0
- jevmod/core/service.py +117 -0
- jevmod/core/store.py +215 -0
- jevmod/judge.py +189 -0
- jevmod/keys.py +146 -0
- jevmod/mcp_server.py +76 -0
- jevmod-0.2.0.dist-info/METADATA +293 -0
- jevmod-0.2.0.dist-info/RECORD +24 -0
- jevmod-0.2.0.dist-info/WHEEL +5 -0
- jevmod-0.2.0.dist-info/entry_points.txt +2 -0
- jevmod-0.2.0.dist-info/licenses/LICENSE +21 -0
- jevmod-0.2.0.dist-info/top_level.txt +1 -0
jevmod/judge.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The judgment core: a batch of messages in, one Jev request, a probability per category per message out.
|
|
2
|
+
|
|
3
|
+
Cost controls live here, not in the bot: local pre-filters decide what is worth judging, a cache reuses verdicts
|
|
4
|
+
for repeated text, and only the categories a server enabled are asked.
|
|
5
|
+
|
|
6
|
+
Findings from the adversarial red team that shaped this file (tests/data/redteam.csv):
|
|
7
|
+
- Messages go to Jev as a dict keyed by position (`messages.m3.text`), not a list. With a list, probabilities
|
|
8
|
+
leaked between neighbouring positions in multilingual batches (a clean German message inherited harassment 0.95).
|
|
9
|
+
- Text is NFKC-normalised and stripped of combining marks before judging and caching: fullwidth, enclosed
|
|
10
|
+
alphanumerics and zalgo were slipping under the thresholds or the pre-filter.
|
|
11
|
+
- The pre-filter counts characters, not space-separated words: Japanese and Chinese never have spaces.
|
|
12
|
+
- Every question carries `criteria`, otherwise "offtopic" measured "undesirable" and "spam" caught one-off sales.
|
|
13
|
+
|
|
14
|
+
Against the TypeSafe docs (checked 2026-09-18): one Noul per hazard with true/false criteria is the pattern of the
|
|
15
|
+
`llm_guardrails` cookbook. Judging several messages in one request is NOT a documented pattern (the cookbooks send
|
|
16
|
+
one item per request, or many questions about one document); it is our own cost trade-off, kept only because
|
|
17
|
+
`tests/test_redteam.py::test_batch_matches_single_verdicts_in_multilingual_batch` shows batch and single verdicts
|
|
18
|
+
agree once the state is a dict. 429/529 are retried with exponential backoff and Retry-After, as the API asks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import html
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
import unicodedata
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from typesafe_sdk import Noul, NoulAnswer, RetryPolicy, TypeSafeClient
|
|
34
|
+
|
|
35
|
+
from .keys import get_api_key
|
|
36
|
+
|
|
37
|
+
# The questions live in categories.json so every implementation (Python, npm, MCP) asks Jev exactly the same thing.
|
|
38
|
+
CATEGORIES: dict[str, dict[str, Any]] = json.loads(
|
|
39
|
+
(Path(__file__).with_name("categories.json")).read_text(encoding="utf-8")
|
|
40
|
+
)["categories"]
|
|
41
|
+
|
|
42
|
+
LINK_RE = re.compile(
|
|
43
|
+
r"(https?://|hxxps?://|www\.|\S+\[\.\]\S+|\b[\w-]+\.(?:gg|com|net|org|ru|io|xyz|fr|de|jp|br)\b/?)", re.I
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Enclosed Alphanumeric Supplement (🄰 🅐 🅰 …) has no NFKC decomposition; map the three A-Z rows by hand.
|
|
48
|
+
_ENCLOSED = {cp + k: chr(ord("A") + k) for cp in (0x1F130, 0x1F150, 0x1F170) for k in range(26)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize(text: str) -> str:
|
|
52
|
+
"""HTML entities → text (scraped comments carry `'`), NFKC (fullwidth, enclosed letters, ligatures → plain),
|
|
53
|
+
drop combining marks (zalgo), collapse whitespace."""
|
|
54
|
+
t = unicodedata.normalize("NFKC", html.unescape(text).translate(_ENCLOSED))
|
|
55
|
+
# drop combining marks (zalgo) and format characters (zero-width spaces/joiners used to split words, BOM)
|
|
56
|
+
t = "".join(ch for ch in t if not unicodedata.combining(ch) and unicodedata.category(ch) != "Cf")
|
|
57
|
+
return " ".join(t.split())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Message:
|
|
62
|
+
id: str
|
|
63
|
+
text: str
|
|
64
|
+
author: str = ""
|
|
65
|
+
channel_topic: str = ""
|
|
66
|
+
author_trusted: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Verdict:
|
|
71
|
+
message_id: str
|
|
72
|
+
scores: dict[str, float] # category -> probability
|
|
73
|
+
judged: bool # False when a pre-filter skipped Jev
|
|
74
|
+
reason: str = "" # why it was skipped, or "cache" / "jev"
|
|
75
|
+
custom: dict[str, float] = field(default_factory=dict) # server-defined rules -> probability
|
|
76
|
+
|
|
77
|
+
def top(self) -> tuple[str, float] | None:
|
|
78
|
+
allscores = {**self.scores, **self.custom}
|
|
79
|
+
if not allscores:
|
|
80
|
+
return None
|
|
81
|
+
k = max(allscores, key=lambda c: allscores[c])
|
|
82
|
+
return k, allscores[k]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prefilter(m: Message, min_chars: int = 8) -> str | None:
|
|
86
|
+
"""Return a reason to skip judging, or None to judge. Counts letters/digits in any script, not words."""
|
|
87
|
+
if m.author_trusted:
|
|
88
|
+
return "trusted author"
|
|
89
|
+
text = normalize(m.text)
|
|
90
|
+
if not text:
|
|
91
|
+
return "empty"
|
|
92
|
+
if LINK_RE.search(text):
|
|
93
|
+
return None # a link is never too short
|
|
94
|
+
letters = sum(1 for ch in text if ch.isalnum())
|
|
95
|
+
if letters < min_chars:
|
|
96
|
+
return "too short"
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Judge:
|
|
101
|
+
def __init__(self, client: TypeSafeClient | None = None, cache_ttl_s: int = 86400, timeout_s: float = 20.0) -> None:
|
|
102
|
+
self.client = client or TypeSafeClient(
|
|
103
|
+
api_key=get_api_key(),
|
|
104
|
+
retry=RetryPolicy(
|
|
105
|
+
max_retries=3, backoff_initial=0.5, backoff_max=8.0, http_statuses={429, 500, 502, 503, 504, 529}
|
|
106
|
+
),
|
|
107
|
+
timeout=timeout_s,
|
|
108
|
+
)
|
|
109
|
+
self.cache: dict[str, tuple[float, dict[str, float], dict[str, float]]] = {}
|
|
110
|
+
self.cache_ttl = cache_ttl_s
|
|
111
|
+
self.requests = 0
|
|
112
|
+
self.input_tokens = 0
|
|
113
|
+
self.judged_messages = 0
|
|
114
|
+
|
|
115
|
+
def judge(
|
|
116
|
+
self,
|
|
117
|
+
messages: list[Message],
|
|
118
|
+
categories: list[str],
|
|
119
|
+
custom_rules: dict[str, str] | None = None,
|
|
120
|
+
) -> list[Verdict]:
|
|
121
|
+
"""One Jev request for every message that passes the pre-filter and is not cached."""
|
|
122
|
+
custom_rules = custom_rules or {}
|
|
123
|
+
cats = [c for c in categories if c in CATEGORIES]
|
|
124
|
+
out: dict[str, Verdict] = {}
|
|
125
|
+
to_judge: list[tuple[Message, str]] = []
|
|
126
|
+
now = time.time()
|
|
127
|
+
for m in messages:
|
|
128
|
+
why = prefilter(m)
|
|
129
|
+
if why:
|
|
130
|
+
out[m.id] = Verdict(m.id, {}, False, why)
|
|
131
|
+
continue
|
|
132
|
+
text = normalize(m.text)
|
|
133
|
+
key = _key(text, m.channel_topic, cats, custom_rules)
|
|
134
|
+
hit = self.cache.get(key)
|
|
135
|
+
if hit and now - hit[0] < self.cache_ttl:
|
|
136
|
+
out[m.id] = Verdict(m.id, dict(hit[1]), True, "cache", dict(hit[2]))
|
|
137
|
+
continue
|
|
138
|
+
to_judge.append((m, text))
|
|
139
|
+
|
|
140
|
+
if to_judge and (cats or custom_rules):
|
|
141
|
+
# only the text and the channel topic reach Jev: no author names, no ids beyond the position
|
|
142
|
+
state: dict[str, Any] = {
|
|
143
|
+
"messages": {
|
|
144
|
+
f"m{i}": {"text": text, "channel_topic": m.channel_topic or "general chat"}
|
|
145
|
+
for i, (m, text) in enumerate(to_judge)
|
|
146
|
+
},
|
|
147
|
+
"custom_rules": custom_rules,
|
|
148
|
+
}
|
|
149
|
+
questions: dict[str, Noul] = {}
|
|
150
|
+
for i, _ in enumerate(to_judge):
|
|
151
|
+
path = f"messages.m{i}"
|
|
152
|
+
for c in cats:
|
|
153
|
+
questions[f"{c}_{i}"] = Noul(
|
|
154
|
+
instructions=CATEGORIES[c]["instructions"].format(m=path), criteria=CATEGORIES[c]["criteria"]
|
|
155
|
+
)
|
|
156
|
+
for name, rule in custom_rules.items():
|
|
157
|
+
questions[f"custom__{name}_{i}"] = Noul(
|
|
158
|
+
instructions=f"Does `{path}.text` break this community rule: `custom_rules.{name}` ({rule!r})?",
|
|
159
|
+
criteria={
|
|
160
|
+
"true": "the message does what the rule forbids, as a moderator who wrote it would read it",
|
|
161
|
+
"false": "the message is ordinary conversation, or the rule does not clearly cover it; "
|
|
162
|
+
"when the rule lists exceptions, those are allowed",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
resp = self.client.system_one(state=state, questions=questions)
|
|
166
|
+
self.requests += 1
|
|
167
|
+
self.input_tokens += getattr(getattr(resp, "usage", None), "input_tokens", 0) or 0
|
|
168
|
+
self.judged_messages += len(to_judge)
|
|
169
|
+
for i, (m, text) in enumerate(to_judge):
|
|
170
|
+
scores = {c: _p(resp.answers[f"{c}_{i}"]) for c in cats}
|
|
171
|
+
custom = {name: _p(resp.answers[f"custom__{name}_{i}"]) for name in custom_rules}
|
|
172
|
+
self.cache[_key(text, m.channel_topic, cats, custom_rules)] = (now, scores, custom)
|
|
173
|
+
out[m.id] = Verdict(m.id, scores, True, "jev", custom)
|
|
174
|
+
elif to_judge:
|
|
175
|
+
for m, _ in to_judge:
|
|
176
|
+
out[m.id] = Verdict(m.id, {}, False, "no categories enabled")
|
|
177
|
+
return [out[m.id] for m in messages]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _p(answer: Any) -> float:
|
|
181
|
+
if not isinstance(answer, NoulAnswer):
|
|
182
|
+
raise TypeError(f"expected a Noul answer, got {type(answer).__name__}")
|
|
183
|
+
return float(answer.noul)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _key(text: str, topic: str, cats: list[str], rules: dict[str, str]) -> str:
|
|
187
|
+
norm = text.lower()
|
|
188
|
+
h = hashlib.sha256(f"{norm}|{topic}|{','.join(cats)}|{sorted(rules.items())}".encode()).hexdigest()
|
|
189
|
+
return h[:32]
|
jevmod/keys.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Where the TypeSafe key comes from, in this order: `TYPESAFE_API_KEY` in the environment, the OS keyring, `.env` in
|
|
2
|
+
the current directory. `jevmod init` stores it (keyring first, `.env` with mode 600 as the fallback). Nothing here
|
|
3
|
+
ever prints or logs the key.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SERVICE = "jevmod"
|
|
13
|
+
ACCOUNT = "TYPESAFE_API_KEY"
|
|
14
|
+
ENV_VAR = "TYPESAFE_API_KEY"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_api_key(cwd: Path | None = None) -> str | None:
|
|
18
|
+
"""Environment -> keyring -> .env: an explicit variable always wins over what `jevmod init` stored, so a
|
|
19
|
+
container, a CI job or a test can override the machine's key. None when nothing is set (TypeSafeClient then
|
|
20
|
+
raises its own error)."""
|
|
21
|
+
return os.environ.get(ENV_VAR, "").strip() or _from_keyring() or read_dotenv(cwd or Path.cwd()).get(ENV_VAR)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _from_keyring() -> str | None:
|
|
25
|
+
try:
|
|
26
|
+
import keyring
|
|
27
|
+
except ImportError:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
value = keyring.get_password(SERVICE, ACCOUNT)
|
|
31
|
+
except Exception: # no backend (headless Linux), locked vault, broken install: fall through
|
|
32
|
+
return None
|
|
33
|
+
return value.strip() if value and value.strip() else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def store_in_keyring(key: str) -> bool:
|
|
37
|
+
"""True when the key is now in the OS keyring, False when keyring is missing or refused."""
|
|
38
|
+
try:
|
|
39
|
+
import keyring
|
|
40
|
+
except ImportError:
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
keyring.set_password(SERVICE, ACCOUNT, key)
|
|
44
|
+
return keyring.get_password(SERVICE, ACCOUNT) == key
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_dotenv(directory: Path) -> dict[str, str]:
|
|
50
|
+
"""Minimal `.env` parser: KEY=value lines, optional `export `, quotes stripped, `#` comments ignored."""
|
|
51
|
+
path = directory / ".env"
|
|
52
|
+
out: dict[str, str] = {}
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return out
|
|
55
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
56
|
+
line = raw.strip()
|
|
57
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
58
|
+
continue
|
|
59
|
+
if line.startswith("export "):
|
|
60
|
+
line = line[7:]
|
|
61
|
+
k, _, v = line.partition("=")
|
|
62
|
+
v = v.strip()
|
|
63
|
+
if v[:1] in ("'", '"') and v.find(v[0], 1) > 0:
|
|
64
|
+
v = v[1 : v.find(v[0], 1)]
|
|
65
|
+
elif " #" in v:
|
|
66
|
+
v = v.split(" #", 1)[0].rstrip()
|
|
67
|
+
if v:
|
|
68
|
+
out[k.strip()] = v
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_dotenv(key: str, directory: Path) -> Path:
|
|
73
|
+
"""Set TYPESAFE_API_KEY in `.env` (other lines kept), mode 600, and make sure `.gitignore` lists `.env`."""
|
|
74
|
+
path = directory / ".env"
|
|
75
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
|
76
|
+
entry = f"{ENV_VAR}={key}"
|
|
77
|
+
replaced = False
|
|
78
|
+
for i, line in enumerate(lines):
|
|
79
|
+
if line.strip().removeprefix("export ").startswith(f"{ENV_VAR}="):
|
|
80
|
+
lines[i] = entry
|
|
81
|
+
replaced = True
|
|
82
|
+
if not replaced:
|
|
83
|
+
lines.append(entry)
|
|
84
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
85
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
86
|
+
gitignore = directory / ".gitignore"
|
|
87
|
+
if gitignore.is_file():
|
|
88
|
+
entries = [ln.strip() for ln in gitignore.read_text(encoding="utf-8").splitlines()]
|
|
89
|
+
if ".env" not in entries:
|
|
90
|
+
with gitignore.open("a", encoding="utf-8") as fh:
|
|
91
|
+
fh.write("\n.env\n")
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_key(key: str) -> None:
|
|
96
|
+
"""One tiny Jev call; raises TypeSafeError when the key is rejected or the API is unreachable."""
|
|
97
|
+
from typesafe_sdk import Noul, RetryPolicy, TypeSafeClient
|
|
98
|
+
|
|
99
|
+
with TypeSafeClient(api_key=key, retry=RetryPolicy(max_retries=1), timeout=15.0) as client:
|
|
100
|
+
client.system_one(state={"text": "hello"}, questions={"ok": Noul(instructions="Is `text` a greeting?")})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def forget() -> int:
|
|
104
|
+
"""`jevmod init --forget`: remove the key from the OS keyring. `.env` files are yours to delete."""
|
|
105
|
+
try:
|
|
106
|
+
import keyring
|
|
107
|
+
from keyring.errors import PasswordDeleteError
|
|
108
|
+
except ImportError:
|
|
109
|
+
print("keyring is not installed; nothing stored there. Delete .env by hand if you wrote one.")
|
|
110
|
+
return 0
|
|
111
|
+
try:
|
|
112
|
+
keyring.delete_password(SERVICE, ACCOUNT)
|
|
113
|
+
print("key removed from the OS keyring (service 'jevmod')")
|
|
114
|
+
except PasswordDeleteError:
|
|
115
|
+
print("no key stored in the OS keyring")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def init(directory: Path | None = None, *, prefer_env_file: bool = False, prompt: bool = True) -> int:
|
|
120
|
+
"""`jevmod init`: ask for the key without echo, validate it, store it. Returns an exit code."""
|
|
121
|
+
import sys
|
|
122
|
+
from getpass import getpass
|
|
123
|
+
|
|
124
|
+
from typesafe_sdk import TypeSafeError
|
|
125
|
+
|
|
126
|
+
directory = directory or Path.cwd()
|
|
127
|
+
try:
|
|
128
|
+
key = getpass("TypeSafe API key (input hidden): ").strip() if prompt else os.environ.get(ENV_VAR, "").strip()
|
|
129
|
+
except (EOFError, KeyboardInterrupt):
|
|
130
|
+
print("cancelled", file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
if not key:
|
|
133
|
+
print("no key given", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
try:
|
|
136
|
+
validate_key(key)
|
|
137
|
+
except TypeSafeError as exc:
|
|
138
|
+
print(f"key rejected: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
if not prefer_env_file and store_in_keyring(key):
|
|
141
|
+
print("key verified and stored in the OS keyring (service 'jevmod')")
|
|
142
|
+
return 0
|
|
143
|
+
path = write_dotenv(key, directory)
|
|
144
|
+
why = "" if prefer_env_file else " (keyring unavailable)"
|
|
145
|
+
print(f"key verified and written to {path} with mode 600{why}; .env is git-ignored when a .gitignore exists")
|
|
146
|
+
return 0
|
jevmod/mcp_server.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""MCP server over stdio: `jevmod mcp`. Two tools, no state, no database.
|
|
2
|
+
|
|
3
|
+
moderate(texts, channel_topic="", rules=None) -> one decision dict per text (action, category, probability, scores)
|
|
4
|
+
categories() -> what each category means and its default action and threshold
|
|
5
|
+
|
|
6
|
+
Register it in Claude Code, Cursor or Codex as a stdio server with command `jevmod` and args `["mcp"]`; the key
|
|
7
|
+
comes from `TYPESAFE_API_KEY`, the OS keyring or `.env`, in that order (see `jevmod init`). Built on the MCP Python SDK
|
|
8
|
+
(`mcp>=2`, where FastMCP became `MCPServer`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mcp.server.mcpserver import MCPServer
|
|
16
|
+
|
|
17
|
+
from . import Moderator, Policy
|
|
18
|
+
from .core.policy import DEFAULT_ACTIONS, DEFAULT_THRESHOLDS
|
|
19
|
+
from .judge import CATEGORIES, Judge
|
|
20
|
+
|
|
21
|
+
server = MCPServer(
|
|
22
|
+
"jevmod",
|
|
23
|
+
instructions="Moderation decisions for user text: spam, scam, harassment, nsfw, offtopic, selfharm, doxxing, "
|
|
24
|
+
"minors and plain-language rules. Every category is on (flag) with default thresholds; offtopic only when a "
|
|
25
|
+
"channel_topic is given. Nothing is stored.",
|
|
26
|
+
)
|
|
27
|
+
_judge: Judge | None = None # one client and one verdict cache per process; policy is rebuilt per call
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _moderator(channel_topic: str, rules: dict[str, str] | None) -> Moderator:
|
|
31
|
+
global _judge
|
|
32
|
+
if _judge is None:
|
|
33
|
+
_judge = Judge()
|
|
34
|
+
policy = Policy()
|
|
35
|
+
for c in CATEGORIES:
|
|
36
|
+
policy.set_category(c, "off" if c == "offtopic" and not channel_topic else "flag")
|
|
37
|
+
for name, text in (rules or {}).items():
|
|
38
|
+
policy.set_rule(name, text)
|
|
39
|
+
return Moderator(policy=policy, judge=_judge)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@server.tool()
|
|
43
|
+
def moderate(texts: list[str], channel_topic: str = "", rules: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
|
44
|
+
"""Judge up to 50 texts in one Jev request. Returns one decision per text, in order: action ("none" | "flag"),
|
|
45
|
+
category (the winning one, or "rule:<name>"), probability, scores for every category, judged (false when a
|
|
46
|
+
pre-filter skipped it: too short, empty) and reason. `rules` maps a name to a rule in plain language, up to 5."""
|
|
47
|
+
if not texts:
|
|
48
|
+
return []
|
|
49
|
+
if len(texts) > 50:
|
|
50
|
+
raise ValueError("up to 50 texts per call")
|
|
51
|
+
mod = _moderator(channel_topic, rules)
|
|
52
|
+
return [d.to_dict() for d in mod.check_many(texts, channel_topic=channel_topic)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@server.tool()
|
|
56
|
+
def categories() -> list[dict[str, Any]]:
|
|
57
|
+
"""The moderation categories: name, label, when they are true or false, default action and threshold."""
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
"name": name,
|
|
61
|
+
"label": spec["label"],
|
|
62
|
+
"true_when": spec["criteria"]["true"],
|
|
63
|
+
"false_when": spec["criteria"]["false"],
|
|
64
|
+
"default_action": DEFAULT_ACTIONS.get(name, "flag"),
|
|
65
|
+
"default_threshold": DEFAULT_THRESHOLDS.get(name, 0.8),
|
|
66
|
+
}
|
|
67
|
+
for name, spec in CATEGORIES.items()
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
server.run("stdio")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|