paxmod-guard 0.1.0__tar.gz

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,36 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .aws-ml.env
12
+ .aws-ml.instance
13
+ .venv/
14
+ train/models/
15
+ train/data/
16
+ bench/data/external/
17
+ .gcp-ml.env
18
+ .ml-ledger
19
+ logs/
20
+ .hf-token
21
+ js/node_modules/
22
+
23
+ # Large or regenerable outputs (labelled data, per-row results); suite files hold non-commercial benchmark text
24
+ label/out/
25
+ label/relabel/
26
+ label/*.log
27
+ bench/results/
28
+ bench/data/suite*.jsonl
29
+ *.tgz
30
+ .ml-ledger-pending
31
+ data_private/
32
+
33
+ # v10 raw source downloads (licence-checked public data; regenerable)
34
+ data_raw/
35
+ data_pool/
36
+ release/
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: paxmod-guard
3
+ Version: 0.1.0
4
+ Summary: Local safety classifier for AI companions, chatbots and kids' game chat: self-harm, grooming, sexual content, jailbreaks and more, on your own CPU in milliseconds.
5
+ Author: Paxmod
6
+ License-Expression: Apache-2.0
7
+ Keywords: ai-safety,chatbot,child-safety,companion,guardrails,llm,moderation,onnx
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: numpy>=1.24
10
+ Requires-Dist: onnxruntime>=1.17
11
+ Requires-Dist: tokenizers>=0.15
12
+ Description-Content-Type: text/markdown
13
+
14
+ # paxmod-guard
15
+
16
+ Safety checks for AI companions, chatbots and kids' game chat, running on your own CPU. No API key, no network calls
17
+ after the first model download, and no message ever leaves your server. The Python twin of the
18
+ [`@paxmod/guard`](https://www.npmjs.com/package/@paxmod/guard) npm package: same model, same scores.
19
+
20
+ ```bash
21
+ pip install paxmod-guard
22
+ ```
23
+
24
+ ```python
25
+ from paxmod_guard import Guard
26
+
27
+ guard = Guard() # downloads the model once (about 220 MB) to ~/.cache/paxmod-guard
28
+
29
+ # 1. Check what the user sent before your model sees it
30
+ r = guard.check_input(user_message)
31
+ if r.crisis_reply:
32
+ return r.crisis_reply # self-harm risk: answer with crisis support, not the persona
33
+ if "jailbreak" in r.labels:
34
+ return "Let's keep things friendly."
35
+
36
+ # 2. Check what your bot wrote before the user sees it
37
+ reply = llm.generate(user_message)
38
+ if guard.check_output(reply).flagged:
39
+ return regenerate_or_refuse()
40
+ ```
41
+
42
+ Needs about 1 GB of memory: run it in a container or on a server, not in small serverless functions.
43
+
44
+ Game or community chat, with the messages before it, or a whole conversation (grooming and other patterns that no
45
+ single message shows):
46
+
47
+ ```python
48
+ guard.check_chat("wanna add me on snap", context=[{"role": "player", "speaker": "B", "text": "im 12"}])
49
+ guard.check_conversation([{"role": "player", "speaker": "A", "text": "..."}, ...])
50
+ ```
51
+
52
+ Each result has `flagged`, `labels`, `scores` (every label, 0 to 1) and `latency_ms`. Signal labels (grooming tactics,
53
+ distress, swearing, age questions, romance) are always scored but only flag when enabled:
54
+ `Guard(enable=["secrecy", "risk_check"])`. Thresholds can be overridden per label: `Guard(thresholds={"sexual": 0.7})`.
55
+
56
+ Licence: Apache-2.0. Model card and labels: https://huggingface.co/paxmod/guard
@@ -0,0 +1,43 @@
1
+ # paxmod-guard
2
+
3
+ Safety checks for AI companions, chatbots and kids' game chat, running on your own CPU. No API key, no network calls
4
+ after the first model download, and no message ever leaves your server. The Python twin of the
5
+ [`@paxmod/guard`](https://www.npmjs.com/package/@paxmod/guard) npm package: same model, same scores.
6
+
7
+ ```bash
8
+ pip install paxmod-guard
9
+ ```
10
+
11
+ ```python
12
+ from paxmod_guard import Guard
13
+
14
+ guard = Guard() # downloads the model once (about 220 MB) to ~/.cache/paxmod-guard
15
+
16
+ # 1. Check what the user sent before your model sees it
17
+ r = guard.check_input(user_message)
18
+ if r.crisis_reply:
19
+ return r.crisis_reply # self-harm risk: answer with crisis support, not the persona
20
+ if "jailbreak" in r.labels:
21
+ return "Let's keep things friendly."
22
+
23
+ # 2. Check what your bot wrote before the user sees it
24
+ reply = llm.generate(user_message)
25
+ if guard.check_output(reply).flagged:
26
+ return regenerate_or_refuse()
27
+ ```
28
+
29
+ Needs about 1 GB of memory: run it in a container or on a server, not in small serverless functions.
30
+
31
+ Game or community chat, with the messages before it, or a whole conversation (grooming and other patterns that no
32
+ single message shows):
33
+
34
+ ```python
35
+ guard.check_chat("wanna add me on snap", context=[{"role": "player", "speaker": "B", "text": "im 12"}])
36
+ guard.check_conversation([{"role": "player", "speaker": "A", "text": "..."}, ...])
37
+ ```
38
+
39
+ Each result has `flagged`, `labels`, `scores` (every label, 0 to 1) and `latency_ms`. Signal labels (grooming tactics,
40
+ distress, swearing, age questions, romance) are always scored but only flag when enabled:
41
+ `Guard(enable=["secrecy", "risk_check"])`. Thresholds can be overridden per label: `Guard(thresholds={"sexual": 0.7})`.
42
+
43
+ Licence: Apache-2.0. Model card and labels: https://huggingface.co/paxmod/guard
@@ -0,0 +1,219 @@
1
+ """paxmod-guard: a local safety classifier for AI companions, chatbots and kids' game chat.
2
+
3
+ Runs Paxmod's Guard model on your own CPU with ONNX Runtime. No API calls; no message leaves the machine.
4
+ A Python port of the @paxmod/guard npm SDK: same model files, same input format, same scores.
5
+
6
+ from paxmod_guard import Guard
7
+ guard = Guard() # downloads the model once (about 220 MB) to ~/.cache/paxmod-guard
8
+ r = guard.check_input(user_message)
9
+ if r.crisis_reply: ... # self-harm risk: answer with crisis support, not the persona
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ import re
16
+ import os
17
+ import time
18
+ import urllib.request
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Callable, Iterable, Optional
22
+
23
+ import numpy as np
24
+ import onnxruntime as ort
25
+ from tokenizers import Tokenizer
26
+
27
+ __all__ = ["Guard", "Result", "download_model", "MODEL_VERSION", "DEFAULT_CRISIS_REPLY"]
28
+
29
+ MODEL_VERSION = "paxmodguard-v1"
30
+ MODEL_FILES = ["model.int8.onnx", "tokenizer.json", "tokenizer_config.json", "guard.json"]
31
+ DEFAULT_MODEL_URL = "https://huggingface.co/paxmod/guard/resolve/main"
32
+ DEFAULT_CRISIS_REPLY = (
33
+ "It sounds like you're going through something really painful, and I'm glad you told me. "
34
+ "I'm an AI, so please reach out to someone who can help right now. In the US you can call or text 988 "
35
+ "(Suicide & Crisis Lifeline). Elsewhere, findahelpline.com lists free, confidential lines in your country."
36
+ )
37
+ # v10 renamed self_harm to self_harm_intent; either triggers the crisis reply.
38
+ CRISIS = ("self_harm_intent", "self_harm")
39
+
40
+
41
+ def _cache_dir() -> Path:
42
+ if os.environ.get("PAXMOD_GUARD_CACHE"):
43
+ return Path(os.environ["PAXMOD_GUARD_CACHE"])
44
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "paxmod-guard"
45
+
46
+
47
+ def download_model(cache_dir: Optional[str] = None, model_url: Optional[str] = None,
48
+ on_progress: Optional[Callable[[str], None]] = None) -> Path:
49
+ """Download the model files if they are not there yet. Returns the model directory."""
50
+ url = model_url or os.environ.get("PAXMOD_GUARD_MODEL_URL") or DEFAULT_MODEL_URL
51
+ d = Path(cache_dir) if cache_dir else _cache_dir() / MODEL_VERSION
52
+ d.mkdir(parents=True, exist_ok=True)
53
+ for f in MODEL_FILES:
54
+ dest = d / f
55
+ if dest.exists():
56
+ continue
57
+ if on_progress:
58
+ on_progress(f"Downloading {f}")
59
+ part = dest.with_suffix(dest.suffix + f".{os.getpid()}.part")
60
+ with urllib.request.urlopen(f"{url}/{MODEL_VERSION}/{f}") as res, open(part, "wb") as out:
61
+ while chunk := res.read(1 << 20):
62
+ out.write(chunk)
63
+ part.rename(dest) # atomic: a half-written file is never mistaken for a finished one
64
+ return d
65
+
66
+
67
+ @dataclass
68
+ class Result:
69
+ flagged: bool
70
+ labels: list[str]
71
+ scores: dict[str, float]
72
+ latency_ms: float
73
+ direction: Optional[str] = None
74
+ crisis_reply: Optional[str] = None
75
+ turns: Optional[int] = None
76
+
77
+
78
+ @dataclass
79
+ class _Stats:
80
+ checks: int = 0
81
+ flagged: int = 0
82
+ crisis_referrals: int = 0
83
+ since: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
84
+
85
+
86
+ class Guard:
87
+ """Load the Guard model. Downloads it on first use unless `model_dir` points at local files.
88
+
89
+ enable: signal labels (grooming tactics, distress, swearing, age questions, romance) to count towards flags;
90
+ they are always scored. include_unsafe: also count the general "unsafe" score.
91
+ """
92
+
93
+ def __init__(self, model_dir: Optional[str] = None, *, enable: Iterable[str] = (), include_unsafe: bool = False,
94
+ thresholds: Optional[dict[str, float]] = None, threads: int = 2, window_tokens: int = 128,
95
+ crisis_reply: str = DEFAULT_CRISIS_REPLY, on_crisis: Optional[Callable[[Result], None]] = None):
96
+ d = Path(model_dir) if model_dir else download_model()
97
+ meta = json.loads((d / "guard.json").read_text())
98
+ self.labels: list[str] = meta["labels"]
99
+ self.model: str = meta.get("version", MODEL_VERSION)
100
+ self.thresholds = {**meta.get("thresholds", {}), **(thresholds or {})}
101
+ self.max_length = meta.get("max_length", 256)
102
+ self.window_tokens = window_tokens
103
+ self.crisis_reply, self.on_crisis = crisis_reply, on_crisis
104
+ on = set(enable) | ({"unsafe"} if include_unsafe else set())
105
+ self._off = {l for l in meta.get("default_off", ["unsafe"]) if l not in on}
106
+ self.tokenizer = Tokenizer.from_file(str(d / "tokenizer.json"))
107
+ so = ort.SessionOptions(); so.intra_op_num_threads = threads
108
+ self.session = ort.InferenceSession(str(d / "model.int8.onnx"), so, providers=["CPUExecutionProvider"])
109
+ self.contextual = "grooming" in self.labels
110
+ self._stats = _Stats()
111
+
112
+ # Long messages are split into overlapping windows at two sizes, full-length for context and short so one bad
113
+ # sentence in a long message is not drowned out. A label's score is its highest across all windows.
114
+ def _windows(self, text: str, short: bool) -> list[list[int]]:
115
+ ids = self.tokenizer.encode(re.sub(" {2,}", " ", text)).ids # as the npm SDK (its tokenizer splits space runs differently)
116
+ cls, sep, body = ids[0], ids[-1], ids[1:-1]
117
+ sizes = [self.max_length - 2]
118
+ small = min(self.max_length, self.window_tokens) - 2
119
+ if short and small < sizes[0] and len(body) > small:
120
+ sizes.append(small)
121
+ out = []
122
+ for size in sizes:
123
+ stride = int(size * 0.75)
124
+ i = 0
125
+ while True:
126
+ out.append([cls, *body[i:i + size], sep])
127
+ if i + size >= len(body):
128
+ break
129
+ i += stride
130
+ return out
131
+
132
+ def _score(self, texts: list[str], short: bool = True) -> dict[str, float]:
133
+ wins = sorted((w for t in texts for w in self._windows(t, short)), key=len)
134
+ best = np.full(len(self.labels), -np.inf)
135
+ for k in range(0, len(wins), 8): # small batches of similar length: little padding
136
+ batch = wins[k:k + 8]; n = len(batch[-1])
137
+ ids = np.zeros((len(batch), n), dtype=np.int64); mask = np.zeros_like(ids)
138
+ for b, w in enumerate(batch):
139
+ ids[b, :len(w)] = w; mask[b, :len(w)] = 1
140
+ logits = self.session.run(["logits"], {"input_ids": ids, "attention_mask": mask})[0]
141
+ best = np.maximum(best, logits.max(0))
142
+ return {l: 1 / (1 + math.exp(-float(v))) for l, v in zip(self.labels, best)}
143
+
144
+ @staticmethod
145
+ def _cut(t: str, n: int = 300) -> str:
146
+ return t if len(t) <= n else t[:n] + "…" # as in training: 300 for earlier turns
147
+
148
+ @staticmethod
149
+ def _tag(turn: dict) -> str:
150
+ role = turn.get("role") or {"output": "ai", "chat": "player"}.get(turn.get("direction"), "user")
151
+ if role == "ai": return "[AI]"
152
+ if role == "player": return f"[CHAT:{turn['speaker']}]" if turn.get("speaker") else "[CHAT]"
153
+ return "[USER]"
154
+
155
+ def _with_context(self, text: str, direction: str, context: list[dict]) -> str:
156
+ target = f"[TARGET] {self._tag({'direction': direction})} {self._cut(text, 1000)}" # the judged turn: 1000, as in training
157
+ earlier = [f"{self._tag(t)} {self._cut(t.get('text') or '')}" for t in context]
158
+ for k in range(len(earlier) + 1):
159
+ s = " ".join(earlier[k:] + [target])
160
+ if k == len(earlier) or len(self.tokenizer.encode(s).ids) <= self.max_length:
161
+ return s
162
+ return target
163
+
164
+ def _flags(self, scores: dict[str, float]) -> list[str]:
165
+ return [l for l in self.labels if l not in self._off and scores[l] >= self.thresholds.get(l, 0.5)]
166
+
167
+ def check(self, text: str, direction: str = "input", context: Optional[list[dict]] = None) -> Result:
168
+ """direction: "input" = a person writing to an AI, "output" = the AI, "chat" = a person writing to other people.
169
+ context: earlier turns, e.g. [{"role": "player", "speaker": "B", "text": "..."}] (role user / ai / player)."""
170
+ if not isinstance(text, str):
171
+ raise TypeError("paxmod_guard: text must be a string")
172
+ t0 = time.perf_counter()
173
+ if not self.contextual:
174
+ inp = f"{'[AI]' if direction == 'output' else '[USER]'} {text}"
175
+ elif context:
176
+ inp = self._with_context(text, direction, context)
177
+ else:
178
+ inp = f"{'[CHAT]' if direction == 'chat' else self._tag({'direction': direction})} {text}" # read whole, in windows
179
+ scores = self._score([inp], not (self.contextual and context)) if text.strip() else {l: 0.0 for l in self.labels}
180
+ flags = self._flags(scores)
181
+ r = Result(bool(flags), flags, scores, round((time.perf_counter() - t0) * 1000, 1), direction)
182
+ self._stats.checks += 1; self._stats.flagged += bool(flags)
183
+ if direction != "output" and any(l in flags for l in CRISIS):
184
+ r.crisis_reply = self.crisis_reply; self._stats.crisis_referrals += 1
185
+ if self.on_crisis: self.on_crisis(r)
186
+ return r
187
+
188
+ def check_input(self, text: str, context: Optional[list[dict]] = None) -> Result:
189
+ """Check a message from the user before your bot sees it."""
190
+ return self.check(text, "input", context)
191
+
192
+ def check_output(self, text: str, context: Optional[list[dict]] = None) -> Result:
193
+ """Check your bot's reply before the user sees it."""
194
+ return self.check(text, "output", context)
195
+
196
+ def check_chat(self, text: str, context: Optional[list[dict]] = None) -> Result:
197
+ """Check a message one person wrote to others (game or community chat)."""
198
+ return self.check(text, "chat", context)
199
+
200
+ def check_conversation(self, turns: list[dict]) -> Result:
201
+ """Judge a whole stretch of conversation, e.g. a DM with both sides, including patterns no single message
202
+ shows (grooming). Read as in training: windows of up to 40 turns, overlapping by half."""
203
+ if not self.contextual:
204
+ raise RuntimeError("paxmod_guard: check_conversation needs a v9 or later model")
205
+ t0 = time.perf_counter()
206
+ parts, i = [], 0
207
+ while True:
208
+ parts.append("[CONVERSATION] " + " ".join(f"{self._tag(t)} {self._cut(t.get('text') or '')}" for t in turns[i:i + 40]))
209
+ if i + 40 >= len(turns):
210
+ break
211
+ i += 20
212
+ scores = self._score(parts, False)
213
+ flags = self._flags(scores)
214
+ self._stats.checks += 1; self._stats.flagged += bool(flags)
215
+ return Result(bool(flags), flags, scores, round((time.perf_counter() - t0) * 1000, 1), turns=len(turns))
216
+
217
+ def stats(self) -> dict:
218
+ """Counts since this guard was created: checks, flagged messages, crisis referrals (e.g. for SB 243 reports)."""
219
+ return dict(self._stats.__dict__)
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "paxmod-guard"
3
+ version = "0.1.0"
4
+ description = "Local safety classifier for AI companions, chatbots and kids' game chat: self-harm, grooming, sexual content, jailbreaks and more, on your own CPU in milliseconds."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.9"
8
+ authors = [{ name = "Paxmod" }]
9
+ keywords = ["guardrails", "ai-safety", "moderation", "companion", "chatbot", "llm", "onnx", "child-safety"]
10
+ dependencies = ["onnxruntime>=1.17", "tokenizers>=0.15", "numpy>=1.24"]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"