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/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()