edgepii 0.1.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.
- edgepii/__init__.py +16 -0
- edgepii/bridges/__init__.py +0 -0
- edgepii/bridges/presidio.py +17 -0
- edgepii/check.py +14 -0
- edgepii/detectors/__init__.py +0 -0
- edgepii/detectors/gliner.py +58 -0
- edgepii/detectors/gliner2.py +77 -0
- edgepii/detectors/hf_token.py +70 -0
- edgepii/detectors/rules.py +228 -0
- edgepii/detectors/uie.py +107 -0
- edgepii/errors.py +22 -0
- edgepii/eval/__init__.py +32 -0
- edgepii/eval/cli.py +104 -0
- edgepii/eval/corpora.py +349 -0
- edgepii/eval/corpora.yaml +118 -0
- edgepii/eval/runner.py +352 -0
- edgepii/eval/score.py +190 -0
- edgepii/labelmap.py +94 -0
- edgepii/locator.py +188 -0
- edgepii/merge.py +100 -0
- edgepii/presets.py +54 -0
- edgepii/protocol.py +39 -0
- edgepii/py.typed +0 -0
- edgepii/redact.py +59 -0
- edgepii/span.py +51 -0
- edgepii/taxonomies/2026.11.yaml +113 -0
- edgepii/windows.py +51 -0
- edgepii-0.1.0.dist-info/METADATA +482 -0
- edgepii-0.1.0.dist-info/RECORD +32 -0
- edgepii-0.1.0.dist-info/WHEEL +5 -0
- edgepii-0.1.0.dist-info/licenses/LICENSE +202 -0
- edgepii-0.1.0.dist-info/top_level.txt +1 -0
edgepii/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Locate PII as typed character spans on the source string."""
|
|
2
|
+
|
|
3
|
+
from edgepii.errors import DetectorLoadError, OverlapError, UnknownType
|
|
4
|
+
from edgepii.locator import Locator
|
|
5
|
+
from edgepii.redact import redact
|
|
6
|
+
from edgepii.span import LocateResult, Span
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DetectorLoadError",
|
|
10
|
+
"LocateResult",
|
|
11
|
+
"Locator",
|
|
12
|
+
"OverlapError",
|
|
13
|
+
"Span",
|
|
14
|
+
"UnknownType",
|
|
15
|
+
"redact",
|
|
16
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Convert spans to the Presidio recognizer-result shape. Core does not import Presidio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgepii.span import Span
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_presidio(spans: tuple[Span, ...] | list[Span]) -> list[dict]:
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
"entity_type": span.type,
|
|
12
|
+
"start": span.start,
|
|
13
|
+
"end": span.end,
|
|
14
|
+
"score": 0.0 if span.score is None else span.score,
|
|
15
|
+
}
|
|
16
|
+
for span in spans
|
|
17
|
+
]
|
edgepii/check.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Slice check. A failed hit is dropped. The locator does not search for a repair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgepii.protocol import Hit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def slice_text(source: str, hit: Hit) -> str | None:
|
|
9
|
+
if hit.start < 0 or hit.end <= hit.start or hit.end > len(source):
|
|
10
|
+
return None
|
|
11
|
+
text = source[hit.start : hit.end]
|
|
12
|
+
if hit.claimed_text is not None and hit.claimed_text != text:
|
|
13
|
+
return None
|
|
14
|
+
return text
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""GLiNER v1 adapter for nvidia/gliner-PII and the urchade PII checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgepii.errors import DetectorLoadError
|
|
6
|
+
from edgepii.protocol import Hit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GlinerDetector:
|
|
10
|
+
max_chars = 8_000
|
|
11
|
+
overlap_chars = 64
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
repo_id: str,
|
|
16
|
+
revision: str,
|
|
17
|
+
device: str = "cpu",
|
|
18
|
+
candidate_floor: float | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.repo_id = repo_id
|
|
21
|
+
self.revision = revision
|
|
22
|
+
self.device = device
|
|
23
|
+
self.candidate_floor = candidate_floor
|
|
24
|
+
try:
|
|
25
|
+
from gliner import GLiNER
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise DetectorLoadError(
|
|
28
|
+
f"extra=gliner repo={repo_id} revision={revision}"
|
|
29
|
+
) from exc
|
|
30
|
+
try:
|
|
31
|
+
self._model = GLiNER.from_pretrained(repo_id, revision=revision)
|
|
32
|
+
except TypeError:
|
|
33
|
+
self._model = GLiNER.from_pretrained(repo_id)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise DetectorLoadError(
|
|
36
|
+
f"extra=gliner repo={repo_id} revision={revision}"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
|
|
40
|
+
if source == "" or not labels:
|
|
41
|
+
return ()
|
|
42
|
+
options: dict = {}
|
|
43
|
+
# The checkpoint sets its own boundary. Pass no second, unmeasured one.
|
|
44
|
+
if self.candidate_floor is not None:
|
|
45
|
+
options["threshold"] = self.candidate_floor
|
|
46
|
+
rows = self._model.predict_entities(source, labels, **options)
|
|
47
|
+
hits: list[Hit] = []
|
|
48
|
+
for row in rows:
|
|
49
|
+
hits.append(
|
|
50
|
+
Hit(
|
|
51
|
+
raw_label=str(row.get("label") or ""),
|
|
52
|
+
start=int(row["start"]),
|
|
53
|
+
end=int(row["end"]),
|
|
54
|
+
score=None if row.get("score") is None else float(row["score"]),
|
|
55
|
+
detector_id=f"gliner.{self.repo_id}",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return tuple(hits)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""GLiNER2 open-schema adapter. The type list is an input to the model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgepii.errors import DetectorLoadError
|
|
6
|
+
from edgepii.protocol import Hit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Gliner2Detector:
|
|
10
|
+
max_chars = 8_000
|
|
11
|
+
overlap_chars = 64
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
repo_id: str,
|
|
16
|
+
revision: str,
|
|
17
|
+
device: str = "cpu",
|
|
18
|
+
candidate_floor: float | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.repo_id = repo_id
|
|
21
|
+
self.revision = revision
|
|
22
|
+
self.device = device
|
|
23
|
+
self.candidate_floor = candidate_floor
|
|
24
|
+
try:
|
|
25
|
+
from gliner2 import GLiNER2
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise DetectorLoadError(
|
|
28
|
+
f"extra=gliner2 repo={repo_id} revision={revision}"
|
|
29
|
+
) from exc
|
|
30
|
+
try:
|
|
31
|
+
self._model = GLiNER2.from_pretrained(repo_id, revision=revision)
|
|
32
|
+
except TypeError:
|
|
33
|
+
self._model = GLiNER2.from_pretrained(repo_id)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise DetectorLoadError(
|
|
36
|
+
f"extra=gliner2 repo={repo_id} revision={revision}"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
|
|
40
|
+
if source == "" or not labels:
|
|
41
|
+
return ()
|
|
42
|
+
options: dict = {
|
|
43
|
+
"include_confidence": True,
|
|
44
|
+
"include_spans": True,
|
|
45
|
+
}
|
|
46
|
+
# The checkpoint author chose its own decision boundary. This adapter
|
|
47
|
+
# adds no second one. A caller that measured a floor passes one.
|
|
48
|
+
if self.candidate_floor is not None:
|
|
49
|
+
options["threshold"] = self.candidate_floor
|
|
50
|
+
result = self._model.extract_entities(source, labels, **options)
|
|
51
|
+
return tuple(_hits_from_tree(result, f"gliner2.{self.repo_id}"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hits_from_tree(node: object, detector_id: str, label: str | None = None) -> list[Hit]:
|
|
55
|
+
hits: list[Hit] = []
|
|
56
|
+
if isinstance(node, dict) and "start" in node and "end" in node:
|
|
57
|
+
raw = str(node.get("label") or node.get("type") or label or "")
|
|
58
|
+
score = node.get("confidence", node.get("score"))
|
|
59
|
+
hits.append(
|
|
60
|
+
Hit(
|
|
61
|
+
raw_label=raw,
|
|
62
|
+
start=int(node["start"]),
|
|
63
|
+
end=int(node["end"]),
|
|
64
|
+
score=None if score is None else float(score),
|
|
65
|
+
detector_id=detector_id,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return hits
|
|
69
|
+
if isinstance(node, dict):
|
|
70
|
+
for key, value in node.items():
|
|
71
|
+
next_label = key if isinstance(key, str) else label
|
|
72
|
+
hits.extend(_hits_from_tree(value, detector_id, next_label))
|
|
73
|
+
return hits
|
|
74
|
+
if isinstance(node, list):
|
|
75
|
+
for item in node:
|
|
76
|
+
hits.extend(_hits_from_tree(item, detector_id, label))
|
|
77
|
+
return hits
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Hugging Face token classifiers. BERT and ModernBERT share this class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from edgepii.errors import DetectorLoadError
|
|
6
|
+
from edgepii.protocol import Hit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HfTokenDetector:
|
|
10
|
+
"""A window is 75 percent of the checkpoint max position to stay under it."""
|
|
11
|
+
|
|
12
|
+
default_context = 512
|
|
13
|
+
context_fraction = 0.75
|
|
14
|
+
overlap_chars = 64
|
|
15
|
+
|
|
16
|
+
def __init__(self, repo_id: str, revision: str, device: str = "cpu") -> None:
|
|
17
|
+
self.repo_id = repo_id
|
|
18
|
+
self.revision = revision
|
|
19
|
+
self.device = device
|
|
20
|
+
try:
|
|
21
|
+
from transformers import pipeline
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise DetectorLoadError(
|
|
24
|
+
f"extra=hf repo={repo_id} revision={revision}"
|
|
25
|
+
) from exc
|
|
26
|
+
device_index = 0 if device == "cuda" else -1
|
|
27
|
+
try:
|
|
28
|
+
self._pipe = pipeline(
|
|
29
|
+
"token-classification",
|
|
30
|
+
model=repo_id,
|
|
31
|
+
revision=revision,
|
|
32
|
+
aggregation_strategy="simple",
|
|
33
|
+
device=device_index,
|
|
34
|
+
)
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
raise DetectorLoadError(
|
|
37
|
+
f"extra=hf repo={repo_id} revision={revision}"
|
|
38
|
+
) from exc
|
|
39
|
+
self.max_chars = self._window_from_context()
|
|
40
|
+
|
|
41
|
+
def _window_from_context(self) -> int:
|
|
42
|
+
"""Read the checkpoint limit. Characters here are a safe lower bound."""
|
|
43
|
+
|
|
44
|
+
config = getattr(self._pipe.model, "config", None)
|
|
45
|
+
positions = getattr(config, "max_position_embeddings", None)
|
|
46
|
+
if not isinstance(positions, int) or positions <= 0:
|
|
47
|
+
positions = self.default_context
|
|
48
|
+
return max(1, int(positions * self.context_fraction))
|
|
49
|
+
|
|
50
|
+
def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
|
|
51
|
+
del labels
|
|
52
|
+
if source == "":
|
|
53
|
+
return ()
|
|
54
|
+
rows = self._pipe(source)
|
|
55
|
+
hits: list[Hit] = []
|
|
56
|
+
for row in rows:
|
|
57
|
+
start = int(row["start"])
|
|
58
|
+
end = int(row["end"])
|
|
59
|
+
raw = str(row.get("entity_group") or row.get("entity") or "")
|
|
60
|
+
score = float(row["score"])
|
|
61
|
+
hits.append(
|
|
62
|
+
Hit(
|
|
63
|
+
raw_label=raw,
|
|
64
|
+
start=start,
|
|
65
|
+
end=end,
|
|
66
|
+
score=score,
|
|
67
|
+
detector_id=f"hf.{self.repo_id}",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return tuple(hits)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Structured forms with a named validator.
|
|
2
|
+
|
|
3
|
+
Every rule here answers one question: does a published grammar or checksum
|
|
4
|
+
confirm this slice? A rule that a validator confirms scores 1.0. A rule that
|
|
5
|
+
only matches a shape scores null. No rule invents a probability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import phonenumbers
|
|
14
|
+
from phonenumbers import PhoneNumberMatcher, ValidationResult
|
|
15
|
+
|
|
16
|
+
from edgepii.protocol import Hit
|
|
17
|
+
|
|
18
|
+
# RFC 5321 section 4.5.3.1: local part at most 64 octets, path at most 256.
|
|
19
|
+
# The domain must hold at least one dot and a two-letter top label.
|
|
20
|
+
_EMAIL = re.compile(
|
|
21
|
+
r"(?<![A-Za-z0-9._%+\-])"
|
|
22
|
+
r"([A-Za-z0-9!#$%&'*+/=?^_`{|}~\-]{1,64}"
|
|
23
|
+
r"(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~\-]{1,64})*)"
|
|
24
|
+
r"@"
|
|
25
|
+
r"((?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})"
|
|
26
|
+
r"(?![A-Za-z0-9\-])"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# ISO/IEC 7812-1: a PAN holds 8 to 19 digits and starts with a major industry
|
|
30
|
+
# identifier in the range 1 to 9. Separators are spaces or hyphens.
|
|
31
|
+
_CARD = re.compile(r"(?<![A-Za-z0-9])\d(?:[ \-]?\d){7,18}(?![A-Za-z0-9])")
|
|
32
|
+
|
|
33
|
+
# ISO 13616: two country letters, two check digits, then 11 to 30 alphanumerics.
|
|
34
|
+
_IBAN = re.compile(r"(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{11,30}(?![A-Za-z0-9])")
|
|
35
|
+
|
|
36
|
+
# RFC 3986 section 3.2.2 for IPv4 text form. Octet ranges are checked by value.
|
|
37
|
+
# A trailing sentence period is allowed. A trailing digit or dotted octet is not.
|
|
38
|
+
_IPV4 = re.compile(r"(?<![0-9.])(?:\d{1,3}\.){3}\d{1,3}(?![0-9])(?!\.[0-9])")
|
|
39
|
+
|
|
40
|
+
# RFC 4291 section 2.2 text forms. The stdlib validator decides membership.
|
|
41
|
+
_IPV6 = re.compile(r"(?<![0-9A-Za-z:])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![0-9A-Za-z:])")
|
|
42
|
+
|
|
43
|
+
# A cue word, then one of two forms.
|
|
44
|
+
# 1. Assignment: cue, separator, value. The separator marks the value.
|
|
45
|
+
# 2. Mention: cue, space, value, where the value holds a digit or a symbol.
|
|
46
|
+
# Form 2 exists because prose says "the password hunter2". A bare word after a
|
|
47
|
+
# cue is prose, so form 2 refuses it. That is a grammar rule, not a word list.
|
|
48
|
+
_CUE_ASSIGN = re.compile(
|
|
49
|
+
r"(?i)\b(?:password|passcode|passwd|api[_\-]?key|access[_\-]?token|secret[_\-]?key)\b"
|
|
50
|
+
r"\s*[:=]\s*"
|
|
51
|
+
r"([^\s,.;:=\"'()<>\[\]]{1,256})"
|
|
52
|
+
)
|
|
53
|
+
_CUE_MENTION = re.compile(
|
|
54
|
+
r"(?i)\b(?:password|passcode|passwd|api[_\-]?key|access[_\-]?token|secret[_\-]?key)\b"
|
|
55
|
+
r"\s+"
|
|
56
|
+
r"([^\s,.;:=\"'()<>\[\]]{1,256})"
|
|
57
|
+
)
|
|
58
|
+
_SECRET_SHAPE = re.compile(r"[0-9!-/:-@\[-`{-~]")
|
|
59
|
+
|
|
60
|
+
# ISO 3166 regions that the phone rule asks libphonenumber to try.
|
|
61
|
+
PHONE_REGIONS = ("US", "CN", "HK", "GB")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _luhn_ok(digits: str) -> bool:
|
|
65
|
+
"""ISO/IEC 7812-1 check digit."""
|
|
66
|
+
|
|
67
|
+
if not 8 <= len(digits) <= 19:
|
|
68
|
+
return False
|
|
69
|
+
if digits[0] == "0":
|
|
70
|
+
return False
|
|
71
|
+
total = 0
|
|
72
|
+
for index, char in enumerate(reversed(digits)):
|
|
73
|
+
value = ord(char) - 48
|
|
74
|
+
if index % 2 == 1:
|
|
75
|
+
value *= 2
|
|
76
|
+
if value > 9:
|
|
77
|
+
value -= 9
|
|
78
|
+
total += value
|
|
79
|
+
return total % 10 == 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iban_ok(raw: str) -> bool:
|
|
83
|
+
"""ISO 13616 with the ISO 7064 MOD-97-10 check."""
|
|
84
|
+
|
|
85
|
+
compact = raw.replace(" ", "").upper()
|
|
86
|
+
if not 15 <= len(compact) <= 34:
|
|
87
|
+
return False
|
|
88
|
+
rearranged = compact[4:] + compact[:4]
|
|
89
|
+
number = ""
|
|
90
|
+
for char in rearranged:
|
|
91
|
+
if char.isdigit():
|
|
92
|
+
number += char
|
|
93
|
+
elif "A" <= char <= "Z":
|
|
94
|
+
number += str(ord(char) - 55)
|
|
95
|
+
else:
|
|
96
|
+
return False
|
|
97
|
+
return int(number) % 97 == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ip_ok(raw: str) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
ipaddress.ip_address(raw)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RuleDetector:
|
|
109
|
+
"""Validators and grammars. Person names are not this detector's job."""
|
|
110
|
+
|
|
111
|
+
max_chars = 10_000_000
|
|
112
|
+
overlap_chars = 0
|
|
113
|
+
detector_kind = "rules"
|
|
114
|
+
|
|
115
|
+
def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
|
|
116
|
+
allowed = {_norm(label) for label in labels}
|
|
117
|
+
hits: list[Hit] = []
|
|
118
|
+
if _wants(allowed, "email"):
|
|
119
|
+
hits.extend(_emails(source))
|
|
120
|
+
if _wants(allowed, "phone", "phone_number", "phonenumber"):
|
|
121
|
+
hits.extend(_phones(source))
|
|
122
|
+
if _wants(allowed, "card_number", "payment_card", "creditcardnumber", "credit_debit_card", "iban", "bank_account"):
|
|
123
|
+
hits.extend(_payments(source, allowed))
|
|
124
|
+
if _wants(allowed, "ip", "ip_address", "ipv4", "ipv6"):
|
|
125
|
+
hits.extend(_ips(source))
|
|
126
|
+
if _wants(allowed, "password", "passcode", "secret", "api_key", "access_token", "secret_key"):
|
|
127
|
+
hits.extend(_secrets(source))
|
|
128
|
+
return tuple(hits)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _norm(label: str) -> str:
|
|
132
|
+
return label.strip().casefold().replace(" ", "_")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _wants(allowed: set[str], *names: str) -> bool:
|
|
136
|
+
return any(name in allowed for name in names)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _emails(source: str) -> list[Hit]:
|
|
140
|
+
"""RFC 5322 address in its common ASCII form. Shape only, so score is null."""
|
|
141
|
+
|
|
142
|
+
hits = []
|
|
143
|
+
for match in _EMAIL.finditer(source):
|
|
144
|
+
if len(match.group()) > 254:
|
|
145
|
+
continue
|
|
146
|
+
hits.append(Hit("email", match.start(), match.end(), None, "rules.email"))
|
|
147
|
+
return hits
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _phones(source: str) -> list[Hit]:
|
|
151
|
+
"""libphonenumber decides. Two accept tests, one per documented form.
|
|
152
|
+
|
|
153
|
+
A number that libphonenumber calls valid is a phone. A number that starts
|
|
154
|
+
with an explicit international prefix and has a plan-correct length is a
|
|
155
|
+
phone. Nothing else is. The region list only supplies a default region.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
found: dict[tuple[int, int], float | None] = {}
|
|
159
|
+
for region in PHONE_REGIONS:
|
|
160
|
+
matcher = PhoneNumberMatcher(
|
|
161
|
+
source, region, leniency=phonenumbers.Leniency.POSSIBLE
|
|
162
|
+
)
|
|
163
|
+
for match in matcher:
|
|
164
|
+
key = (match.start, match.end)
|
|
165
|
+
text = source[match.start : match.end]
|
|
166
|
+
if phonenumbers.is_valid_number(match.number):
|
|
167
|
+
found[key] = 1.0
|
|
168
|
+
continue
|
|
169
|
+
if not text.startswith("+"):
|
|
170
|
+
continue
|
|
171
|
+
reason = phonenumbers.is_possible_number_with_reason(match.number)
|
|
172
|
+
if reason == ValidationResult.IS_POSSIBLE:
|
|
173
|
+
found.setdefault(key, None)
|
|
174
|
+
return [
|
|
175
|
+
Hit("phone", start, end, score, "rules.phone")
|
|
176
|
+
for (start, end), score in sorted(found.items())
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _payments(source: str, allowed: set[str]) -> list[Hit]:
|
|
181
|
+
hits: list[Hit] = []
|
|
182
|
+
iban_spans: list[tuple[int, int]] = []
|
|
183
|
+
if _wants(allowed, "iban", "bank_account"):
|
|
184
|
+
for match in _IBAN.finditer(source):
|
|
185
|
+
if not _iban_ok(match.group()):
|
|
186
|
+
continue
|
|
187
|
+
iban_spans.append((match.start(), match.end()))
|
|
188
|
+
hits.append(Hit("iban", match.start(), match.end(), 1.0, "rules.iban"))
|
|
189
|
+
if _wants(allowed, "card_number", "payment_card", "creditcardnumber", "credit_debit_card"):
|
|
190
|
+
for match in _CARD.finditer(source):
|
|
191
|
+
if _inside(match.start(), match.end(), iban_spans):
|
|
192
|
+
continue
|
|
193
|
+
raw = match.group()
|
|
194
|
+
digits = "".join(char for char in raw if char.isdigit())
|
|
195
|
+
if not _luhn_ok(digits):
|
|
196
|
+
continue
|
|
197
|
+
hits.append(Hit("card_number", match.start(), match.end(), 1.0, "rules.card"))
|
|
198
|
+
return hits
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
|
|
202
|
+
return any(left <= start and end <= right for left, right in spans)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _ips(source: str) -> list[Hit]:
|
|
206
|
+
hits: list[Hit] = []
|
|
207
|
+
for pattern in (_IPV4, _IPV6):
|
|
208
|
+
for match in pattern.finditer(source):
|
|
209
|
+
if not _ip_ok(match.group()):
|
|
210
|
+
continue
|
|
211
|
+
hits.append(Hit("ip_address", match.start(), match.end(), 1.0, "rules.ip"))
|
|
212
|
+
return hits
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _secrets(source: str) -> list[Hit]:
|
|
216
|
+
"""A cue names a value. An assignment always counts, a mention needs a shape."""
|
|
217
|
+
|
|
218
|
+
spans: dict[tuple[int, int], Hit] = {}
|
|
219
|
+
for match in _CUE_ASSIGN.finditer(source):
|
|
220
|
+
key = (match.start(1), match.end(1))
|
|
221
|
+
spans[key] = Hit("password", key[0], key[1], None, "rules.secret")
|
|
222
|
+
for match in _CUE_MENTION.finditer(source):
|
|
223
|
+
value = match.group(1)
|
|
224
|
+
if _SECRET_SHAPE.search(value) is None:
|
|
225
|
+
continue
|
|
226
|
+
key = (match.start(1), match.end(1))
|
|
227
|
+
spans.setdefault(key, Hit("password", key[0], key[1], None, "rules.secret"))
|
|
228
|
+
return [spans[key] for key in sorted(spans)]
|
edgepii/detectors/uie.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ModelScope Siamese-UIE. A hit is public only when the slice equals the surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from edgepii.errors import DetectorLoadError
|
|
8
|
+
from edgepii.protocol import Hit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UieDetector:
|
|
12
|
+
max_chars = 2_000
|
|
13
|
+
overlap_chars = 64
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
repo_id: str,
|
|
18
|
+
revision: str,
|
|
19
|
+
schema: dict[str, str],
|
|
20
|
+
device: str = "cpu",
|
|
21
|
+
runner: Callable | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.repo_id = repo_id
|
|
24
|
+
self.revision = revision
|
|
25
|
+
self.schema = schema
|
|
26
|
+
self.device = device
|
|
27
|
+
self._runner = runner
|
|
28
|
+
if runner is not None:
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
from modelscope.pipelines import pipeline
|
|
32
|
+
from modelscope.utils.constant import Tasks
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise DetectorLoadError(
|
|
35
|
+
f"extra=ms repo={repo_id} revision={revision}"
|
|
36
|
+
) from exc
|
|
37
|
+
try:
|
|
38
|
+
self._pipe = pipeline(Tasks.siamese_uie, model=repo_id, model_revision=revision)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise DetectorLoadError(
|
|
41
|
+
f"extra=ms repo={repo_id} revision={revision}"
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
def locate(self, source: str, labels: list[str]) -> tuple[Hit, ...]:
|
|
45
|
+
if source == "":
|
|
46
|
+
return ()
|
|
47
|
+
allowed = {label.strip() for label in labels}
|
|
48
|
+
schema = {
|
|
49
|
+
name: None
|
|
50
|
+
for name in self.schema.values()
|
|
51
|
+
if name in allowed
|
|
52
|
+
}
|
|
53
|
+
if not schema:
|
|
54
|
+
return ()
|
|
55
|
+
if self._runner is not None:
|
|
56
|
+
raw = self._runner(source, schema)
|
|
57
|
+
else:
|
|
58
|
+
raw = self._pipe(source, schema=schema)
|
|
59
|
+
hits: list[Hit] = []
|
|
60
|
+
for label, items in _pairs(raw):
|
|
61
|
+
if not isinstance(items, list):
|
|
62
|
+
continue
|
|
63
|
+
for item in items:
|
|
64
|
+
hit = _one(source, label, item, f"uie.{self.repo_id}")
|
|
65
|
+
if hit is not None:
|
|
66
|
+
hits.append(hit)
|
|
67
|
+
return tuple(hits)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pairs(raw: object) -> list[tuple[str, object]]:
|
|
71
|
+
if isinstance(raw, dict) and "output" in raw and isinstance(raw["output"], dict):
|
|
72
|
+
raw = raw["output"]
|
|
73
|
+
if isinstance(raw, dict):
|
|
74
|
+
return [(str(key), value) for key, value in raw.items()]
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _one(source: str, label: str, item: object, detector_id: str) -> Hit | None:
|
|
79
|
+
if not isinstance(item, dict):
|
|
80
|
+
return None
|
|
81
|
+
surface = str(item.get("span") or item.get("text") or "")
|
|
82
|
+
start, end = _bounds(item)
|
|
83
|
+
if start is None or end is None or surface == "":
|
|
84
|
+
return None
|
|
85
|
+
if source[start:end] == surface:
|
|
86
|
+
chosen = end
|
|
87
|
+
elif end + 1 <= len(source) and source[start : end + 1] == surface:
|
|
88
|
+
chosen = end + 1
|
|
89
|
+
else:
|
|
90
|
+
return None
|
|
91
|
+
score = item.get("prob", item.get("probs", item.get("score")))
|
|
92
|
+
return Hit(
|
|
93
|
+
raw_label=str(item.get("type") or label),
|
|
94
|
+
start=start,
|
|
95
|
+
end=chosen,
|
|
96
|
+
score=None if score is None else float(score),
|
|
97
|
+
detector_id=detector_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bounds(item: dict) -> tuple[int | None, int | None]:
|
|
102
|
+
if "start" in item and "end" in item:
|
|
103
|
+
return int(item["start"]), int(item["end"])
|
|
104
|
+
offset = item.get("offset")
|
|
105
|
+
if isinstance(offset, (list, tuple)) and len(offset) == 2:
|
|
106
|
+
return int(offset[0]), int(offset[1])
|
|
107
|
+
return None, None
|
edgepii/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Errors for the public locator. Messages do not include the source string."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnknownType(ValueError):
|
|
5
|
+
"""The caller named a type that is not in the active taxonomy."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, name: str) -> None:
|
|
8
|
+
self.name = name
|
|
9
|
+
super().__init__(name)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DetectorLoadError(RuntimeError):
|
|
13
|
+
"""Weights or an extra failed to load. The message has no source text."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OverlapError(ValueError):
|
|
17
|
+
"""redact saw two spans that share a character."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, start: int, end: int) -> None:
|
|
20
|
+
self.start = start
|
|
21
|
+
self.end = end
|
|
22
|
+
super().__init__(f"{start}:{end}")
|
edgepii/eval/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Scoring. Exact-span F1, the pinned public corpora, and the run report.
|
|
2
|
+
|
|
3
|
+
`score` holds the metric. `corpora` holds the pins and the label fold. `runner`
|
|
4
|
+
holds the per-document pass. `cli` is the entry point:
|
|
5
|
+
|
|
6
|
+
python -m edgepii.eval.cli --preset rules --corpus nemotron-pii
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from edgepii.eval.corpora import (
|
|
10
|
+
CorpusError,
|
|
11
|
+
CorpusSpec,
|
|
12
|
+
GoldDiagnostics,
|
|
13
|
+
corpus_keys,
|
|
14
|
+
load_spec,
|
|
15
|
+
)
|
|
16
|
+
from edgepii.eval.runner import RunReport, SliceScore, score_corpus, score_docs
|
|
17
|
+
from edgepii.eval.score import ScoreReport, exact_span_f1, leakage_count
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CorpusError",
|
|
21
|
+
"CorpusSpec",
|
|
22
|
+
"GoldDiagnostics",
|
|
23
|
+
"RunReport",
|
|
24
|
+
"ScoreReport",
|
|
25
|
+
"SliceScore",
|
|
26
|
+
"corpus_keys",
|
|
27
|
+
"exact_span_f1",
|
|
28
|
+
"leakage_count",
|
|
29
|
+
"load_spec",
|
|
30
|
+
"score_corpus",
|
|
31
|
+
"score_docs",
|
|
32
|
+
]
|