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/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """Command line for developers and scripts: judge text from the terminal, no server, no database.
2
+
3
+ jevmod check "FREE NITRO claim at discord-gifts.ru" # one message
4
+ jevmod check - < messages.txt # one message per line, one Jev request per 50
5
+ jevmod check --topic "support" --rule "No politics" -- "..." # context and a plain-language rule
6
+ jevmod check --json ... # machine-readable, one object per line
7
+ jevmod init # store the TypeSafe key (keyring, or .env)
8
+ jevmod mcp # MCP server over stdio for coding agents
9
+
10
+ Exit code 0 when no message triggers an action, 1 when at least one does, 2 on an error. Pipe-friendly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from collections.abc import Iterable
19
+
20
+ from . import Moderator, Policy
21
+ from .judge import CATEGORIES
22
+
23
+
24
+ def _read_texts(args: argparse.Namespace) -> list[str]:
25
+ if args.text == ["-"] or not args.text:
26
+ return [line.rstrip("\n") for line in sys.stdin if line.strip()]
27
+ return [" ".join(args.text)]
28
+
29
+
30
+ def _chunks(items: list[str], n: int) -> Iterable[list[str]]:
31
+ for i in range(0, len(items), n):
32
+ yield items[i : i + n]
33
+
34
+
35
+ def check(args: argparse.Namespace) -> int:
36
+ policy = Policy()
37
+ for c in CATEGORIES:
38
+ policy.set_category(c, "off" if c == "offtopic" and not args.topic else "flag", args.threshold)
39
+ for i, rule in enumerate(args.rule or []):
40
+ policy.set_rule(f"rule{i + 1}", rule)
41
+ mod = Moderator(policy=policy)
42
+ texts = _read_texts(args)
43
+ if not texts:
44
+ print("nothing to check", file=sys.stderr)
45
+ return 2
46
+ hit = False
47
+ for batch in _chunks(texts, 50):
48
+ for text, d in zip(batch, mod.check_many(batch, channel_topic=args.topic), strict=True):
49
+ hit = hit or d.action != "none"
50
+ if args.json:
51
+ print(json.dumps({"text": text, **d.to_dict()}, ensure_ascii=False))
52
+ else:
53
+ top = ", ".join(f"{k} {v:.2f}" for k, v in sorted(d.scores.items(), key=lambda kv: -kv[1])[:3])
54
+ verdict = f"{d.category} {d.probability:.2f}" if d.action != "none" else "ok"
55
+ if not d.judged:
56
+ verdict = f"skipped ({d.reason})"
57
+ print(f"{verdict:<28} {text[:70]!r} [{top}]")
58
+ return 1 if hit else 0
59
+
60
+
61
+ def init(args: argparse.Namespace) -> int:
62
+ from .keys import forget
63
+ from .keys import init as store_key
64
+
65
+ if args.forget:
66
+ return forget()
67
+ return store_key(prefer_env_file=args.env_file)
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ parser = argparse.ArgumentParser(prog="jevmod", description="Moderation decisions powered by Jev.")
72
+ sub = parser.add_subparsers(dest="cmd", required=True)
73
+ p = sub.add_parser("check", help="judge one message (argument) or many (stdin, one per line with '-')")
74
+ p.add_argument("text", nargs="*", help="the message; '-' or nothing reads stdin")
75
+ p.add_argument("--topic", default="", help="what the channel is about (enables the offtopic check)")
76
+ p.add_argument("--rule", action="append", help="a rule in plain language; repeatable, up to 5")
77
+ p.add_argument("--threshold", type=float, default=None, help="one threshold for every category (0.5-0.99)")
78
+ p.add_argument("--json", action="store_true", help="one JSON object per line")
79
+ p.set_defaults(func=check)
80
+ p = sub.add_parser("init", help="ask for the TypeSafe key (hidden input), verify it, store it in the OS keyring")
81
+ p.add_argument("--env-file", action="store_true", help="write .env in the current directory instead of the keyring")
82
+ p.add_argument("--forget", action="store_true", help="remove the key from the OS keyring")
83
+ p.set_defaults(func=init)
84
+ sub.add_parser("mcp", help="MCP server over stdio (tools: moderate, categories)")
85
+ for role in ("api", "demo", "discord", "telegram", "reddit"):
86
+ sub.add_parser(role, help=f"run the {role} role (same as JEVMOD_ROLE={role})")
87
+ args = parser.parse_args(argv)
88
+ if args.cmd in ("check", "init"):
89
+ try:
90
+ return int(args.func(args))
91
+ except Exception as exc: # network, key, quota: say it in one line, exit 2
92
+ print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
93
+ return 2
94
+ from .__main__ import run_role
95
+
96
+ run_role(args.cmd)
97
+ return 0
@@ -0,0 +1,17 @@
1
+ from .policy import ACTIONS, DEFAULT_ACTIONS, DEFAULT_THRESHOLDS, RULE_THRESHOLD, Decision, Policy, decide
2
+ from .service import Batcher, ModerationService
3
+ from .store import FREE_MONTHLY, Store
4
+
5
+ __all__ = [
6
+ "ACTIONS",
7
+ "DEFAULT_ACTIONS",
8
+ "DEFAULT_THRESHOLDS",
9
+ "FREE_MONTHLY",
10
+ "RULE_THRESHOLD",
11
+ "Batcher",
12
+ "Decision",
13
+ "ModerationService",
14
+ "Policy",
15
+ "Store",
16
+ "decide",
17
+ ]
jevmod/core/policy.py ADDED
@@ -0,0 +1,155 @@
1
+ """Policy: turn a Verdict (probabilities) into a Decision (what to do), per tenant. Pure code, offline-testable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from ..judge import CATEGORIES, Verdict
9
+
10
+ ACTIONS = ("off", "flag", "delete", "timeout") # ordered by severity
11
+ DEFAULT_THRESHOLDS = {
12
+ "spam": 0.85,
13
+ "scam": 0.75,
14
+ "harassment": 0.75,
15
+ "nsfw": 0.8,
16
+ "offtopic": 0.9,
17
+ "selfharm": 0.8,
18
+ "doxxing": 0.8,
19
+ "minors": 0.7,
20
+ }
21
+ # selfharm is flag-only by design: moderators should reach out, not punish. doxxing/minors flag by default;
22
+ # communities that want automatic removal set delete/timeout explicitly.
23
+ DEFAULT_ACTIONS = {
24
+ "spam": "flag",
25
+ "scam": "flag",
26
+ "harassment": "flag",
27
+ "nsfw": "flag",
28
+ "offtopic": "off",
29
+ "selfharm": "flag",
30
+ "doxxing": "flag",
31
+ "minors": "flag",
32
+ }
33
+ RULE_THRESHOLD = 0.8
34
+ POLICY_VERSION = 1
35
+
36
+
37
+ @dataclass
38
+ class Policy:
39
+ thresholds: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_THRESHOLDS))
40
+ actions: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_ACTIONS))
41
+ rules: dict[str, str] = field(default_factory=dict) # name -> natural-language rule
42
+ rule_actions: dict[str, str] = field(default_factory=dict) # name -> action (default flag)
43
+ rule_thresholds: dict[str, float] = field(default_factory=dict)
44
+ timeout_minutes: int = 10
45
+
46
+ def enabled_categories(self) -> list[str]:
47
+ return [c for c in CATEGORIES if self.actions.get(c, "off") != "off"]
48
+
49
+ def active(self) -> bool:
50
+ return bool(self.enabled_categories() or self.rules)
51
+
52
+ def set_category(self, category: str, action: str, threshold: float | None = None) -> None:
53
+ if category not in CATEGORIES:
54
+ raise ValueError(f"unknown category {category!r}; one of {', '.join(CATEGORIES)}")
55
+ if action not in ACTIONS:
56
+ raise ValueError(f"unknown action {action!r}; one of {', '.join(ACTIONS)}")
57
+ self.actions[category] = action
58
+ if threshold is not None:
59
+ self.thresholds[category] = _clamp(threshold)
60
+
61
+ def set_rule(self, name: str, text: str | None, action: str = "flag", threshold: float | None = None) -> None:
62
+ name = name.strip().lower().replace(" ", "_")[:30]
63
+ if text is None or not text.strip():
64
+ self.rules.pop(name, None)
65
+ self.rule_actions.pop(name, None)
66
+ self.rule_thresholds.pop(name, None)
67
+ return
68
+ if len(self.rules) >= 5 and name not in self.rules:
69
+ raise ValueError("up to 5 custom rules")
70
+ if action not in ACTIONS:
71
+ raise ValueError(f"unknown action {action!r}")
72
+ self.rules[name] = text.strip()[:200]
73
+ self.rule_actions[name] = action
74
+ if threshold is not None:
75
+ self.rule_thresholds[name] = _clamp(threshold)
76
+
77
+ def nudge(self, category: str, delta: float = 0.03) -> float:
78
+ """False-positive feedback: raise that category's threshold a notch."""
79
+ if category.startswith("rule:"):
80
+ name = category[5:]
81
+ cur = self.rule_thresholds.get(name, RULE_THRESHOLD)
82
+ self.rule_thresholds[name] = _clamp(cur + delta)
83
+ return self.rule_thresholds[name]
84
+ cur = self.thresholds.get(category, 0.9)
85
+ self.thresholds[category] = _clamp(cur + delta)
86
+ return self.thresholds[category]
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "thresholds": self.thresholds,
91
+ "actions": self.actions,
92
+ "rules": self.rules,
93
+ "rule_actions": self.rule_actions,
94
+ "rule_thresholds": self.rule_thresholds,
95
+ "timeout_minutes": self.timeout_minutes,
96
+ "version": POLICY_VERSION,
97
+ }
98
+
99
+ @classmethod
100
+ def from_dict(cls, d: dict[str, Any]) -> Policy:
101
+ p = cls()
102
+ for k in ("thresholds", "actions", "rules", "rule_actions", "rule_thresholds"):
103
+ if isinstance(d.get(k), dict):
104
+ getattr(p, k).update(d[k])
105
+ if "timeout_minutes" in d:
106
+ p.timeout_minutes = int(d["timeout_minutes"])
107
+ return p
108
+
109
+
110
+ @dataclass
111
+ class Decision:
112
+ message_id: str
113
+ action: str # "none" | "flag" | "delete" | "timeout"
114
+ category: str | None # winning category or "rule:<name>"
115
+ probability: float
116
+ scores: dict[str, float] # everything Jev returned, for the audit log
117
+ judged: bool
118
+ reason: str # "jev" | "cache" | pre-filter reason | "quota" | "error_open"
119
+ policy_version: int = POLICY_VERSION
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ return {
123
+ "message_id": self.message_id,
124
+ "action": self.action,
125
+ "category": self.category,
126
+ "probability": round(self.probability, 4),
127
+ "scores": {k: round(v, 4) for k, v in self.scores.items()},
128
+ "judged": self.judged,
129
+ "reason": self.reason,
130
+ "policy_version": self.policy_version,
131
+ }
132
+
133
+
134
+ def decide(policy: Policy, v: Verdict) -> Decision:
135
+ """The most severe action whose threshold is crossed wins; ties go to the higher probability."""
136
+ scores = {**v.scores, **{f"rule:{n}": p for n, p in v.custom.items()}}
137
+ if not v.judged:
138
+ return Decision(v.message_id, "none", None, 0.0, scores, False, v.reason)
139
+ hits: list[tuple[str, float, str]] = []
140
+ for c, p in v.scores.items():
141
+ action = policy.actions.get(c, "off")
142
+ if action != "off" and p >= policy.thresholds.get(c, 1.0):
143
+ hits.append((c, p, action))
144
+ for n, p in v.custom.items():
145
+ action = policy.rule_actions.get(n, "flag")
146
+ if action != "off" and p >= policy.rule_thresholds.get(n, RULE_THRESHOLD):
147
+ hits.append((f"rule:{n}", p, action))
148
+ if not hits:
149
+ return Decision(v.message_id, "none", None, 0.0, scores, True, v.reason)
150
+ category, p, action = max(hits, key=lambda h: (ACTIONS.index(h[2]), h[1]))
151
+ return Decision(v.message_id, action, category, p, scores, True, v.reason)
152
+
153
+
154
+ def _clamp(x: float) -> float:
155
+ return max(0.5, min(0.99, round(float(x), 2)))
jevmod/core/service.py ADDED
@@ -0,0 +1,117 @@
1
+ """ModerationService: one entry point for every adapter and for the HTTP API.
2
+
3
+ svc = ModerationService(store)
4
+ decisions = svc.moderate(tenant_id, messages) # applies policy, quota, audit log, fail-open
5
+
6
+ Tenant = a Discord guild, a Telegram chat, a subreddit or an API key. The service never knows which.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import time
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from typesafe_sdk import TypeSafeError
19
+
20
+ from ..judge import Judge, Message
21
+ from .policy import Decision, Policy, decide
22
+ from .store import Store
23
+
24
+ log = logging.getLogger("jevmod")
25
+
26
+ JEV_USD_PER_M_INPUT = 0.042
27
+
28
+
29
+ class ModerationService:
30
+ def __init__(self, store: Store, judge: Judge | None = None, fail_open: bool = True) -> None:
31
+ self.store = store
32
+ self._judge = judge
33
+ self.fail_open = fail_open
34
+
35
+ @property
36
+ def judge(self) -> Judge:
37
+ if self._judge is None:
38
+ self._judge = Judge()
39
+ return self._judge
40
+
41
+ def policy(self, tenant: str) -> Policy:
42
+ return self.store.get_policy(tenant)
43
+
44
+ def save_policy(self, tenant: str, policy: Policy) -> None:
45
+ self.store.save_policy(tenant, policy)
46
+
47
+ def moderate(self, tenant: str, messages: list[Message], request_id: str | None = None) -> list[Decision]:
48
+ rid = request_id or uuid.uuid4().hex[:12]
49
+ policy = self.policy(tenant)
50
+ if not policy.active():
51
+ return [Decision(m.id, "none", None, 0.0, {}, False, "policy inactive") for m in messages]
52
+ self.store.purge_expired()
53
+ if self.store.over_quota(tenant):
54
+ self.store.note_quota_hit(tenant)
55
+ return [Decision(m.id, "none", None, 0.0, {}, False, "quota") for m in messages]
56
+ j = self.judge
57
+ before = (j.requests, j.input_tokens, j.judged_messages)
58
+ t0 = time.perf_counter()
59
+ try:
60
+ verdicts = j.judge(messages, policy.enabled_categories(), policy.rules)
61
+ except TypeSafeError as exc:
62
+ log.warning(
63
+ {"event": "judge_error", "tenant": tenant, "rid": rid, "error": f"{type(exc).__name__}: {exc}"[:200]}
64
+ )
65
+ if not self.fail_open:
66
+ raise
67
+ return [Decision(m.id, "none", None, 0.0, {}, False, "error_open") for m in messages]
68
+ ms = int((time.perf_counter() - t0) * 1000)
69
+ judged = j.judged_messages - before[2]
70
+ reqs = j.requests - before[0]
71
+ toks = j.input_tokens - before[1]
72
+ self.store.add_usage(tenant, judged, reqs, toks)
73
+ decisions = [decide(policy, v) for v in verdicts]
74
+ for m, d in zip(messages, decisions, strict=True):
75
+ if d.action != "none":
76
+ self.store.log_decision(tenant, m, d, rid)
77
+ log.info(
78
+ {
79
+ "event": "moderate",
80
+ "tenant": tenant,
81
+ "rid": rid,
82
+ "messages": len(messages),
83
+ "judged": judged,
84
+ "requests": reqs,
85
+ "input_tokens": toks,
86
+ "usd": round(toks * JEV_USD_PER_M_INPUT / 1e6, 6),
87
+ "ms": ms,
88
+ "actions": {a: sum(1 for d in decisions if d.action == a) for a in ("flag", "delete", "timeout")},
89
+ }
90
+ )
91
+ return decisions
92
+
93
+
94
+ class Batcher:
95
+ """Collects messages per tenant for `window_s`, then hands the batch to `handler` (async). One Jev request per
96
+ tenant per window instead of one per message."""
97
+
98
+ def __init__(self, window_s: float, handler: Callable[[str, list[Any]], Any]) -> None:
99
+ self.window = window_s
100
+ self.handler = handler
101
+ self.pending: dict[str, list[Any]] = {}
102
+ self.tasks: dict[str, asyncio.Task] = {}
103
+
104
+ def add(self, tenant: str, item: Any) -> None:
105
+ self.pending.setdefault(tenant, []).append(item)
106
+ t = self.tasks.get(tenant)
107
+ if t is None or t.done():
108
+ self.tasks[tenant] = asyncio.create_task(self._flush(tenant))
109
+
110
+ async def _flush(self, tenant: str) -> None:
111
+ await asyncio.sleep(self.window)
112
+ batch = self.pending.pop(tenant, [])
113
+ if batch:
114
+ try:
115
+ await self.handler(tenant, batch)
116
+ except Exception as exc: # an adapter bug must not stop future batches
117
+ log.exception({"event": "batch_handler_error", "tenant": tenant, "error": str(exc)[:200]})
jevmod/core/store.py ADDED
@@ -0,0 +1,215 @@
1
+ """Tenant policies, usage counters, an optional monthly quota and the audit log, in SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..judge import Message
14
+ from .policy import Decision, Policy
15
+
16
+ # Optional cost guard per tenant per month. 0 (the default) means unlimited; set JEVMOD_MONTHLY_QUOTA=5000 to pause
17
+ # judging for a tenant after 5,000 judged messages in a calendar month (nothing is deleted while paused).
18
+ FREE_MONTHLY = int(os.environ.get("JEVMOD_MONTHLY_QUOTA", "0") or 0)
19
+
20
+
21
+ class Store:
22
+ def __init__(
23
+ self,
24
+ path: str | Path = "jevmod.sqlite",
25
+ keep_text_chars: int = 300,
26
+ retention_days: int = 30,
27
+ monthly_quota: int | None = None,
28
+ ) -> None:
29
+ """keep_text_chars=0 stores no message text at all. Decisions older than retention_days are purged on
30
+ every write and by `purge_expired()`, which the service also calls on every batch."""
31
+ self.db = sqlite3.connect(str(path), check_same_thread=False)
32
+ self.lock = threading.Lock()
33
+ self.keep_text_chars = keep_text_chars
34
+ self.retention_days = retention_days
35
+ self.monthly_quota = FREE_MONTHLY if monthly_quota is None else monthly_quota # 0 = unlimited
36
+ with self.lock:
37
+ self.db.executescript(
38
+ """
39
+ CREATE TABLE IF NOT EXISTS tenants (
40
+ id TEXT PRIMARY KEY, policy TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free',
41
+ meta TEXT NOT NULL DEFAULT '{}', quota_notified TEXT DEFAULT '');
42
+ CREATE TABLE IF NOT EXISTS usage (
43
+ tenant TEXT, month TEXT, judged INTEGER, requests INTEGER, tokens INTEGER,
44
+ PRIMARY KEY (tenant, month));
45
+ CREATE TABLE IF NOT EXISTS decisions (
46
+ ts REAL, tenant TEXT, rid TEXT, message TEXT, author TEXT, channel TEXT,
47
+ category TEXT, p REAL, action TEXT, scores TEXT, text TEXT);
48
+ CREATE INDEX IF NOT EXISTS decisions_tenant_ts ON decisions (tenant, ts);
49
+ CREATE TABLE IF NOT EXISTS api_keys (
50
+ key_hash TEXT PRIMARY KEY, tenant TEXT NOT NULL, created REAL, label TEXT);
51
+ """
52
+ )
53
+ self.db.commit()
54
+
55
+ # ---- policy
56
+ def get_policy(self, tenant: str) -> Policy:
57
+ with self.lock:
58
+ row = self.db.execute("SELECT policy FROM tenants WHERE id=?", (tenant,)).fetchone()
59
+ return Policy.from_dict(json.loads(row[0])) if row else Policy()
60
+
61
+ def save_policy(self, tenant: str, policy: Policy) -> None:
62
+ with self.lock:
63
+ self.db.execute(
64
+ "INSERT INTO tenants (id, policy) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET policy=excluded.policy",
65
+ (tenant, json.dumps(policy.to_dict())),
66
+ )
67
+ self.db.commit()
68
+
69
+ def get_meta(self, tenant: str) -> dict[str, Any]:
70
+ with self.lock:
71
+ row = self.db.execute("SELECT meta FROM tenants WHERE id=?", (tenant,)).fetchone()
72
+ return json.loads(row[0]) if row else {}
73
+
74
+ def set_meta(self, tenant: str, **kw: Any) -> None:
75
+ meta = {**self.get_meta(tenant), **kw}
76
+ with self.lock:
77
+ self.db.execute(
78
+ "INSERT INTO tenants (id, policy, meta) VALUES (?, ?, ?) "
79
+ "ON CONFLICT(id) DO UPDATE SET meta=excluded.meta",
80
+ (tenant, json.dumps(Policy().to_dict()), json.dumps(meta)),
81
+ )
82
+ self.db.commit()
83
+
84
+ def plan(self, tenant: str) -> str:
85
+ with self.lock:
86
+ row = self.db.execute("SELECT plan FROM tenants WHERE id=?", (tenant,)).fetchone()
87
+ return row[0] if row else "free"
88
+
89
+ def set_plan(self, tenant: str, plan: str) -> None:
90
+ with self.lock:
91
+ self.db.execute(
92
+ "INSERT INTO tenants (id, policy, plan) VALUES (?, ?, ?) "
93
+ "ON CONFLICT(id) DO UPDATE SET plan=excluded.plan",
94
+ (tenant, json.dumps(Policy().to_dict()), plan),
95
+ )
96
+ self.db.commit()
97
+
98
+ # ---- usage and quota
99
+ @staticmethod
100
+ def month() -> str:
101
+ return time.strftime("%Y-%m")
102
+
103
+ def add_usage(self, tenant: str, judged: int, requests: int, tokens: int) -> None:
104
+ with self.lock:
105
+ self.db.execute(
106
+ "INSERT INTO usage (tenant, month, judged, requests, tokens) VALUES (?, ?, ?, ?, ?) "
107
+ "ON CONFLICT(tenant, month) DO UPDATE SET judged=judged+excluded.judged, "
108
+ "requests=requests+excluded.requests, tokens=tokens+excluded.tokens",
109
+ (tenant, self.month(), judged, requests, tokens),
110
+ )
111
+ self.db.commit()
112
+
113
+ def usage(self, tenant: str) -> tuple[int, int, int]:
114
+ with self.lock:
115
+ row = self.db.execute(
116
+ "SELECT judged, requests, tokens FROM usage WHERE tenant=? AND month=?", (tenant, self.month())
117
+ ).fetchone()
118
+ return tuple(row) if row else (0, 0, 0)
119
+
120
+ def over_quota(self, tenant: str) -> bool:
121
+ return self.monthly_quota > 0 and self.plan(tenant) == "free" and self.usage(tenant)[0] >= self.monthly_quota
122
+
123
+ def note_quota_hit(self, tenant: str) -> bool:
124
+ """True the first time this month the tenant hits the quota (so the adapter can notify the owner once)."""
125
+ with self.lock:
126
+ row = self.db.execute("SELECT quota_notified FROM tenants WHERE id=?", (tenant,)).fetchone()
127
+ if row and row[0] == self.month():
128
+ return False
129
+ self.db.execute(
130
+ "INSERT INTO tenants (id, policy, quota_notified) VALUES (?, ?, ?) "
131
+ "ON CONFLICT(id) DO UPDATE SET quota_notified=excluded.quota_notified",
132
+ (tenant, json.dumps(Policy().to_dict()), self.month()),
133
+ )
134
+ self.db.commit()
135
+ return True
136
+
137
+ # ---- audit
138
+ def purge_expired(self) -> int:
139
+ cutoff = time.time() - self.retention_days * 86400
140
+ with self.lock:
141
+ cur = self.db.execute("DELETE FROM decisions WHERE ts < ?", (cutoff,))
142
+ self.db.commit()
143
+ return cur.rowcount
144
+
145
+ def delete_user(self, tenant: str, author: str) -> int:
146
+ """Right to erasure for one member of a community."""
147
+ with self.lock:
148
+ cur = self.db.execute("DELETE FROM decisions WHERE tenant=? AND author=?", (tenant, author))
149
+ self.db.commit()
150
+ return cur.rowcount
151
+
152
+ def log_decision(self, tenant: str, m: Message, d: Decision, rid: str) -> None:
153
+ self.purge_expired()
154
+ with self.lock:
155
+ self.db.execute(
156
+ "INSERT INTO decisions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
157
+ (
158
+ time.time(),
159
+ tenant,
160
+ rid,
161
+ m.id,
162
+ m.author,
163
+ m.channel_topic[:80],
164
+ d.category,
165
+ d.probability,
166
+ d.action,
167
+ json.dumps({k: round(v, 3) for k, v in d.scores.items()}),
168
+ m.text[: self.keep_text_chars],
169
+ ),
170
+ )
171
+ self.db.commit()
172
+
173
+ def recent_decisions(self, tenant: str, n: int = 10) -> list[dict[str, Any]]:
174
+ with self.lock:
175
+ rows = self.db.execute(
176
+ "SELECT ts, message, category, p, action, scores, text FROM decisions "
177
+ "WHERE tenant=? ORDER BY ts DESC LIMIT ?",
178
+ (tenant, n),
179
+ ).fetchall()
180
+ return [
181
+ {
182
+ "ts": r[0],
183
+ "message_id": r[1],
184
+ "category": r[2],
185
+ "p": r[3],
186
+ "action": r[4],
187
+ "scores": json.loads(r[5]),
188
+ "text": r[6],
189
+ }
190
+ for r in rows
191
+ ]
192
+
193
+ def export_decisions(self, tenant: str) -> list[dict[str, Any]]:
194
+ return self.recent_decisions(tenant, 100000)
195
+
196
+ def delete_tenant(self, tenant: str) -> None:
197
+ """GDPR: forget everything about a community or API tenant."""
198
+ with self.lock:
199
+ for table, col in (("tenants", "id"), ("usage", "tenant"), ("decisions", "tenant"), ("api_keys", "tenant")):
200
+ self.db.execute(f"DELETE FROM {table} WHERE {col}=?", (tenant,))
201
+ self.db.commit()
202
+
203
+ # ---- API keys (developers)
204
+ def create_api_key(self, tenant: str, key_hash: str, label: str = "") -> None:
205
+ with self.lock:
206
+ self.db.execute(
207
+ "INSERT OR REPLACE INTO api_keys (key_hash, tenant, created, label) VALUES (?, ?, ?, ?)",
208
+ (key_hash, tenant, time.time(), label),
209
+ )
210
+ self.db.commit()
211
+
212
+ def tenant_for_key(self, key_hash: str) -> str | None:
213
+ with self.lock:
214
+ row = self.db.execute("SELECT tenant FROM api_keys WHERE key_hash=?", (key_hash,)).fetchone()
215
+ return row[0] if row else None