jevmod 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jevmod/__init__.py +67 -0
- jevmod/__main__.py +50 -0
- jevmod/adapters/__init__.py +0 -0
- jevmod/adapters/discord_bot.py +324 -0
- jevmod/adapters/reddit_bot.py +91 -0
- jevmod/adapters/telegram_bot.py +206 -0
- jevmod/api/__init__.py +0 -0
- jevmod/api/demo.py +199 -0
- jevmod/api/server.py +185 -0
- jevmod/categories.json +69 -0
- jevmod/cli.py +97 -0
- jevmod/core/__init__.py +17 -0
- jevmod/core/policy.py +155 -0
- jevmod/core/service.py +117 -0
- jevmod/core/store.py +215 -0
- jevmod/judge.py +189 -0
- jevmod/keys.py +146 -0
- jevmod/mcp_server.py +76 -0
- jevmod-0.2.0.dist-info/METADATA +293 -0
- jevmod-0.2.0.dist-info/RECORD +24 -0
- jevmod-0.2.0.dist-info/WHEEL +5 -0
- jevmod-0.2.0.dist-info/entry_points.txt +2 -0
- jevmod-0.2.0.dist-info/licenses/LICENSE +21 -0
- jevmod-0.2.0.dist-info/top_level.txt +1 -0
jevmod/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
|
jevmod/core/__init__.py
ADDED
|
@@ -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
|