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
|
@@ -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
|
+
}
|