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 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"}