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.
@@ -0,0 +1,206 @@
1
+ """Telegram adapter. Add the bot to a group as admin; it flags by replying in a private admin chat (or the group's
2
+ log topic) and can delete or mute when you enable it. Commands for admins: /mod_status, /mod_set, /mod_rule, /mod_log.
3
+
4
+ TELEGRAM_TOKEN=... TYPESAFE_API_KEY=... python -m jevmod.adapters.telegram_bot
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Any
14
+
15
+ from telegram import ChatPermissions, Update
16
+ from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
17
+
18
+ from ..core import FREE_MONTHLY, Batcher, ModerationService, Store
19
+ from ..judge import CATEGORIES, Message
20
+
21
+ log = logging.getLogger("jevmod.telegram")
22
+ store = Store(os.environ.get("JEVMOD_DB", "jevmod.sqlite"))
23
+ service = ModerationService(store)
24
+
25
+
26
+ def tenant_of(chat_id: int) -> str:
27
+ return f"telegram:{chat_id}"
28
+
29
+
30
+ async def _is_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
31
+ if not update.effective_chat or not update.effective_user:
32
+ return False
33
+ member = await context.bot.get_chat_member(update.effective_chat.id, update.effective_user.id)
34
+ return member.status in ("administrator", "creator")
35
+
36
+
37
+ async def handle_batch(tenant: str, batch: list[tuple[Update, ContextTypes.DEFAULT_TYPE]]) -> None:
38
+ chat_id = batch[0][0].effective_chat.id # type: ignore[union-attr]
39
+ context = batch[0][1]
40
+ admins = {m.user.id for m in await context.bot.get_chat_administrators(chat_id)}
41
+ msgs = []
42
+ for upd, _ in batch:
43
+ m = upd.effective_message
44
+ if m is None:
45
+ continue
46
+ msgs.append(
47
+ Message(
48
+ id=str(m.message_id),
49
+ text=m.text or m.caption or "",
50
+ author=m.from_user.full_name if m.from_user else "",
51
+ channel_topic=store.get_meta(tenant).get("topic", "group chat"),
52
+ author_trusted=bool(m.from_user and m.from_user.id in admins),
53
+ )
54
+ )
55
+ decisions = await asyncio.to_thread(service.moderate, tenant, msgs)
56
+ policy = service.policy(tenant)
57
+ for (upd, ctx), d in zip(batch, decisions, strict=True):
58
+ if d.reason == "quota":
59
+ if store.note_quota_hit(tenant):
60
+ await _log(
61
+ ctx,
62
+ tenant,
63
+ chat_id,
64
+ f"jevmod paused this month: the monthly quota of {FREE_MONTHLY:,} judged messages was reached.",
65
+ )
66
+ return
67
+ if d.action == "none":
68
+ continue
69
+ m = upd.effective_message
70
+ if m is None:
71
+ continue
72
+ note = ""
73
+ try:
74
+ if d.action in ("delete", "timeout"):
75
+ await m.delete()
76
+ note = "deleted"
77
+ if d.action == "timeout" and m.from_user:
78
+ until = datetime.now(timezone.utc) + timedelta(minutes=policy.timeout_minutes)
79
+ await ctx.bot.restrict_chat_member(
80
+ chat_id, m.from_user.id, ChatPermissions(can_send_messages=False), until_date=until
81
+ )
82
+ note = f"deleted, muted {policy.timeout_minutes} min"
83
+ except Exception as exc:
84
+ note = f"could not act: {type(exc).__name__}"
85
+ top = " · ".join(f"{c} {p:.2f}" for c, p in sorted(d.scores.items(), key=lambda kv: -kv[1])[:3])
86
+ text = (
87
+ f"{d.category} p={d.probability:.2f} → {d.action}" + (f" ({note})" if note else "") + "\n"
88
+ f"from {msgs[0].author if len(msgs) == 1 else (m.from_user.full_name if m.from_user else '?')}: "
89
+ f"{(m.text or m.caption or '')[:300]}\n{top}"
90
+ )
91
+ await _log(ctx, tenant, chat_id, text)
92
+
93
+
94
+ async def _log(context: ContextTypes.DEFAULT_TYPE, tenant: str, chat_id: int, text: str) -> None:
95
+ target = store.get_meta(tenant).get("log_chat", chat_id)
96
+ try:
97
+ await context.bot.send_message(int(target), text, disable_web_page_preview=True)
98
+ except Exception as exc:
99
+ log.warning("cannot log to %s: %s", target, exc)
100
+
101
+
102
+ batcher = Batcher(2.0, handle_batch)
103
+
104
+
105
+ async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
106
+ m = update.effective_message
107
+ if not m or not update.effective_chat or update.effective_chat.type == "private":
108
+ return
109
+ if not (m.text or m.caption) or (m.from_user and m.from_user.is_bot):
110
+ return
111
+ tenant = tenant_of(update.effective_chat.id)
112
+ if not service.policy(tenant).active():
113
+ return
114
+ batcher.add(tenant, (update, context))
115
+
116
+
117
+ def _reply(update: Update) -> Any:
118
+ assert update.effective_message is not None
119
+ return update.effective_message.reply_text
120
+
121
+
122
+ async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
123
+ if not await _is_admin(update, context):
124
+ return
125
+ tenant = tenant_of(update.effective_chat.id) # type: ignore[union-attr]
126
+ p = service.policy(tenant)
127
+ judged, requests, tokens = store.usage(tenant)
128
+ lines = [f"{c}: {p.actions.get(c, 'off')} at p ≥ {p.thresholds.get(c, 0.9):.2f}" for c in CATEGORIES]
129
+ lines += [f'rule {n}: {p.rule_actions.get(n, "flag")} · "{r}"' for n, r in p.rules.items()]
130
+ cap = f"/{FREE_MONTHLY:,}" if FREE_MONTHLY else ""
131
+ lines.append(f"\n{judged:,}{cap} judged this month · {requests} Jev requests · {tokens:,} tokens")
132
+ await _reply(update)("\n".join(lines)) # type: ignore[union-attr]
133
+
134
+
135
+ async def cmd_set(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
136
+ """/mod_set <category> <action> [threshold]"""
137
+ if not await _is_admin(update, context):
138
+ return
139
+ args = context.args or []
140
+ tenant = tenant_of(update.effective_chat.id) # type: ignore[union-attr]
141
+ p = service.policy(tenant)
142
+ try:
143
+ p.set_category(args[0], args[1], float(args[2]) if len(args) > 2 else None)
144
+ except (IndexError, ValueError) as exc:
145
+ await _reply(update)(f"usage: /mod_set <category> <off|flag|delete|timeout> [0.5-0.99]\n{exc}") # type: ignore[union-attr]
146
+ return
147
+ service.save_policy(tenant, p)
148
+ await _reply(update)(f"{args[0]} → {args[1]} at p ≥ {p.thresholds[args[0]]:.2f}") # type: ignore[union-attr]
149
+
150
+
151
+ async def cmd_rule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
152
+ """/mod_rule <name> <text...> or /mod_rule <name> remove"""
153
+ if not await _is_admin(update, context):
154
+ return
155
+ args = context.args or []
156
+ if not args:
157
+ await _reply(update)("usage: /mod_rule <name> <rule text> | /mod_rule <name> remove") # type: ignore[union-attr]
158
+ return
159
+ tenant = tenant_of(update.effective_chat.id) # type: ignore[union-attr]
160
+ p = service.policy(tenant)
161
+ text = " ".join(args[1:])
162
+ try:
163
+ p.set_rule(args[0], None if text.strip().lower() == "remove" else text)
164
+ except ValueError as exc:
165
+ await _reply(update)(str(exc)) # type: ignore[union-attr]
166
+ return
167
+ service.save_policy(tenant, p)
168
+ await _reply(update)(f"rules: {p.rules}") # type: ignore[union-attr]
169
+
170
+
171
+ async def cmd_log(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
172
+ """Run in the chat where you want decisions logged (a private admins group works well)."""
173
+ if not await _is_admin(update, context):
174
+ return
175
+ args = context.args or []
176
+ if not args:
177
+ await _reply(update)("usage: /mod_log <chat id of the group to moderate> (run this in the log chat)") # type: ignore[union-attr]
178
+ return
179
+ store.set_meta(tenant_of(int(args[0])), log_chat=update.effective_chat.id) # type: ignore[union-attr]
180
+ await _reply(update)("decisions for that group will be logged here") # type: ignore[union-attr]
181
+
182
+
183
+ async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
184
+ if not await _is_admin(update, context):
185
+ return
186
+ store.set_meta(tenant_of(update.effective_chat.id), topic=" ".join(context.args or [])[:200]) # type: ignore[union-attr]
187
+ await _reply(update)("topic saved") # type: ignore[union-attr]
188
+
189
+
190
+ def main() -> None:
191
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
192
+ token = os.environ.get("TELEGRAM_TOKEN")
193
+ if not token:
194
+ raise SystemExit("set TELEGRAM_TOKEN (@BotFather → /newbot)")
195
+ app = Application.builder().token(token).build()
196
+ app.add_handler(CommandHandler("mod_status", cmd_status))
197
+ app.add_handler(CommandHandler("mod_set", cmd_set))
198
+ app.add_handler(CommandHandler("mod_rule", cmd_rule))
199
+ app.add_handler(CommandHandler("mod_log", cmd_log))
200
+ app.add_handler(CommandHandler("mod_topic", cmd_topic))
201
+ app.add_handler(MessageHandler(filters.TEXT | filters.CAPTION, on_message))
202
+ app.run_polling(allowed_updates=Update.ALL_TYPES)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
jevmod/api/__init__.py ADDED
File without changes
jevmod/api/demo.py ADDED
@@ -0,0 +1,199 @@
1
+ """Public demo endpoint for the landing page: one message in, one decision out, with the TypeSafe key kept on the
2
+ server and a hard monthly budget so a stranger cannot spend more than you allow.
3
+
4
+ JEVMOD_DEMO_BUDGET_USD=0.5 JEVMOD_DEMO_ORIGINS=https://ohernandezdev.github.io jevmod demo
5
+
6
+ Guards, in order: CORS allow-list → per-IP limits (per minute and per day) → text length → global monthly budget
7
+ computed from measured input tokens at Jev's list price → cache (repeats cost nothing). Every judged demo message
8
+ is logged (text, scores, hashed IP, country header if a proxy sets one) so you can see what people try:
9
+ `GET /demo/stats` and `GET /demo/recent` need the admin token.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import os
18
+ import sqlite3
19
+ import threading
20
+ import time
21
+ from collections import defaultdict, deque
22
+ from typing import Any
23
+
24
+ from fastapi import FastAPI, Header, HTTPException, Request
25
+ from fastapi.middleware.cors import CORSMiddleware
26
+ from pydantic import BaseModel, Field
27
+
28
+ from ..core import Policy, decide
29
+ from ..judge import CATEGORIES, Judge, Message, normalize
30
+
31
+ JEV_USD_PER_M = 0.042
32
+ BUDGET_USD = float(os.environ.get("JEVMOD_DEMO_BUDGET_USD", "0.5"))
33
+ PER_MINUTE = int(os.environ.get("JEVMOD_DEMO_PER_MINUTE", "6"))
34
+ PER_DAY = int(os.environ.get("JEVMOD_DEMO_PER_DAY", "40"))
35
+ MAX_CHARS = int(os.environ.get("JEVMOD_DEMO_MAX_CHARS", "300"))
36
+ ORIGINS = [o.strip() for o in os.environ.get("JEVMOD_DEMO_ORIGINS", "http://localhost:8000").split(",") if o.strip()]
37
+ DB_PATH = os.environ.get("JEVMOD_DEMO_DB", "jevmod-demo.sqlite")
38
+ SALT = os.environ.get("JEVMOD_DEMO_SALT", "jevmod-demo")
39
+ CATS = [c for c in CATEGORIES if c != "offtopic"]
40
+
41
+ app = FastAPI(title="jevmod demo", version="0.1.0", docs_url=None, redoc_url=None)
42
+ app.add_middleware(CORSMiddleware, allow_origins=ORIGINS, allow_methods=["POST", "GET"], allow_headers=["Content-Type"])
43
+
44
+ _judge: Judge | None = None
45
+ _policy = Policy()
46
+ for _c in CATS:
47
+ _policy.set_category(_c, "flag")
48
+ _lock = threading.Lock()
49
+ _minute: dict[str, deque[float]] = defaultdict(deque)
50
+ _day: dict[str, deque[float]] = defaultdict(deque)
51
+ _db = sqlite3.connect(DB_PATH, check_same_thread=False)
52
+ _db.executescript(
53
+ """
54
+ CREATE TABLE IF NOT EXISTS demo (ts REAL, month TEXT, ip_hash TEXT, country TEXT, text TEXT, category TEXT,
55
+ p REAL, action TEXT, scores TEXT, tokens INTEGER, cached INTEGER);
56
+ CREATE INDEX IF NOT EXISTS demo_month ON demo (month);
57
+ """
58
+ )
59
+ _db.commit()
60
+
61
+
62
+ class In(BaseModel):
63
+ text: str = Field(..., min_length=1, max_length=MAX_CHARS)
64
+
65
+
66
+ def judge() -> Judge:
67
+ global _judge
68
+ if _judge is None:
69
+ _judge = Judge()
70
+ return _judge
71
+
72
+
73
+ def month() -> str:
74
+ return time.strftime("%Y-%m")
75
+
76
+
77
+ def spent_usd() -> float:
78
+ row = _db.execute("SELECT COALESCE(SUM(tokens), 0) FROM demo WHERE month=? AND cached=0", (month(),)).fetchone()
79
+ return float(row[0]) * JEV_USD_PER_M / 1e6
80
+
81
+
82
+ def _ip(request: Request) -> str:
83
+ fwd = request.headers.get("cf-connecting-ip") or request.headers.get("x-forwarded-for", "")
84
+ ip = fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "?")
85
+ return hashlib.sha256(f"{SALT}|{ip}".encode()).hexdigest()[:16]
86
+
87
+
88
+ def _allow(ip_hash: str) -> str | None:
89
+ now = time.time()
90
+ with _lock:
91
+ for bucket, window, limit in ((_minute[ip_hash], 60, PER_MINUTE), (_day[ip_hash], 86400, PER_DAY)):
92
+ while bucket and now - bucket[0] > window:
93
+ bucket.popleft()
94
+ if len(bucket) >= limit:
95
+ return "per-minute limit, try again in a moment" if window == 60 else "daily limit for this address"
96
+ _minute[ip_hash].append(now)
97
+ _day[ip_hash].append(now)
98
+ return None
99
+
100
+
101
+ @app.get("/demo/health")
102
+ def health() -> dict[str, Any]:
103
+ spent = spent_usd()
104
+ return {"ok": True, "budget_usd": BUDGET_USD, "spent_usd": round(spent, 4), "open": spent < BUDGET_USD}
105
+
106
+
107
+ @app.post("/demo/check")
108
+ def check(body: In, request: Request) -> dict[str, Any]:
109
+ ip_hash = _ip(request)
110
+ why = _allow(ip_hash)
111
+ if why:
112
+ raise HTTPException(429, why)
113
+ spent = spent_usd()
114
+ if spent >= BUDGET_USD:
115
+ raise HTTPException(
116
+ 503, f"the demo budget for this month (${BUDGET_USD:.2f}) is used up; run the CLI with your own key"
117
+ )
118
+ j = judge()
119
+ before = j.input_tokens
120
+ verdicts = j.judge([Message("demo", normalize(body.text))], CATS)
121
+ v = verdicts[0]
122
+ tokens = j.input_tokens - before
123
+ d = decide(_policy, v)
124
+ with _lock:
125
+ _db.execute(
126
+ "INSERT INTO demo VALUES (?,?,?,?,?,?,?,?,?,?,?)",
127
+ (
128
+ time.time(),
129
+ month(),
130
+ ip_hash,
131
+ request.headers.get("cf-ipcountry", ""),
132
+ body.text[:MAX_CHARS],
133
+ d.category,
134
+ d.probability,
135
+ d.action,
136
+ json.dumps({k: round(x, 3) for k, x in d.scores.items()}),
137
+ tokens,
138
+ int(v.reason == "cache"),
139
+ ),
140
+ )
141
+ _db.commit()
142
+ out = d.to_dict()
143
+ out["reason"] = v.reason
144
+ out["budget_left_usd"] = round(max(BUDGET_USD - spent - tokens * JEV_USD_PER_M / 1e6, 0), 4)
145
+ return out
146
+
147
+
148
+ def _admin(authorization: str) -> None:
149
+ admin = os.environ.get("JEVMOD_ADMIN_TOKEN", "")
150
+ given = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
151
+ if not admin or not hmac.compare_digest(given, admin):
152
+ raise HTTPException(403, "admin token required")
153
+
154
+
155
+ @app.get("/demo/stats")
156
+ def stats(authorization: str = Header(default="")) -> dict[str, Any]:
157
+ _admin(authorization)
158
+ m = month()
159
+ rows = _db.execute(
160
+ "SELECT COUNT(*), COUNT(DISTINCT ip_hash), SUM(cached), COALESCE(SUM(tokens),0) FROM demo WHERE month=?", (m,)
161
+ ).fetchone()
162
+ by_cat = _db.execute(
163
+ "SELECT COALESCE(category,'none'), COUNT(*) FROM demo WHERE month=? GROUP BY 1 ORDER BY 2 DESC", (m,)
164
+ ).fetchall()
165
+ by_country = _db.execute(
166
+ "SELECT country, COUNT(*) FROM demo WHERE month=? AND country!='' GROUP BY 1 ORDER BY 2 DESC LIMIT 15", (m,)
167
+ ).fetchall()
168
+ return {
169
+ "month": m,
170
+ "checks": rows[0],
171
+ "distinct_visitors": rows[1],
172
+ "cache_hits": rows[2] or 0,
173
+ "input_tokens": rows[3],
174
+ "spent_usd": round(rows[3] * JEV_USD_PER_M / 1e6, 4),
175
+ "budget_usd": BUDGET_USD,
176
+ "by_category": dict(by_cat),
177
+ "by_country": dict(by_country),
178
+ }
179
+
180
+
181
+ @app.get("/demo/recent")
182
+ def recent(limit: int = 100, authorization: str = Header(default="")) -> list[dict[str, Any]]:
183
+ _admin(authorization)
184
+ rows = _db.execute(
185
+ "SELECT ts, country, text, category, p, action, scores FROM demo ORDER BY ts DESC LIMIT ?",
186
+ (max(1, min(limit, 1000)),),
187
+ ).fetchall()
188
+ return [
189
+ {
190
+ "ts": r[0],
191
+ "country": r[1],
192
+ "text": r[2],
193
+ "category": r[3],
194
+ "p": r[4],
195
+ "action": r[5],
196
+ "scores": json.loads(r[6]),
197
+ }
198
+ for r in rows
199
+ ]
jevmod/api/server.py ADDED
@@ -0,0 +1,185 @@
1
+ """HTTP API for developers and for any chatbot: POST a batch of messages, get decisions.
2
+
3
+ JEVMOD_ADMIN_TOKEN=... TYPESAFE_API_KEY=... uvicorn jevmod.api.server:app --port 8080
4
+
5
+ Auth: `Authorization: Bearer <api key>`. Keys are created with the admin token (`POST /v1/keys`) and stored hashed.
6
+ Every response carries the request id; every judged decision is in the tenant's audit log.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import os
14
+ import secrets
15
+ import time
16
+ import uuid
17
+ from typing import Any
18
+
19
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response
20
+ from fastapi.responses import PlainTextResponse
21
+ from pydantic import BaseModel, Field
22
+
23
+ from ..core import ModerationService, Store
24
+ from ..judge import CATEGORIES, Message
25
+
26
+ app = FastAPI(
27
+ title="jevmod",
28
+ version="0.2.0",
29
+ description="Moderation decisions for user content: spam, scam, harassment, adult, off-topic and your own rules. "
30
+ "Powered by Jev (TypeSafe). Probabilities, thresholds you own, decisions you can audit.",
31
+ )
32
+ store = Store(os.environ.get("JEVMOD_DB", "jevmod.sqlite"))
33
+ service = ModerationService(store)
34
+ STARTED = time.time()
35
+ _metrics = {"requests": 0, "messages": 0, "errors": 0}
36
+
37
+
38
+ # ------------------------------------------------------------------ models
39
+ class InMessage(BaseModel):
40
+ id: str = Field(default="", description="your id for the message; echoed back")
41
+ text: str = Field(..., max_length=8000)
42
+ author: str = ""
43
+ channel_topic: str = Field(default="", description="what the channel/thread is about; used by offtopic")
44
+ author_trusted: bool = Field(default=False, description="true skips judgment (moderators, verified staff)")
45
+
46
+
47
+ class ModerateRequest(BaseModel):
48
+ messages: list[InMessage] = Field(..., min_length=1, max_length=50)
49
+
50
+
51
+ class DecisionOut(BaseModel):
52
+ message_id: str
53
+ action: str
54
+ category: str | None
55
+ probability: float
56
+ scores: dict[str, float]
57
+ judged: bool
58
+ reason: str
59
+
60
+
61
+ class ModerateResponse(BaseModel):
62
+ request_id: str
63
+ decisions: list[DecisionOut]
64
+ usage: dict[str, int]
65
+
66
+
67
+ class PolicyIn(BaseModel):
68
+ thresholds: dict[str, float] | None = None
69
+ actions: dict[str, str] | None = None
70
+ rules: dict[str, str] | None = None
71
+ rule_actions: dict[str, str] | None = None
72
+ timeout_minutes: int | None = None
73
+
74
+
75
+ class KeyRequest(BaseModel):
76
+ tenant: str = Field(..., min_length=1, max_length=80)
77
+ label: str = ""
78
+
79
+
80
+ # ------------------------------------------------------------------ auth
81
+ def _hash(key: str) -> str:
82
+ return hashlib.sha256(key.encode()).hexdigest()
83
+
84
+
85
+ def tenant_from_auth(authorization: str = Header(default="")) -> str:
86
+ if not authorization.startswith("Bearer "):
87
+ raise HTTPException(401, "missing bearer token")
88
+ tenant = store.tenant_for_key(_hash(authorization[7:].strip()))
89
+ if not tenant:
90
+ raise HTTPException(401, "unknown api key")
91
+ return tenant
92
+
93
+
94
+ def admin_only(authorization: str = Header(default="")) -> None:
95
+ admin = os.environ.get("JEVMOD_ADMIN_TOKEN", "")
96
+ given = authorization[7:].strip() if authorization.startswith("Bearer ") else ""
97
+ if not admin or not hmac.compare_digest(given, admin):
98
+ raise HTTPException(403, "admin token required")
99
+
100
+
101
+ # ------------------------------------------------------------------ routes
102
+ @app.get("/v1/health")
103
+ def health() -> dict[str, Any]:
104
+ return {"ok": True, "uptime_s": int(time.time() - STARTED), "categories": list(CATEGORIES)}
105
+
106
+
107
+ @app.get("/metrics", response_class=PlainTextResponse)
108
+ def metrics() -> str:
109
+ j = service.judge if service._judge else None
110
+ lines = [
111
+ f"jevmod_http_requests_total {_metrics['requests']}",
112
+ f"jevmod_messages_total {_metrics['messages']}",
113
+ f"jevmod_http_errors_total {_metrics['errors']}",
114
+ f"jevmod_jev_requests_total {j.requests if j else 0}",
115
+ f"jevmod_jev_input_tokens_total {j.input_tokens if j else 0}",
116
+ ]
117
+ return "\n".join(lines) + "\n"
118
+
119
+
120
+ @app.post("/v1/moderate", response_model=ModerateResponse)
121
+ def moderate(
122
+ req: ModerateRequest, request: Request, response: Response, tenant: str = Depends(tenant_from_auth)
123
+ ) -> ModerateResponse:
124
+ rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
125
+ response.headers["X-Request-Id"] = rid
126
+ _metrics["requests"] += 1
127
+ _metrics["messages"] += len(req.messages)
128
+ msgs = [
129
+ Message(m.id or str(i), m.text, author=m.author, channel_topic=m.channel_topic, author_trusted=m.author_trusted)
130
+ for i, m in enumerate(req.messages)
131
+ ]
132
+ try:
133
+ decisions = service.moderate(tenant, msgs, request_id=rid)
134
+ except Exception as exc:
135
+ _metrics["errors"] += 1
136
+ raise HTTPException(502, f"judgment failed: {type(exc).__name__}") from exc
137
+ judged, requests, tokens = store.usage(tenant)
138
+ return ModerateResponse(
139
+ request_id=rid,
140
+ decisions=[DecisionOut(**{k: v for k, v in d.to_dict().items() if k != "policy_version"}) for d in decisions],
141
+ usage={"judged_this_month": judged, "jev_requests_this_month": requests, "input_tokens_this_month": tokens},
142
+ )
143
+
144
+
145
+ @app.get("/v1/policy")
146
+ def get_policy(tenant: str = Depends(tenant_from_auth)) -> dict[str, Any]:
147
+ return service.policy(tenant).to_dict()
148
+
149
+
150
+ @app.put("/v1/policy")
151
+ def put_policy(body: PolicyIn, tenant: str = Depends(tenant_from_auth)) -> dict[str, Any]:
152
+ p = service.policy(tenant)
153
+ try:
154
+ for c, a in (body.actions or {}).items():
155
+ p.set_category(c, a, (body.thresholds or {}).get(c))
156
+ for c, t in (body.thresholds or {}).items():
157
+ if c in CATEGORIES and c not in (body.actions or {}):
158
+ p.set_category(c, p.actions.get(c, "flag"), t)
159
+ for n, text in (body.rules or {}).items():
160
+ p.set_rule(n, text, (body.rule_actions or {}).get(n, p.rule_actions.get(n, "flag")))
161
+ if body.timeout_minutes is not None:
162
+ p.timeout_minutes = max(1, min(int(body.timeout_minutes), 1440))
163
+ except ValueError as exc:
164
+ raise HTTPException(422, str(exc)) from exc
165
+ service.save_policy(tenant, p)
166
+ return p.to_dict()
167
+
168
+
169
+ @app.get("/v1/decisions")
170
+ def decisions(limit: int = 50, tenant: str = Depends(tenant_from_auth)) -> list[dict[str, Any]]:
171
+ return store.recent_decisions(tenant, max(1, min(limit, 500)))
172
+
173
+
174
+ @app.delete("/v1/tenant")
175
+ def delete_tenant(tenant: str = Depends(tenant_from_auth)) -> dict[str, bool]:
176
+ """GDPR: forget this tenant's policy, usage and decision log."""
177
+ store.delete_tenant(tenant)
178
+ return {"deleted": True}
179
+
180
+
181
+ @app.post("/v1/keys", dependencies=[Depends(admin_only)])
182
+ def create_key(body: KeyRequest) -> dict[str, str]:
183
+ key = "jm_" + secrets.token_urlsafe(32)
184
+ store.create_api_key(body.tenant, _hash(key), body.label)
185
+ return {"tenant": body.tenant, "api_key": key, "note": "shown once; stored hashed"}
jevmod/categories.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "version": 1,
3
+ "categories": {
4
+ "spam": {
5
+ "label": "spam / advertising",
6
+ "instructions": "Is `{m}.text` spam: unsolicited mass promotion, referral or invite farming, repeated offers, link drops with no conversational purpose, or mass mentions to get attention?",
7
+ "criteria": {
8
+ "true": "advertising or promotion pushed at the community, repeated or copy-pasted offers, referral/affiliate/invite links, mass @mentions, bare shortened links with no context",
9
+ "false": "a normal conversational message; a one-off personal sale or trade between members; sharing a guide, video or project once; quoting spam in order to report it"
10
+ }
11
+ },
12
+ "scam": {
13
+ "label": "scam / phishing",
14
+ "instructions": "Is `{m}.text` a scam or phishing attempt aimed at the reader's money, account or credentials?",
15
+ "criteria": {
16
+ "true": "fake giveaways (free Nitro, free skins, crypto doubling), links to domains imitating a service (discord-gifts, steampowerd, discord-secure), impersonation of staff or support, 'DM me for a deal', urgent account-verification threats, requests for money or credentials under false pretenses, in any language or script",
17
+ "false": "a legitimate link to a well-known site, a genuine question about a deal or price, warning others about a scam, ordinary trading between members"
18
+ }
19
+ },
20
+ "harassment": {
21
+ "label": "harassment / abuse",
22
+ "instructions": "Does `{m}.text` harass, insult, threaten or demean a person or group?",
23
+ "criteria": {
24
+ "true": "insults, slurs, threats, telling someone to leave or hurt themselves, dehumanising or targeted abuse, in any language (judge the meaning of the words, not the language they are written in)",
25
+ "false": "friendly banter, competitive trash talk with no target, disagreement, criticism of ideas, an ordinary question or invitation to play"
26
+ }
27
+ },
28
+ "nsfw": {
29
+ "label": "adult / graphic content",
30
+ "instructions": "Does `{m}.text` contain sexual or graphically violent content inappropriate for a general audience community?",
31
+ "criteria": {
32
+ "true": "explicit sexual content or solicitation (trading nudes, 'DM for pics'), sexual content involving minors, graphic gore",
33
+ "false": "insults or profanity with no sexual content, romance or dating talk, medical or health talk, game violence"
34
+ }
35
+ },
36
+ "offtopic": {
37
+ "label": "off-topic for the channel",
38
+ "instructions": "Given `{m}.channel_topic`, is `{m}.text` clearly about something unrelated to that topic?",
39
+ "criteria": {
40
+ "true": "a message whose subject has nothing to do with the channel topic (a recipe in a gaming channel, job hunting in a support channel) and is not a brief aside",
41
+ "false": "on-topic content, greetings, questions, short asides and reactions. Spam, insults or rule violations are NOT off-topic by themselves: judge only the subject"
42
+ }
43
+ },
44
+ "selfharm": {
45
+ "label": "self-harm / crisis",
46
+ "instructions": "Does `{m}.text` express that the author is considering suicide or self-harm, or is in an acute personal crisis? (Meant to alert moderators so they can reach out, not to punish.)",
47
+ "criteria": {
48
+ "true": "statements of wanting to die, disappear or hurt oneself, saying goodbye, describing a plan, hopelessness about one's own life, asking how to self-harm, in any language",
49
+ "false": "telling someone else to hurt themselves (that is harassment), hyperbole about a game ('this boss makes me want to die lol'), song lyrics, discussing the topic in general, venting about a bad day"
50
+ }
51
+ },
52
+ "doxxing": {
53
+ "label": "doxxing / personal data",
54
+ "instructions": "Does `{m}.text` reveal or try to obtain private identifying information about a real person without their consent?",
55
+ "criteria": {
56
+ "true": "home address, phone number, real full name behind a username, workplace, school, ID or financial numbers, photos' locations, or asking others to find or share such data about someone",
57
+ "false": "the author sharing their own city or first name, public figures' public information, business contact details, fictional characters, generic talk about privacy"
58
+ }
59
+ },
60
+ "minors": {
61
+ "label": "sexual content involving minors / grooming",
62
+ "instructions": "Does `{m}.text` sexualise a minor, solicit sexual content from or about a minor, or show grooming behaviour (an adult building private trust with a child for sexual purposes)?",
63
+ "criteria": {
64
+ "true": "sexual comments about someone stated or clearly implied to be under 18, requests for their photos or private contact, offers of gifts or secrecy to a child, age-checking followed by sexual intent, in any language",
65
+ "false": "adults talking about adults (that is nsfw), parents discussing their kids' games, child safety advice, mentions of age with no sexual element"
66
+ }
67
+ }
68
+ }
69
+ }