edshield 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.
- edshield/__init__.py +150 -0
- edshield/cli.py +63 -0
- edshield/deid.py +241 -0
- edshield/models.jsonl +6 -0
- edshield/ner.py +276 -0
- edshield/policies/coppa.yaml +29 -0
- edshield/policies/ferpa.yaml +31 -0
- edshield/policies/research.yaml +26 -0
- edshield/rules.py +430 -0
- edshield/service.py +73 -0
- edshield/types.py +108 -0
- edshield-0.2.0.dist-info/METADATA +229 -0
- edshield-0.2.0.dist-info/RECORD +17 -0
- edshield-0.2.0.dist-info/WHEEL +5 -0
- edshield-0.2.0.dist-info/entry_points.txt +2 -0
- edshield-0.2.0.dist-info/licenses/LICENSE +201 -0
- edshield-0.2.0.dist-info/top_level.txt +1 -0
edshield/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""edshield — local-first student-privacy layer for AI in education.
|
|
2
|
+
|
|
3
|
+
from edshield import extract_pii, deidentify
|
|
4
|
+
|
|
5
|
+
r = extract_pii("Hi, I'm Maya Chen, my email is maya.c@gmail.com")
|
|
6
|
+
print([(e.label, e.text) for e in r.entities])
|
|
7
|
+
|
|
8
|
+
print(deidentify("...", policy="coppa").deidentified_text)
|
|
9
|
+
|
|
10
|
+
Nothing here uses the network. With `edshield[hf]` installed and a model on
|
|
11
|
+
disk, `extract_pii` adds neural detection of student names; without it the
|
|
12
|
+
rule layer runs alone and says so with a warning.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import warnings
|
|
19
|
+
from typing import Iterable, List, Optional
|
|
20
|
+
|
|
21
|
+
from . import ner
|
|
22
|
+
from .ner import ModelUnavailableError
|
|
23
|
+
from .types import Entity, AnalysisResult, DeidResult, PIILO_LABELS, ALL_LABELS, MODEL_AUTHORITY_LABELS
|
|
24
|
+
from .rules import detect_rules, resolve_overlaps, propagate_names
|
|
25
|
+
from .deid import apply_deidentification, check_no_leak, available_policies, load_policy
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"analyze_text",
|
|
30
|
+
"extract_pii",
|
|
31
|
+
"deidentify",
|
|
32
|
+
"check_no_leak",
|
|
33
|
+
"available_policies",
|
|
34
|
+
"load_policy",
|
|
35
|
+
"Entity",
|
|
36
|
+
"AnalysisResult",
|
|
37
|
+
"DeidResult",
|
|
38
|
+
"PIILO_LABELS",
|
|
39
|
+
"ALL_LABELS",
|
|
40
|
+
"MODEL_AUTHORITY_LABELS",
|
|
41
|
+
"ModelUnavailableError",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _model_entities(
|
|
46
|
+
text: str, model_name: Optional[str], device: str, threshold: float, o_threshold: Optional[float]
|
|
47
|
+
) -> Optional[List[Entity]]:
|
|
48
|
+
"""Model detections, or None when no model ran. An empty list means it
|
|
49
|
+
ran and found nothing.
|
|
50
|
+
|
|
51
|
+
A model the caller asked for by name (or through EDSHIELD_MODEL) must
|
|
52
|
+
work: failure raises ModelUnavailableError. With no model named, the
|
|
53
|
+
default is tried and, if it is not available, the rules run alone and a
|
|
54
|
+
RuntimeWarning says so.
|
|
55
|
+
"""
|
|
56
|
+
if model_name == "rules":
|
|
57
|
+
return None
|
|
58
|
+
requested = model_name or os.environ.get("EDSHIELD_MODEL")
|
|
59
|
+
try:
|
|
60
|
+
return ner.detect_model(
|
|
61
|
+
text, model_name=model_name, device=device, threshold=threshold, o_threshold=o_threshold
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc: # noqa: BLE001
|
|
64
|
+
if requested:
|
|
65
|
+
if isinstance(exc, ModelUnavailableError):
|
|
66
|
+
raise
|
|
67
|
+
raise ModelUnavailableError(f"model '{requested}' failed: {exc}") from exc
|
|
68
|
+
warnings.warn(
|
|
69
|
+
f"edshield: no model available ({exc}); running rules only, which misses most names. "
|
|
70
|
+
"Pass model_name='rules' to choose this on purpose.",
|
|
71
|
+
RuntimeWarning,
|
|
72
|
+
stacklevel=3,
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def analyze_text(
|
|
78
|
+
text: str,
|
|
79
|
+
model_name: Optional[str] = None,
|
|
80
|
+
device: str = "cpu",
|
|
81
|
+
labels: Optional[Iterable[str]] = None,
|
|
82
|
+
threshold: float = 0.5,
|
|
83
|
+
use_rules: bool = True,
|
|
84
|
+
propagate: bool = True,
|
|
85
|
+
model_authority: Optional[Iterable[str]] = MODEL_AUTHORITY_LABELS,
|
|
86
|
+
o_threshold: Optional[float] = None,
|
|
87
|
+
) -> AnalysisResult:
|
|
88
|
+
"""Detect PII entities. Combines rule detectors with the model (if any).
|
|
89
|
+
|
|
90
|
+
When a model ran, the labels in `model_authority` come from the model
|
|
91
|
+
only and the rules are not run for them. Without a model the rules cover
|
|
92
|
+
every label. Pass `model_authority=()` to union both layers on all labels.
|
|
93
|
+
|
|
94
|
+
`o_threshold` switches the model from argmax to the recall-first rule
|
|
95
|
+
"entity whenever P(O) < o_threshold"; see `edshield.ner.decode`.
|
|
96
|
+
"""
|
|
97
|
+
model_ents = _model_entities(text, model_name, device, threshold, o_threshold)
|
|
98
|
+
model_ran = model_ents is not None
|
|
99
|
+
model_ents = model_ents or []
|
|
100
|
+
if labels:
|
|
101
|
+
wanted = set(labels)
|
|
102
|
+
model_ents = [e for e in model_ents if e.label in wanted]
|
|
103
|
+
|
|
104
|
+
ents: List[Entity] = []
|
|
105
|
+
if use_rules:
|
|
106
|
+
rule_labels = list(labels) if labels else list(ALL_LABELS)
|
|
107
|
+
if model_ran:
|
|
108
|
+
deferred = set(model_authority or ())
|
|
109
|
+
rule_labels = [l for l in rule_labels if l not in deferred]
|
|
110
|
+
if rule_labels:
|
|
111
|
+
ents.extend(detect_rules(text, rule_labels))
|
|
112
|
+
ents.extend(model_ents)
|
|
113
|
+
if propagate:
|
|
114
|
+
ents = propagate_names(text, ents)
|
|
115
|
+
used = model_name if model_ran else "rules"
|
|
116
|
+
return AnalysisResult(text=text, entities=resolve_overlaps(ents), model_name=used)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_pii(text: str, model_name: Optional[str] = None, **kw) -> AnalysisResult:
|
|
120
|
+
"""Alias for analyze_text restricted to PII labels."""
|
|
121
|
+
return analyze_text(text, model_name=model_name, labels=kw.pop("labels", ALL_LABELS), **kw)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def deidentify(
|
|
125
|
+
text: str,
|
|
126
|
+
method: Optional[str] = None,
|
|
127
|
+
policy: str = "ferpa",
|
|
128
|
+
model_name: Optional[str] = None,
|
|
129
|
+
device: str = "cpu",
|
|
130
|
+
date_shift_days: Optional[int] = None,
|
|
131
|
+
seed: Optional[int] = None,
|
|
132
|
+
verify: bool = True,
|
|
133
|
+
o_threshold: Optional[float] = None,
|
|
134
|
+
) -> DeidResult:
|
|
135
|
+
"""Detect then transform. `method=None` uses the policy's default method.
|
|
136
|
+
`verify=True` raises if any acted-on value is still present verbatim.
|
|
137
|
+
With `o_threshold`, the policy's confidence floor applies to 1 - P(O)."""
|
|
138
|
+
res = analyze_text(text, model_name=model_name, device=device, o_threshold=o_threshold)
|
|
139
|
+
out = apply_deidentification(
|
|
140
|
+
text, res.entities, method=method, policy=policy, date_shift_days=date_shift_days, seed=seed
|
|
141
|
+
)
|
|
142
|
+
out.audit.update(
|
|
143
|
+
detector=res.model_name or ner.resolve_model_id(None),
|
|
144
|
+
model_authority=sorted(MODEL_AUTHORITY_LABELS) if res.model_name != "rules" else [],
|
|
145
|
+
o_threshold=o_threshold,
|
|
146
|
+
verified=verify,
|
|
147
|
+
)
|
|
148
|
+
if verify and out.leaks:
|
|
149
|
+
raise RuntimeError(f"De-identification leak detected: {out.leaks}")
|
|
150
|
+
return out
|
edshield/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
edshield extract essay.txt
|
|
4
|
+
edshield redact essay.txt --policy coppa --method mask
|
|
5
|
+
edshield serve --port 8080
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read(path: str) -> str:
|
|
16
|
+
return sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None) -> int:
|
|
20
|
+
p = argparse.ArgumentParser(prog="edshield", description="Local-first student-privacy layer")
|
|
21
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
|
|
23
|
+
ex = sub.add_parser("extract", help="Detect PII and print entities as JSON")
|
|
24
|
+
ex.add_argument("path", help="file path or - for stdin")
|
|
25
|
+
ex.add_argument("--model", default=None, help="model short name, HF id, local dir, or 'rules'")
|
|
26
|
+
ex.add_argument("--device", default="cpu")
|
|
27
|
+
|
|
28
|
+
rd = sub.add_parser("redact", help="De-identify a document under a policy")
|
|
29
|
+
rd.add_argument("path")
|
|
30
|
+
rd.add_argument("--policy", default="ferpa")
|
|
31
|
+
rd.add_argument("--method", default=None, choices=["mask", "replace", "hash", "shift_dates"],
|
|
32
|
+
help="default: the policy's own method")
|
|
33
|
+
rd.add_argument("--model", default=None)
|
|
34
|
+
rd.add_argument("--device", default="cpu")
|
|
35
|
+
rd.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
|
36
|
+
|
|
37
|
+
sv = sub.add_parser("serve", help="Run the REST service")
|
|
38
|
+
sv.add_argument("--host", default="127.0.0.1")
|
|
39
|
+
sv.add_argument("--port", type=int, default=8080)
|
|
40
|
+
|
|
41
|
+
pl = sub.add_parser("policies", help="List built-in policies")
|
|
42
|
+
|
|
43
|
+
a = p.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
if a.cmd == "extract":
|
|
46
|
+
from edshield import extract_pii
|
|
47
|
+
r = extract_pii(_read(a.path), model_name=a.model, device=a.device)
|
|
48
|
+
print(json.dumps(r.to_dict(), indent=2))
|
|
49
|
+
elif a.cmd == "redact":
|
|
50
|
+
from edshield import deidentify
|
|
51
|
+
r = deidentify(_read(a.path), method=a.method, policy=a.policy, model_name=a.model, device=a.device)
|
|
52
|
+
print(json.dumps(r.to_dict(), indent=2) if a.json else r.deidentified_text)
|
|
53
|
+
elif a.cmd == "serve":
|
|
54
|
+
import uvicorn
|
|
55
|
+
uvicorn.run("edshield.service:app", host=a.host, port=a.port)
|
|
56
|
+
elif a.cmd == "policies":
|
|
57
|
+
from edshield import available_policies
|
|
58
|
+
print("\n".join(available_policies()))
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|
edshield/deid.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""De-identification: turn detected entities into masked, surrogate, hashed
|
|
2
|
+
or date-shifted text under a named policy.
|
|
3
|
+
|
|
4
|
+
Policies live in edshield/policies/*.yaml and say which labels to act on,
|
|
5
|
+
which method to use per label, and the minimum confidence to honour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from .types import Entity, DeidResult
|
|
21
|
+
|
|
22
|
+
POLICY_DIR = Path(__file__).resolve().parent / "policies"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _policy_path(name_or_path: str) -> Path:
|
|
26
|
+
p = Path(name_or_path)
|
|
27
|
+
if not p.exists():
|
|
28
|
+
p = POLICY_DIR / f"{name_or_path}.yaml"
|
|
29
|
+
if not p.exists():
|
|
30
|
+
raise FileNotFoundError(f"Unknown policy '{name_or_path}'. Built-ins: {available_policies()}")
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_policy(name_or_path: str) -> dict:
|
|
35
|
+
with open(_policy_path(name_or_path), encoding="utf-8") as fh:
|
|
36
|
+
return yaml.safe_load(fh)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def policy_fingerprint(name_or_path: str) -> str:
|
|
40
|
+
"""SHA-256 of the policy file, so an audit record pins the exact rules applied."""
|
|
41
|
+
text = _policy_path(name_or_path).read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
42
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def available_policies() -> List[str]:
|
|
46
|
+
return sorted(p.stem for p in POLICY_DIR.glob("*.yaml"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- Surrogates -------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class SurrogateFactory:
|
|
52
|
+
"""Consistent fake replacements: the same original value always maps to
|
|
53
|
+
the same surrogate within one document (or one session if reused)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, seed: Optional[int] = None, locale: str = "en_US"):
|
|
56
|
+
try:
|
|
57
|
+
from faker import Faker # noqa: WPS433
|
|
58
|
+
except ImportError as exc: # pragma: no cover
|
|
59
|
+
raise RuntimeError("`replace` needs `pip install faker`") from exc
|
|
60
|
+
self.fake = Faker(locale)
|
|
61
|
+
if seed is not None:
|
|
62
|
+
self.fake.seed_instance(seed)
|
|
63
|
+
self.rng = random.Random(seed)
|
|
64
|
+
self._memo: Dict[str, str] = {}
|
|
65
|
+
|
|
66
|
+
def for_entity(self, ent: Entity) -> str:
|
|
67
|
+
key = f"{ent.label}::{ent.text.strip().lower()}"
|
|
68
|
+
if key in self._memo:
|
|
69
|
+
return self._memo[key]
|
|
70
|
+
f = self.fake
|
|
71
|
+
n_words = len(ent.text.split())
|
|
72
|
+
if ent.label in ("NAME_STUDENT", "NAME_RELATED"):
|
|
73
|
+
val = f.first_name() if n_words == 1 else f"{f.first_name()} {f.last_name()}"
|
|
74
|
+
elif ent.label == "EMAIL":
|
|
75
|
+
val = f.free_email()
|
|
76
|
+
elif ent.label == "USERNAME":
|
|
77
|
+
val = f.user_name()
|
|
78
|
+
elif ent.label == "ID_NUM":
|
|
79
|
+
digits = sum(ch.isdigit() for ch in ent.text)
|
|
80
|
+
val = "".join(self.rng.choice("0123456789") for _ in range(max(6, digits)))
|
|
81
|
+
elif ent.label == "PHONE_NUM":
|
|
82
|
+
val = f.numerify("555-###-####")
|
|
83
|
+
elif ent.label == "URL_PERSONAL":
|
|
84
|
+
val = f"https://example.com/{f.user_name()}"
|
|
85
|
+
elif ent.label == "STREET_ADDRESS":
|
|
86
|
+
val = f.street_address()
|
|
87
|
+
elif ent.label == "SSN":
|
|
88
|
+
val = f.numerify("900-##-####")
|
|
89
|
+
elif ent.label == "DATE":
|
|
90
|
+
val = f.date(pattern="%m/%d/%Y")
|
|
91
|
+
else:
|
|
92
|
+
val = f"[{ent.label}]"
|
|
93
|
+
self._memo[key] = val
|
|
94
|
+
return val
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _hash(value: str, salt: str, length: int = 10) -> str:
|
|
98
|
+
return hashlib.sha256((salt + value).encode("utf-8")).hexdigest()[:length]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_DATE_FORMATS = ["%m/%d/%Y", "%m-%d-%Y", "%Y-%m-%d", "%B %d, %Y", "%b %d, %Y", "%B %d %Y"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _shift_date(value: str, days: int) -> str:
|
|
105
|
+
for fmt in _DATE_FORMATS:
|
|
106
|
+
try:
|
|
107
|
+
d = datetime.strptime(value.replace("Sept", "Sep"), fmt)
|
|
108
|
+
return (d + timedelta(days=days)).strftime(fmt)
|
|
109
|
+
except ValueError:
|
|
110
|
+
continue
|
|
111
|
+
return "[DATE]"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- Main entry -------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def apply_deidentification(
|
|
117
|
+
text: str,
|
|
118
|
+
entities: List[Entity],
|
|
119
|
+
method: Optional[str] = None,
|
|
120
|
+
policy: str = "ferpa",
|
|
121
|
+
date_shift_days: Optional[int] = None,
|
|
122
|
+
seed: Optional[int] = None,
|
|
123
|
+
salt: Optional[str] = None,
|
|
124
|
+
) -> DeidResult:
|
|
125
|
+
pol = load_policy(policy)
|
|
126
|
+
rules: dict = pol.get("labels", {})
|
|
127
|
+
# The caller's method applies only if given and the policy allows it.
|
|
128
|
+
if method is None or not pol.get("allow_method_override", True):
|
|
129
|
+
method = pol.get("default_method", "mask")
|
|
130
|
+
min_conf = float(pol.get("min_confidence", 0.0))
|
|
131
|
+
salt = salt or os.environ.get("EDSHIELD_HASH_SALT", "edshield")
|
|
132
|
+
if date_shift_days == 0:
|
|
133
|
+
raise ValueError("date_shift_days=0 would leave every date unchanged; pass a non-zero shift or None")
|
|
134
|
+
if date_shift_days is None:
|
|
135
|
+
date_shift_days = int(pol.get("date_shift_days", 0))
|
|
136
|
+
if not date_shift_days:
|
|
137
|
+
rng = random.Random(seed)
|
|
138
|
+
date_shift_days = rng.choice([-1, 1]) * rng.randint(1, 365)
|
|
139
|
+
|
|
140
|
+
factory: Optional[SurrogateFactory] = None
|
|
141
|
+
replacements: Dict[str, str] = {}
|
|
142
|
+
|
|
143
|
+
def render(ent: Entity) -> str:
|
|
144
|
+
nonlocal factory
|
|
145
|
+
cfg = rules.get(ent.label, {})
|
|
146
|
+
# Precedence: an explicit per-label method in the policy always wins;
|
|
147
|
+
# otherwise the method resolved above.
|
|
148
|
+
if isinstance(cfg, dict) and cfg.get("method"):
|
|
149
|
+
m = cfg["method"]
|
|
150
|
+
else:
|
|
151
|
+
m = method
|
|
152
|
+
if m == "mask":
|
|
153
|
+
out = cfg.get("mask", f"[{ent.label}]") if isinstance(cfg, dict) else f"[{ent.label}]"
|
|
154
|
+
elif m == "replace":
|
|
155
|
+
if factory is None:
|
|
156
|
+
factory = SurrogateFactory(seed=seed)
|
|
157
|
+
out = factory.for_entity(ent)
|
|
158
|
+
elif m == "hash":
|
|
159
|
+
out = _hash(ent.text, salt)
|
|
160
|
+
elif m == "shift_dates":
|
|
161
|
+
out = _shift_date(ent.text, date_shift_days) if ent.label == "DATE" else f"[{ent.label}]"
|
|
162
|
+
elif m == "keep":
|
|
163
|
+
out = ent.text
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(f"Unknown method '{m}'")
|
|
166
|
+
replacements[ent.text] = out
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
# Filter by policy: label must be enabled and confidence above threshold.
|
|
170
|
+
acted: List[Entity] = []
|
|
171
|
+
for e in entities:
|
|
172
|
+
cfg = rules.get(e.label)
|
|
173
|
+
enabled = cfg is not None and (cfg is True or (isinstance(cfg, dict) and cfg.get("enabled", True)))
|
|
174
|
+
label_min = float(cfg.get("min_confidence", min_conf)) if isinstance(cfg, dict) else min_conf
|
|
175
|
+
if enabled and e.confidence >= label_min:
|
|
176
|
+
acted.append(e)
|
|
177
|
+
|
|
178
|
+
acted.sort(key=lambda e: e.start)
|
|
179
|
+
out_parts: List[str] = []
|
|
180
|
+
untouched: List[str] = []
|
|
181
|
+
unchanged: List[str] = []
|
|
182
|
+
cursor = 0
|
|
183
|
+
for e in acted:
|
|
184
|
+
untouched.append(text[cursor:e.start])
|
|
185
|
+
out_parts.append(untouched[-1])
|
|
186
|
+
rendered = render(e)
|
|
187
|
+
if rendered == e.text and e.text not in unchanged:
|
|
188
|
+
unchanged.append(e.text)
|
|
189
|
+
out_parts.append(rendered)
|
|
190
|
+
cursor = e.end
|
|
191
|
+
untouched.append(text[cursor:])
|
|
192
|
+
out_parts.append(untouched[-1])
|
|
193
|
+
# Verify against the text that was left in place, not the output, so a
|
|
194
|
+
# surrogate or shifted date that happens to equal another original value
|
|
195
|
+
# is not mistaken for a leak.
|
|
196
|
+
leaks = unchanged + [v for v in check_no_leak("\n".join(untouched), acted) if v not in unchanged]
|
|
197
|
+
output = "".join(out_parts)
|
|
198
|
+
counts: Dict[str, int] = {}
|
|
199
|
+
for e in acted:
|
|
200
|
+
counts[e.label] = counts.get(e.label, 0) + 1
|
|
201
|
+
audit = {
|
|
202
|
+
"edshield_version": _version(),
|
|
203
|
+
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
204
|
+
"policy": pol.get("name", policy),
|
|
205
|
+
"policy_sha256": policy_fingerprint(policy),
|
|
206
|
+
"method": method,
|
|
207
|
+
"entities_acted_on": len(acted),
|
|
208
|
+
"entities_below_policy_threshold": len(entities) - len(acted),
|
|
209
|
+
"by_label": dict(sorted(counts.items())),
|
|
210
|
+
"input_chars": len(text),
|
|
211
|
+
"output_sha256": hashlib.sha256(output.encode("utf-8")).hexdigest(),
|
|
212
|
+
"leaks_found": len(leaks),
|
|
213
|
+
}
|
|
214
|
+
return DeidResult(
|
|
215
|
+
original_text=text,
|
|
216
|
+
deidentified_text=output,
|
|
217
|
+
entities=acted,
|
|
218
|
+
method=method,
|
|
219
|
+
policy=policy,
|
|
220
|
+
replacements=replacements,
|
|
221
|
+
leaks=leaks,
|
|
222
|
+
audit=audit,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _version() -> str:
|
|
227
|
+
from . import __version__ # noqa: WPS433 - the package imports this module
|
|
228
|
+
return __version__
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def check_no_leak(deidentified_text: str, entities: List[Entity]) -> List[str]:
|
|
232
|
+
"""Return original entity strings that still appear verbatim, as whole
|
|
233
|
+
words, in the text ("Ann" inside "Annual" is not a leak)."""
|
|
234
|
+
leaks = []
|
|
235
|
+
for e in entities:
|
|
236
|
+
value = e.text.strip()
|
|
237
|
+
if len(value) < 3 or value in leaks:
|
|
238
|
+
continue
|
|
239
|
+
if re.search(r"(?<!\w)" + re.escape(value) + r"(?!\w)", deidentified_text):
|
|
240
|
+
leaks.append(value)
|
|
241
|
+
return leaks
|
edshield/models.jsonl
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# One JSON object per line. `status`: planned | training | available.
|
|
2
|
+
# "available" means published on the Hugging Face Hub as `hf_id`. `local_path`, relative to a clone of the repository, is used first when it exists.
|
|
3
|
+
{"name": "piilo_deberta_small", "hf_id": "edshield/piilo-deberta-v3-small", "base": "microsoft/deberta-v3-small", "task": "token-classification", "labels": ["NAME_STUDENT","EMAIL","USERNAME","ID_NUM","PHONE_NUM","URL_PERSONAL","STREET_ADDRESS"], "params": "142M", "train_data": "PIILO/CRAPII (CC BY 4.0)", "status": "available", "local_path": "models/piilo-deberta-v3-small-v2", "eval": "eval/results/deberta_small_v2_validation.json"}
|
|
4
|
+
{"name": "piilo_deberta_base", "hf_id": "edshield/piilo-deberta-v3-base", "base": "microsoft/deberta-v3-base", "task": "token-classification", "labels": ["NAME_STUDENT","EMAIL","USERNAME","ID_NUM","PHONE_NUM","URL_PERSONAL","STREET_ADDRESS"], "params": "184M", "train_data": "PIILO/CRAPII (CC BY 4.0)", "status": "planned", "local_path": "models/piilo-deberta-v3-base"}
|
|
5
|
+
{"name": "piilo_modernbert_base", "hf_id": "edshield/piilo-modernbert-base", "base": "answerdotai/ModernBERT-base", "task": "token-classification", "labels": ["NAME_STUDENT","EMAIL","USERNAME","ID_NUM","PHONE_NUM","URL_PERSONAL","STREET_ADDRESS"], "params": "149M", "train_data": "PIILO/CRAPII (CC BY 4.0)", "status": "planned", "local_path": "models/piilo-modernbert-base"}
|
|
6
|
+
{"name": "piilo_deberta_small_onnx", "hf_id": "edshield/piilo-deberta-v3-small-onnx", "base": "edshield/piilo-deberta-v3-small", "task": "token-classification", "runtime": ["onnxruntime", "transformers.js"], "quant": ["fp32","int8"], "status": "available", "local_path": "demo/models/piilo-deberta-v3-small-onnx"}
|