safetype 1.5.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.
- safetype-1.5.0/.gitignore +16 -0
- safetype-1.5.0/LICENSE +21 -0
- safetype-1.5.0/PKG-INFO +122 -0
- safetype-1.5.0/README.md +100 -0
- safetype-1.5.0/pyproject.toml +42 -0
- safetype-1.5.0/src/safetype/__init__.py +70 -0
- safetype-1.5.0/src/safetype/_context.py +46 -0
- safetype-1.5.0/src/safetype/_matcher.py +176 -0
- safetype-1.5.0/src/safetype/_normalize.py +65 -0
- safetype-1.5.0/src/safetype/_pattern.py +82 -0
- safetype-1.5.0/src/safetype/_policy.py +82 -0
- safetype-1.5.0/src/safetype/_tokenize.py +97 -0
- safetype-1.5.0/src/safetype/_transform.py +74 -0
- safetype-1.5.0/src/safetype/data/en.json +276 -0
- safetype-1.5.0/src/safetype/engine.py +230 -0
- safetype-1.5.0/src/safetype/py.typed +0 -0
- safetype-1.5.0/src/safetype/types.py +109 -0
- safetype-1.5.0/tests/fixtures/parity.json +17441 -0
- safetype-1.5.0/tests/test_engine.py +91 -0
- safetype-1.5.0/tests/test_parity.py +56 -0
safetype-1.5.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SafeType.js contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
safetype-1.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: safetype
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Rule-based content moderation (profanity, hate, harassment) for Python servers. Same engine as SafeType.js.
|
|
5
|
+
Project-URL: Homepage, https://github.com/karammm/safetype
|
|
6
|
+
Project-URL: Documentation, https://github.com/karammm/safetype/tree/main/python#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/karammm/safetype/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/karammm/safetype/blob/main/CHANGELOG.md
|
|
9
|
+
Author: SafeType.js contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: content-safety,filter,harassment,hate-speech,moderation,profanity
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# safetype (Python)
|
|
24
|
+
|
|
25
|
+
Rule-based content moderation for Python servers: profanity, hate speech and harassment.
|
|
26
|
+
It's the **same engine and English dictionary** as the [SafeType.js](https://github.com/karammm/safetype)
|
|
27
|
+
JavaScript packages, so your backend can re-check exactly what the browser checked.
|
|
28
|
+
It has no dependencies and runs fully offline.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install safetype
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from safetype import create_moderation_engine
|
|
36
|
+
|
|
37
|
+
engine = create_moderation_engine(mode="mask")
|
|
38
|
+
engine.moderate("What the fuck are you doing?").text
|
|
39
|
+
# 'What the f*ck are you doing?'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Server-side enforcement
|
|
43
|
+
|
|
44
|
+
Client-side moderation can be bypassed (devtools, curl, modified clients). Re-check before you store:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
engine = create_moderation_engine(
|
|
48
|
+
mode="mask",
|
|
49
|
+
policy={"hate": {"action": "block"}, "harassment": {"action": "block"}},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
check = engine.validate(comment)
|
|
53
|
+
if not check.valid:
|
|
54
|
+
raise ValidationError([v.category for v in check.violations])
|
|
55
|
+
store(check.result.text) # profanity masked
|
|
56
|
+
if check.result.requires_review:
|
|
57
|
+
queue_for_human_review(comment) # e.g. quoted or reported speech
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
FastAPI:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
@app.post("/comments")
|
|
64
|
+
def create_comment(body: CommentIn):
|
|
65
|
+
check = engine.validate(body.text)
|
|
66
|
+
if not check.valid:
|
|
67
|
+
raise HTTPException(422, detail=[{"category": v.category, "rule": v.rule_id} for v in check.violations])
|
|
68
|
+
return save(check.result.text)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Django form:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def clean_body(self):
|
|
75
|
+
check = engine.validate(self.cleaned_data["body"])
|
|
76
|
+
if not check.valid:
|
|
77
|
+
raise forms.ValidationError("Please keep it respectful.")
|
|
78
|
+
return check.result.text
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## What it handles
|
|
82
|
+
|
|
83
|
+
- Five actions (`allow`, `mask`, `suggest`, `warn`, `block`) and per-category policies
|
|
84
|
+
- Profanity is masked deterministically. Hate and harassment rules are lower-confidence signals that context can weaken:
|
|
85
|
+
quoted or reported speech becomes `requires_review` instead of being acted on
|
|
86
|
+
- Unicode (accents, full-width, Cyrillic/Greek lookalikes, zero-width), leet (`sh1t`), repeated letters, spaced letters
|
|
87
|
+
- No Scunthorpe problem, and masking is idempotent
|
|
88
|
+
- Custom words, custom rules, allow lists, mask styles (`vowels`, `partial`, `full`, or a function)
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
engine = create_moderation_engine(
|
|
94
|
+
mode="mask", language="en", categories=None, policy=None, packs=None,
|
|
95
|
+
custom_words=None, allow_list=None, rules=None,
|
|
96
|
+
mask_char="*", mask_style="vowels", detect_spaced_letters=True,
|
|
97
|
+
)
|
|
98
|
+
engine.moderate(text) # ModerationResult(text, suggested_text, matches, action, allowed, requires_review, mapping)
|
|
99
|
+
engine.mask(text) # str
|
|
100
|
+
engine.validate(text) # ValidationResult(valid, result, violations)
|
|
101
|
+
engine.add_rule({"id": "app.x", "pattern": "(foo|bar*)", "category": "custom"})
|
|
102
|
+
engine.remove_rule("app.x")
|
|
103
|
+
engine.set_policy({"harassment": {"action": "block", "min_confidence": 0.7}})
|
|
104
|
+
engine.set_mode("warn")
|
|
105
|
+
|
|
106
|
+
safetype.moderate(text); safetype.mask(text) # shared default engine
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Offsets (`match.start` / `match.end`) are Python string indices (code points).
|
|
110
|
+
|
|
111
|
+
## Parity with JavaScript
|
|
112
|
+
|
|
113
|
+
The test suite replays 684 cases recorded from the JavaScript engine and requires identical results.
|
|
114
|
+
The dictionary is exported from the JavaScript build, so both always share the same rules.
|
|
115
|
+
|
|
116
|
+
## Limitations
|
|
117
|
+
|
|
118
|
+
Rule-based detection misses novel spellings and sarcasm, and it can flag reclaimed or quoted language.
|
|
119
|
+
Treat hate and harassment results as signals for review, not verdicts. Contextual (AI) classifiers
|
|
120
|
+
are available in the JavaScript engine and planned for Python.
|
|
121
|
+
|
|
122
|
+
MIT licensed.
|
safetype-1.5.0/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# safetype (Python)
|
|
2
|
+
|
|
3
|
+
Rule-based content moderation for Python servers: profanity, hate speech and harassment.
|
|
4
|
+
It's the **same engine and English dictionary** as the [SafeType.js](https://github.com/karammm/safetype)
|
|
5
|
+
JavaScript packages, so your backend can re-check exactly what the browser checked.
|
|
6
|
+
It has no dependencies and runs fully offline.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install safetype
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from safetype import create_moderation_engine
|
|
14
|
+
|
|
15
|
+
engine = create_moderation_engine(mode="mask")
|
|
16
|
+
engine.moderate("What the fuck are you doing?").text
|
|
17
|
+
# 'What the f*ck are you doing?'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Server-side enforcement
|
|
21
|
+
|
|
22
|
+
Client-side moderation can be bypassed (devtools, curl, modified clients). Re-check before you store:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
engine = create_moderation_engine(
|
|
26
|
+
mode="mask",
|
|
27
|
+
policy={"hate": {"action": "block"}, "harassment": {"action": "block"}},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
check = engine.validate(comment)
|
|
31
|
+
if not check.valid:
|
|
32
|
+
raise ValidationError([v.category for v in check.violations])
|
|
33
|
+
store(check.result.text) # profanity masked
|
|
34
|
+
if check.result.requires_review:
|
|
35
|
+
queue_for_human_review(comment) # e.g. quoted or reported speech
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
FastAPI:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
@app.post("/comments")
|
|
42
|
+
def create_comment(body: CommentIn):
|
|
43
|
+
check = engine.validate(body.text)
|
|
44
|
+
if not check.valid:
|
|
45
|
+
raise HTTPException(422, detail=[{"category": v.category, "rule": v.rule_id} for v in check.violations])
|
|
46
|
+
return save(check.result.text)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Django form:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
def clean_body(self):
|
|
53
|
+
check = engine.validate(self.cleaned_data["body"])
|
|
54
|
+
if not check.valid:
|
|
55
|
+
raise forms.ValidationError("Please keep it respectful.")
|
|
56
|
+
return check.result.text
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What it handles
|
|
60
|
+
|
|
61
|
+
- Five actions (`allow`, `mask`, `suggest`, `warn`, `block`) and per-category policies
|
|
62
|
+
- Profanity is masked deterministically. Hate and harassment rules are lower-confidence signals that context can weaken:
|
|
63
|
+
quoted or reported speech becomes `requires_review` instead of being acted on
|
|
64
|
+
- Unicode (accents, full-width, Cyrillic/Greek lookalikes, zero-width), leet (`sh1t`), repeated letters, spaced letters
|
|
65
|
+
- No Scunthorpe problem, and masking is idempotent
|
|
66
|
+
- Custom words, custom rules, allow lists, mask styles (`vowels`, `partial`, `full`, or a function)
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
engine = create_moderation_engine(
|
|
72
|
+
mode="mask", language="en", categories=None, policy=None, packs=None,
|
|
73
|
+
custom_words=None, allow_list=None, rules=None,
|
|
74
|
+
mask_char="*", mask_style="vowels", detect_spaced_letters=True,
|
|
75
|
+
)
|
|
76
|
+
engine.moderate(text) # ModerationResult(text, suggested_text, matches, action, allowed, requires_review, mapping)
|
|
77
|
+
engine.mask(text) # str
|
|
78
|
+
engine.validate(text) # ValidationResult(valid, result, violations)
|
|
79
|
+
engine.add_rule({"id": "app.x", "pattern": "(foo|bar*)", "category": "custom"})
|
|
80
|
+
engine.remove_rule("app.x")
|
|
81
|
+
engine.set_policy({"harassment": {"action": "block", "min_confidence": 0.7}})
|
|
82
|
+
engine.set_mode("warn")
|
|
83
|
+
|
|
84
|
+
safetype.moderate(text); safetype.mask(text) # shared default engine
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Offsets (`match.start` / `match.end`) are Python string indices (code points).
|
|
88
|
+
|
|
89
|
+
## Parity with JavaScript
|
|
90
|
+
|
|
91
|
+
The test suite replays 684 cases recorded from the JavaScript engine and requires identical results.
|
|
92
|
+
The dictionary is exported from the JavaScript build, so both always share the same rules.
|
|
93
|
+
|
|
94
|
+
## Limitations
|
|
95
|
+
|
|
96
|
+
Rule-based detection misses novel spellings and sarcasm, and it can flag reclaimed or quoted language.
|
|
97
|
+
Treat hate and harassment results as signals for review, not verdicts. Contextual (AI) classifiers
|
|
98
|
+
are available in the JavaScript engine and planned for Python.
|
|
99
|
+
|
|
100
|
+
MIT licensed.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "safetype"
|
|
7
|
+
version = "1.5.0"
|
|
8
|
+
description = "Rule-based content moderation (profanity, hate, harassment) for Python servers. Same engine as SafeType.js."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "SafeType.js contributors" }]
|
|
14
|
+
keywords = ["moderation", "profanity", "filter", "content-safety", "hate-speech", "harassment"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Text Processing :: Filters",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/karammm/safetype"
|
|
28
|
+
Documentation = "https://github.com/karammm/safetype/tree/main/python#readme"
|
|
29
|
+
Issues = "https://github.com/karammm/safetype/issues"
|
|
30
|
+
Changelog = "https://github.com/karammm/safetype/blob/main/CHANGELOG.md"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/safetype"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["src/safetype", "tests", "README.md", "LICENSE"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""SafeType: rule-based content moderation for Python servers.
|
|
2
|
+
|
|
3
|
+
The same engine and English dictionary as the SafeType.js JavaScript packages, so the server can
|
|
4
|
+
re-check exactly what the browser checked.
|
|
5
|
+
|
|
6
|
+
>>> from safetype import create_moderation_engine
|
|
7
|
+
>>> engine = create_moderation_engine(mode="mask")
|
|
8
|
+
>>> engine.moderate("What the fuck are you doing?").text
|
|
9
|
+
'What the f*ck are you doing?'
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from ._pattern import PatternError
|
|
17
|
+
from ._transform import mask_text, match_case
|
|
18
|
+
from .engine import ModerationEngine, create_moderation_engine, load_pack
|
|
19
|
+
from .types import (
|
|
20
|
+
ModerationAction,
|
|
21
|
+
ModerationCategory,
|
|
22
|
+
ModerationMatch,
|
|
23
|
+
ModerationResult,
|
|
24
|
+
OffsetMapping,
|
|
25
|
+
RuleDict,
|
|
26
|
+
TextEdit,
|
|
27
|
+
ValidationResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = "1.5.0"
|
|
31
|
+
|
|
32
|
+
_default: Optional[ModerationEngine] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _engine() -> ModerationEngine:
|
|
36
|
+
global _default
|
|
37
|
+
if _default is None:
|
|
38
|
+
_default = create_moderation_engine()
|
|
39
|
+
return _default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def moderate(text: str) -> ModerationResult:
|
|
43
|
+
"""Moderates with a shared default engine (``mode="mask"``, English)."""
|
|
44
|
+
return _engine().moderate(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mask(text: str) -> str:
|
|
48
|
+
"""Masks with a shared default engine."""
|
|
49
|
+
return _engine().mask(text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ModerationAction",
|
|
54
|
+
"ModerationCategory",
|
|
55
|
+
"ModerationEngine",
|
|
56
|
+
"ModerationMatch",
|
|
57
|
+
"ModerationResult",
|
|
58
|
+
"OffsetMapping",
|
|
59
|
+
"PatternError",
|
|
60
|
+
"RuleDict",
|
|
61
|
+
"TextEdit",
|
|
62
|
+
"ValidationResult",
|
|
63
|
+
"__version__",
|
|
64
|
+
"create_moderation_engine",
|
|
65
|
+
"load_pack",
|
|
66
|
+
"mask",
|
|
67
|
+
"mask_text",
|
|
68
|
+
"match_case",
|
|
69
|
+
"moderate",
|
|
70
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Synchronous context heuristics (mirrors classifier.ts). They only lower confidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._matcher import RawMatch
|
|
6
|
+
from ._tokenize import Token
|
|
7
|
+
|
|
8
|
+
CONTEXT_FACTOR = {"direct": 1.0, "quoted": 0.5, "mention": 0.4}
|
|
9
|
+
|
|
10
|
+
_MENTION_WORDS = frozenset(
|
|
11
|
+
"word words term slur phrase say said saying says spell spelled called calling means meaning define definition using use".split()
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_PAIRS = (('"', '"'), ("“", "”"), ("‘", "’"), ("«", "»"), ("「", "」"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_quoted_ranges(text: str) -> list[tuple[int, int]]:
|
|
18
|
+
ranges: list[tuple[int, int]] = []
|
|
19
|
+
for open_, close in _PAIRS:
|
|
20
|
+
start_from = 0
|
|
21
|
+
while start_from < len(text):
|
|
22
|
+
s = text.find(open_, start_from)
|
|
23
|
+
if s == -1:
|
|
24
|
+
break
|
|
25
|
+
e = text.find(close, s + 1)
|
|
26
|
+
if e == -1:
|
|
27
|
+
break
|
|
28
|
+
newline = text.find("\n", s)
|
|
29
|
+
if newline != -1 and newline < e:
|
|
30
|
+
start_from = newline + 1
|
|
31
|
+
continue
|
|
32
|
+
ranges.append((s + 1, e))
|
|
33
|
+
start_from = e + 1
|
|
34
|
+
return ranges
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_context(match: RawMatch, tokens: list[Token], quoted: list[tuple[int, int]]) -> str:
|
|
38
|
+
if match.token_index is not None:
|
|
39
|
+
for k in (1, 2):
|
|
40
|
+
i = match.token_index - k
|
|
41
|
+
if i >= 0 and tokens[i].value in _MENTION_WORDS:
|
|
42
|
+
return "mention"
|
|
43
|
+
for s, e in quoted:
|
|
44
|
+
if match.start >= s and match.end <= e:
|
|
45
|
+
return "quoted"
|
|
46
|
+
return "direct"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Rule compilation, indexing and matching (mirrors matcher.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ._normalize import NormalizedText
|
|
9
|
+
from ._pattern import Alternative, Element, PatternError, Run, parse_pattern, runs_match, to_runs
|
|
10
|
+
from ._tokenize import Token
|
|
11
|
+
from .types import ALL_CATEGORIES, SEVERITY_RANK, RuleDict
|
|
12
|
+
|
|
13
|
+
DEFAULT_PRIORITY = {"custom": 40, "hate": 30, "harassment": 20, "profanity": 10}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CompiledRule:
|
|
18
|
+
id: str
|
|
19
|
+
category: str
|
|
20
|
+
severity: str
|
|
21
|
+
confidence: float
|
|
22
|
+
priority: int
|
|
23
|
+
replacement: Optional[str]
|
|
24
|
+
label: Optional[str]
|
|
25
|
+
boundary: str
|
|
26
|
+
elements: tuple[Element, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RawMatch:
|
|
31
|
+
start: int
|
|
32
|
+
end: int
|
|
33
|
+
rule: CompiledRule
|
|
34
|
+
confidence: float
|
|
35
|
+
token_index: Optional[int] = None
|
|
36
|
+
context: str = "direct"
|
|
37
|
+
source: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compile_rule(rule: RuleDict, boundary: str = "word") -> CompiledRule:
|
|
41
|
+
rule_id = rule.get("id")
|
|
42
|
+
if not isinstance(rule_id, str) or not rule_id:
|
|
43
|
+
raise TypeError("Rule requires a non-empty id")
|
|
44
|
+
if not isinstance(rule.get("pattern"), str):
|
|
45
|
+
raise TypeError(f'Rule "{rule_id}" requires a pattern')
|
|
46
|
+
category = rule.get("category")
|
|
47
|
+
if category not in ALL_CATEGORIES:
|
|
48
|
+
raise TypeError(f'Rule "{rule_id}" has unknown category "{category}"')
|
|
49
|
+
severity = rule.get("severity", "medium")
|
|
50
|
+
if severity not in SEVERITY_RANK:
|
|
51
|
+
raise TypeError(f'Rule "{rule_id}" has unknown severity "{severity}"')
|
|
52
|
+
confidence = rule.get("confidence", 1)
|
|
53
|
+
if not (0 <= confidence <= 1):
|
|
54
|
+
raise ValueError(f'Rule "{rule_id}" confidence must be within [0, 1]')
|
|
55
|
+
try:
|
|
56
|
+
elements = parse_pattern(rule["pattern"])
|
|
57
|
+
except PatternError as error:
|
|
58
|
+
raise PatternError(f'Rule "{rule_id}": {error}') from None
|
|
59
|
+
if boundary == "substring" and len(elements) != 1:
|
|
60
|
+
raise PatternError(f'Rule "{rule_id}": substring packs support single-token patterns only')
|
|
61
|
+
return CompiledRule(
|
|
62
|
+
id=rule_id,
|
|
63
|
+
category=category,
|
|
64
|
+
severity=severity,
|
|
65
|
+
confidence=confidence,
|
|
66
|
+
priority=rule.get("priority", DEFAULT_PRIORITY[category]),
|
|
67
|
+
replacement=rule.get("replacement"),
|
|
68
|
+
label=rule.get("label"),
|
|
69
|
+
boundary=boundary,
|
|
70
|
+
elements=elements,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class _Entry:
|
|
76
|
+
rule: CompiledRule
|
|
77
|
+
alt: Alternative
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RuleIndex:
|
|
82
|
+
exact: dict[str, list[_Entry]] = field(default_factory=dict)
|
|
83
|
+
prefix: dict[str, list[_Entry]] = field(default_factory=dict)
|
|
84
|
+
substring: list[CompiledRule] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def build(cls, rules) -> "RuleIndex":
|
|
88
|
+
index = cls()
|
|
89
|
+
for rule in rules:
|
|
90
|
+
if rule.boundary == "substring":
|
|
91
|
+
index.substring.append(rule)
|
|
92
|
+
continue
|
|
93
|
+
for alt in rule.elements[0].alternatives:
|
|
94
|
+
target = index.prefix if alt.prefix else index.exact
|
|
95
|
+
key = alt.key[0] if alt.prefix else alt.key
|
|
96
|
+
target.setdefault(key, []).append(_Entry(rule, alt))
|
|
97
|
+
return index
|
|
98
|
+
|
|
99
|
+
def candidates(self, runs: tuple[Run, ...], key: str) -> list[_Entry]:
|
|
100
|
+
out = [e for e in self.exact.get(key, []) if runs_match(runs, e.alt)]
|
|
101
|
+
out += [e for e in self.prefix.get(key[:1], []) if runs_match(runs, e.alt)]
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class _TokenInfo:
|
|
107
|
+
runs: tuple[Run, ...]
|
|
108
|
+
key: str
|
|
109
|
+
blocked: bool
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _token_info(token: Token, allow_list: set[str]) -> _TokenInfo:
|
|
113
|
+
runs = to_runs(token.value)
|
|
114
|
+
return _TokenInfo(runs, "".join(c for c, _ in runs), token.masked or token.value in allow_list)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _element_matches(element: Element, info: _TokenInfo) -> bool:
|
|
118
|
+
return not info.blocked and any(runs_match(info.runs, alt) for alt in element.alternatives)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _match_rest(elements, e: int, infos, t: int) -> int:
|
|
122
|
+
"""Index of the last token consumed, preferring the longest match, or -1."""
|
|
123
|
+
if e == len(elements):
|
|
124
|
+
return t - 1
|
|
125
|
+
element = elements[e]
|
|
126
|
+
best = -1
|
|
127
|
+
if t < len(infos) and _element_matches(element, infos[t]):
|
|
128
|
+
best = _match_rest(elements, e + 1, infos, t + 1)
|
|
129
|
+
if element.optional:
|
|
130
|
+
best = max(best, _match_rest(elements, e + 1, infos, t))
|
|
131
|
+
return best
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_matches(index: RuleIndex, normalized: NormalizedText, tokens: list[Token], spaced: list[Token], allow_list: set[str]) -> list[RawMatch]:
|
|
135
|
+
infos = [_token_info(t, allow_list) for t in tokens]
|
|
136
|
+
out: list[RawMatch] = []
|
|
137
|
+
seen: set[tuple[str, int, int]] = set()
|
|
138
|
+
|
|
139
|
+
def push(match: RawMatch) -> None:
|
|
140
|
+
key = (match.rule.id, match.start, match.end)
|
|
141
|
+
if key not in seen:
|
|
142
|
+
seen.add(key)
|
|
143
|
+
out.append(match)
|
|
144
|
+
|
|
145
|
+
for i, token in enumerate(tokens):
|
|
146
|
+
info = infos[i]
|
|
147
|
+
if info.blocked:
|
|
148
|
+
continue
|
|
149
|
+
seen_rules: set[str] = set()
|
|
150
|
+
for entry in index.candidates(info.runs, info.key):
|
|
151
|
+
rule = entry.rule
|
|
152
|
+
if rule.id in seen_rules:
|
|
153
|
+
continue
|
|
154
|
+
seen_rules.add(rule.id)
|
|
155
|
+
last = _match_rest(rule.elements, 1, infos, i + 1)
|
|
156
|
+
if last < i:
|
|
157
|
+
continue
|
|
158
|
+
push(RawMatch(token.start, tokens[last].end, rule, rule.confidence, token_index=i))
|
|
159
|
+
|
|
160
|
+
for token in spaced:
|
|
161
|
+
info = _token_info(token, allow_list)
|
|
162
|
+
if info.blocked:
|
|
163
|
+
continue
|
|
164
|
+
for entry in index.candidates(info.runs, info.key):
|
|
165
|
+
if len(entry.rule.elements) == 1:
|
|
166
|
+
push(RawMatch(token.start, token.end, entry.rule, entry.rule.confidence, token_index=token.first_index))
|
|
167
|
+
|
|
168
|
+
text = normalized.text
|
|
169
|
+
for rule in index.substring:
|
|
170
|
+
for alt in rule.elements[0].alternatives:
|
|
171
|
+
at = text.find(alt.value)
|
|
172
|
+
while at != -1:
|
|
173
|
+
n_end = at + len(alt.value)
|
|
174
|
+
push(RawMatch(normalized.starts[at], normalized.ends[n_end - 1], rule, rule.confidence))
|
|
175
|
+
at = text.find(alt.value, at + 1)
|
|
176
|
+
return out
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Unicode-aware normalization with an offset map back to the original text (mirrors normalizer.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
_ZERO_WIDTH = frozenset("")
|
|
10
|
+
|
|
11
|
+
# Cyrillic and Greek letters that look like Latin letters (lowercase forms).
|
|
12
|
+
_CONFUSABLES = {
|
|
13
|
+
"а": "a", "в": "b", "е": "e", "ё": "e", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p", "с": "c", "т": "t",
|
|
14
|
+
"у": "y", "х": "x", "ѕ": "s", "і": "i", "ї": "i", "ј": "j", "һ": "h", "ԁ": "d", "ԛ": "q", "ԝ": "w", "ү": "y",
|
|
15
|
+
"α": "a", "β": "b", "ε": "e", "η": "n", "ι": "i", "κ": "k", "ν": "v", "ο": "o", "ρ": "p", "τ": "t", "υ": "u",
|
|
16
|
+
"χ": "x", "ω": "w",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Symbol/digit substitutions, applied only inside tokens that already contain a letter.
|
|
20
|
+
LEET = {"@": "a", "4": "a", "$": "s", "5": "s", "0": "o", "1": "i", "!": "i", "|": "i", "3": "e", "€": "e", "7": "t"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=10_000)
|
|
24
|
+
def normalize_char(ch: str) -> str:
|
|
25
|
+
if ch in _ZERO_WIDTH:
|
|
26
|
+
return ""
|
|
27
|
+
decomposed = unicodedata.normalize("NFKD", ch.lower())
|
|
28
|
+
base = "".join(c for c in decomposed if not unicodedata.category(c).startswith("M")).lower()
|
|
29
|
+
return "".join(_CONFUSABLES.get(c, c) for c in base)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class NormalizedText:
|
|
34
|
+
text: str
|
|
35
|
+
starts: list[int]
|
|
36
|
+
"""Original start offset for each character of ``text``."""
|
|
37
|
+
ends: list[int]
|
|
38
|
+
"""Original end offset (exclusive) for each character of ``text``."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize(value: str) -> NormalizedText:
|
|
42
|
+
out: list[str] = []
|
|
43
|
+
starts: list[int] = []
|
|
44
|
+
ends: list[int] = []
|
|
45
|
+
for i, ch in enumerate(value):
|
|
46
|
+
n = normalize_char(ch)
|
|
47
|
+
for c in n:
|
|
48
|
+
out.append(c)
|
|
49
|
+
starts.append(i)
|
|
50
|
+
ends.append(i + 1)
|
|
51
|
+
return NormalizedText("".join(out), starts, ends)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_letter(ch: str) -> bool:
|
|
55
|
+
return unicodedata.category(ch).startswith("L")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def apply_leet(value: str) -> str:
|
|
59
|
+
if not any(is_letter(c) for c in value):
|
|
60
|
+
return value
|
|
61
|
+
return "".join(LEET.get(c, c) for c in value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_term(value: str) -> str:
|
|
65
|
+
return apply_leet(normalize(value).text)
|