deidkit 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.
Files changed (45) hide show
  1. deidkit/__init__.py +74 -0
  2. deidkit/__main__.py +6 -0
  3. deidkit/api.py +316 -0
  4. deidkit/cli.py +399 -0
  5. deidkit/config.py +285 -0
  6. deidkit/data/NOTICES.md +25 -0
  7. deidkit/data/context_triggers_en.txt +17 -0
  8. deidkit/data/context_triggers_es.txt +36 -0
  9. deidkit/data/en_given_names.txt +203 -0
  10. deidkit/data/en_surnames.txt +118 -0
  11. deidkit/data/es_given_names.txt +273 -0
  12. deidkit/data/es_surnames.txt +168 -0
  13. deidkit/data/honorifics_en.txt +22 -0
  14. deidkit/data/honorifics_es.txt +35 -0
  15. deidkit/data/medical_stoplist_en.txt +345 -0
  16. deidkit/data/medical_stoplist_es.txt +499 -0
  17. deidkit/data/medical_vocab.txt +21445 -0
  18. deidkit/dates.py +229 -0
  19. deidkit/detect/__init__.py +19 -0
  20. deidkit/detect/checksums.py +160 -0
  21. deidkit/detect/context.py +137 -0
  22. deidkit/detect/gazetteer.py +158 -0
  23. deidkit/detect/patterns.py +147 -0
  24. deidkit/detect/pipeline.py +234 -0
  25. deidkit/detect/spacy_ner.py +53 -0
  26. deidkit/detect/types.py +53 -0
  27. deidkit/engine.py +644 -0
  28. deidkit/generators/__init__.py +4 -0
  29. deidkit/generators/names.py +111 -0
  30. deidkit/generators/surrogates.py +76 -0
  31. deidkit/io.py +184 -0
  32. deidkit/learn.py +83 -0
  33. deidkit/mapping.py +108 -0
  34. deidkit/report.py +219 -0
  35. deidkit/resources.py +100 -0
  36. deidkit/schema.py +234 -0
  37. deidkit/secret.py +77 -0
  38. deidkit/textnorm.py +66 -0
  39. deidkit/version.py +3 -0
  40. deidkit-0.1.0.dist-info/METADATA +768 -0
  41. deidkit-0.1.0.dist-info/RECORD +45 -0
  42. deidkit-0.1.0.dist-info/WHEEL +5 -0
  43. deidkit-0.1.0.dist-info/entry_points.txt +2 -0
  44. deidkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. deidkit-0.1.0.dist-info/top_level.txt +1 -0
deidkit/cli.py ADDED
@@ -0,0 +1,399 @@
1
+ """Command-line interface.
2
+
3
+ deidkit init-secret --out secret.key
4
+ deidkit plan --input data/ --schema dict.json
5
+ deidkit run --input data/ --out out/ --schema dict.json \
6
+ --secret-file secret.key --report audit.xlsx --ignore blood_type
7
+ deidkit scan --text "Paciente Juan Pérez, CC 1.234.567.890"
8
+
9
+ See ``deidkit <command> --help`` for all options.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from typing import List, Optional
18
+
19
+ from . import io
20
+ from .config import (
21
+ ALL_MODES,
22
+ FieldRule,
23
+ Policy,
24
+ SYNTHETIC_NAME,
25
+ normalize_only_ignore,
26
+ )
27
+ from .engine import Deidentifier
28
+ from .schema import load_dictionary
29
+ from .secret import ENV_VAR, generate_secret
30
+ from .version import __version__
31
+
32
+
33
+ def _split_csv(value: Optional[str]) -> Optional[List[str]]:
34
+ if not value:
35
+ return None
36
+ return [v.strip() for v in value.split(",") if v.strip()]
37
+
38
+
39
+ def build_policy(args) -> Policy:
40
+ policy = Policy.from_yaml(args.policy) if getattr(args, "policy", None) else Policy()
41
+ if getattr(args, "lang", None):
42
+ policy.lang = args.lang
43
+ if getattr(args, "mode", None):
44
+ policy.mode = args.mode
45
+ if getattr(args, "name_style", None):
46
+ policy.name_style = args.name_style
47
+ if getattr(args, "entity_key", None):
48
+ policy.entity_key = args.entity_key
49
+ if getattr(args, "date_max_days", None) is not None:
50
+ policy.date_max_days = args.date_max_days
51
+ if getattr(args, "spacy_model", None):
52
+ policy.spacy_model = args.spacy_model
53
+ if getattr(args, "names_token", False):
54
+ policy.names_synthetic = False
55
+ if getattr(args, "no_date_text", False):
56
+ policy.shift_dates_in_text = False
57
+ if getattr(args, "no_medical_vocab", False):
58
+ policy.use_medical_vocab = False
59
+ frag = normalize_only_ignore(_split_csv(getattr(args, "only", None)),
60
+ _split_csv(getattr(args, "ignore", None)))
61
+ if "only" in frag:
62
+ policy.only = frag["only"]
63
+ if "ignore" in frag:
64
+ policy.ignore |= frag["ignore"]
65
+ # --name-column NAME can be repeated to force synthetic-name treatment
66
+ for col in getattr(args, "name_column", None) or []:
67
+ policy.rules.insert(0, FieldRule(column=col, strategy=SYNTHETIC_NAME))
68
+ policy.known_name_columns += getattr(args, "known_name_column", None) or []
69
+ policy.known_id_columns += getattr(args, "known_id_column", None) or []
70
+ policy.extra_known_names += getattr(args, "known_name", None) or []
71
+ policy.id_checksums += _split_csv(getattr(args, "id_checksums", None)) or []
72
+ policy.extra_name_files += getattr(args, "extra_name_file", None) or []
73
+ policy.extra_stoplist_files += getattr(args, "extra_stoplist_file", None) or []
74
+ learn_dir = getattr(args, "learn_dir", None)
75
+ if learn_dir:
76
+ from .learn import learned_files
77
+
78
+ name_files, stop_files = learned_files(learn_dir)
79
+ policy.extra_name_files += name_files
80
+ policy.extra_stoplist_files += stop_files
81
+ policy.__post_init__()
82
+ return policy
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ def cmd_init_secret(args) -> int:
87
+ secret = generate_secret()
88
+ if args.out:
89
+ with open(args.out, "w", encoding="utf-8") as fh:
90
+ fh.write(secret + "\n")
91
+ os.chmod(args.out, 0o600)
92
+ print(f"Wrote a new secret to {args.out} (chmod 600).")
93
+ print("Keep it private and stored separately from the delivered data.")
94
+ else:
95
+ print(secret)
96
+ return 0
97
+
98
+
99
+ def cmd_plan(args) -> int:
100
+ policy = build_policy(args)
101
+ dict_index = load_dictionary(args.schema) if args.schema else {}
102
+ deid = Deidentifier(policy, secret="plan-only-not-used", dict_index=dict_index)
103
+ input_path = args.input or args.input_flag
104
+ if not input_path:
105
+ print("error: give an input path, e.g. `deidkit plan data/`", file=sys.stderr)
106
+ return 2
107
+ tables = io.read_tables(input_path)
108
+ print(f"{'table':<16} {'column':<32} {'strategy':<16} decided_by")
109
+ print("-" * 88)
110
+ for name, df in tables.items():
111
+ plan = deid.plan_dataframe(df, name)
112
+ for col, item in plan.items():
113
+ flag = "" if item.strategy == "passthrough" else " *"
114
+ print(f"{name:<16} {col:<32} {item.strategy:<16} {item.source}{flag}")
115
+ return 0
116
+
117
+
118
+ def cmd_scan(args) -> int:
119
+ policy = build_policy(args)
120
+ deid = Deidentifier(policy, secret="scan-only")
121
+ if args.text:
122
+ text = args.text
123
+ elif args.file:
124
+ with open(args.file, encoding="utf-8") as fh:
125
+ text = fh.read()
126
+ else:
127
+ text = sys.stdin.read()
128
+ dets = deid.detector.detect(text)
129
+ print(f"NER: {deid.detector.ner_status}")
130
+ print(f"{'accepted':<9} {'type':<12} {'conf':<5} {'detectors':<22} text")
131
+ print("-" * 80)
132
+ for d in sorted(dets, key=lambda x: x.start):
133
+ mark = "REPLACE" if d.accepted else "review"
134
+ print(f"{mark:<9} {d.entity_type:<12} {d.confidence:<5.2f} "
135
+ f"{'+'.join(sorted(d.methods)):<22} {d.text!r}")
136
+ return 0
137
+
138
+
139
+ def cmd_map(args) -> int:
140
+ """Surface the private value->surrogate mapping (the re-identification key)."""
141
+ from .mapping import Mapping
142
+
143
+ if not os.path.exists(args.mapping):
144
+ print(f"error: mapping file not found: {args.mapping}", file=sys.stderr)
145
+ return 2
146
+ try:
147
+ mp = Mapping.load(args.mapping)
148
+ except (ValueError, OSError) as exc:
149
+ print(f"error: could not read mapping {args.mapping!r}: {exc}", file=sys.stderr)
150
+ return 2
151
+ rows = []
152
+ for etype in mp.entity_types():
153
+ for original, surrogate in mp.items(etype):
154
+ rows.append((etype, original, surrogate))
155
+ if args.out:
156
+ import csv
157
+
158
+ with open(args.out, "w", newline="", encoding="utf-8") as fh:
159
+ w = csv.writer(fh)
160
+ w.writerow(["entity_type", "original", "surrogate"])
161
+ w.writerows(rows)
162
+ print(f"PRIVATE mapping ({len(rows)} entries) -> {args.out}")
163
+ print("This is the re-identification key. Keep it out of any deliverable.")
164
+ else:
165
+ print("# PRIVATE re-identification key — do not share with the data")
166
+ print(f"{'entity_type':<16}{'original':<32}surrogate")
167
+ for etype, original, surrogate in rows:
168
+ print(f"{etype:<16}{str(original):<32}{surrogate}")
169
+ print(f"\n{len(rows)} entries")
170
+ return 0
171
+
172
+
173
+ def _default_out(input_path: str) -> str:
174
+ p = input_path.rstrip("/\\")
175
+ if os.path.isdir(p):
176
+ return p + "_deid"
177
+ if any(ch in p for ch in "*?["):
178
+ return "deid-output"
179
+ return os.path.splitext(p)[0] + "_deid"
180
+
181
+
182
+ def cmd_learn(args) -> int:
183
+ """Fold a human-edited review.csv into the learned dictionaries."""
184
+ from .learn import learn_from_review
185
+
186
+ if not os.path.exists(args.review):
187
+ print(f"error: review file not found: {args.review}", file=sys.stderr)
188
+ return 2
189
+ n_names, n_stop = learn_from_review(args.review, args.learn_dir)
190
+ print(f"learned {n_names} new name(s) and {n_stop} new stopword(s) -> {args.learn_dir}/")
191
+ print("Re-run with `--learn-dir " + args.learn_dir + "` to apply them.")
192
+ return 0
193
+
194
+
195
+ def cmd_run_db(args) -> int:
196
+ from .api import deidentify_database
197
+
198
+ try:
199
+ result = deidentify_database(
200
+ args.source, args.out_db,
201
+ tables=_split_csv(args.tables),
202
+ schema=args.schema,
203
+ out_schema=args.out_schema,
204
+ if_exists=args.if_exists,
205
+ policy=build_policy(args),
206
+ secret=args.secret,
207
+ secret_file=args.secret_file,
208
+ mapping=args.mapping,
209
+ private_dir=args.private_dir,
210
+ )
211
+ except (ValueError, TypeError, ImportError) as exc:
212
+ print(f"error: {exc}", file=sys.stderr)
213
+ return 2
214
+
215
+ summ = result.summary
216
+ for name, df in result.tables.items():
217
+ print(f" {name:<20} {len(df):,} rows -> output DB")
218
+ print(f"\n OUTPUT DB -> {args.out_db} (de-identified, same structure)")
219
+ print(f" KEEP PRIVATE-> {result.private_dir}/ (secret + mapping + audit — NEVER share)")
220
+ print(f" cells changed : {summ['cells_changed']:,} | to review: {summ['review_detections']:,}")
221
+ return 0
222
+
223
+
224
+ def cmd_run(args) -> int:
225
+ from .api import deidentify
226
+
227
+ input_path = args.input or args.input_flag
228
+ if not input_path:
229
+ print("error: give an input path, e.g. `deidkit run data/`", file=sys.stderr)
230
+ return 2
231
+ out = args.out or args.out_flag or _default_out(input_path)
232
+
233
+ try:
234
+ result = deidentify(
235
+ input_path,
236
+ out=out,
237
+ policy=build_policy(args),
238
+ schema=args.schema,
239
+ secret=args.secret,
240
+ secret_file=args.secret_file,
241
+ mapping=args.mapping,
242
+ report=args.report,
243
+ fmt=args.format,
244
+ quiet=True,
245
+ )
246
+ except (FileNotFoundError, ValueError, TypeError, ImportError) as exc:
247
+ print(f"error: {exc}", file=sys.stderr)
248
+ return 2
249
+
250
+ summ = result.summary
251
+ for name, df in result.tables.items():
252
+ print(f" {name:<16} {len(df):,} rows")
253
+ print(f"\n SHIP THIS -> {result.out}/ (de-identified data only)")
254
+ print(f" KEEP PRIVATE-> {result.private_dir}/ (secret + mapping + audit — NEVER share)")
255
+ print(f" audit : {os.path.basename(result.report_path or '')}")
256
+ print(f" mapping : {os.path.basename(result.mapping_path or '')} (re-identification key)")
257
+ print(f" review : review.csv (mark y/n, then `deidkit learn`)")
258
+ if result.secret:
259
+ print(f" secret : deidkit-secret.key (KEEP — needed to re-run consistently)")
260
+ print(f"\n cells changed : {summ['cells_changed']:,}")
261
+ print(f" to review : {summ['review_detections']:,}")
262
+ print(f" detection : {summ['ner_status']} (deterministic rules; no AI by default)")
263
+ for etype, cnt in sorted(summ["entity_counts"].items(), key=lambda x: -x[1]):
264
+ print(f" {etype:<14} {cnt:,}")
265
+ return 0
266
+
267
+
268
+ # --------------------------------------------------------------------------- #
269
+ def _add_policy_args(p):
270
+ p.add_argument("--policy", help="YAML policy file (CLI flags override it).")
271
+ p.add_argument("--lang", choices=["es", "en", "multi"],
272
+ help="Language(s) for names/detection ('multi' = ES+EN).")
273
+ p.add_argument("--mode", choices=sorted(ALL_MODES),
274
+ help="Free-text detection aggressiveness (default conservative).")
275
+ p.add_argument("--name-style", choices=["tagged", "placeholder", "token", "realistic"],
276
+ help="How fake names look (default tagged = obviously synthetic).")
277
+ p.add_argument("--only", help="Comma list: transform ONLY these columns.")
278
+ p.add_argument("--ignore", help="Comma list: never transform these columns.")
279
+ p.add_argument("--name-column", action="append",
280
+ help="Force a column to synthetic-name treatment (repeatable).")
281
+ p.add_argument("--entity-key", help="Column that groups date shifts (default patient_id).")
282
+ p.add_argument("--date-max-days", type=int, help="Date shift window in days (default 365).")
283
+ p.add_argument("--names-token", action="store_true",
284
+ help="Use [PERSON_x] tokens instead of realistic synthetic names.")
285
+ p.add_argument("--no-date-text", action="store_true",
286
+ help="Do not shift date strings found inside free text.")
287
+ p.add_argument("--spacy-model",
288
+ help="Enable spaCy NER stage with this model (e.g. es_core_news_lg).")
289
+ p.add_argument("--known-name-column", action="append",
290
+ help="Column whose value is a known person name to force-scrub "
291
+ "from that row's free text (repeatable).")
292
+ p.add_argument("--known-id-column", action="append",
293
+ help="Column whose value is a known identifier to force-scrub "
294
+ "from that row's free text (repeatable).")
295
+ p.add_argument("--known-name", action="append",
296
+ help="A known name (e.g. clinic staff) to scrub from ALL free text (repeatable).")
297
+ p.add_argument("--id-checksums",
298
+ help="Comma list of checksum-validated ID types to detect (bare, no cue): "
299
+ "CPF,CNPJ,CNS (Brazil), DNI,NIE (Spain), NHS (UK), CURP,RFC (Mexico), SSN, or ALL.")
300
+ p.add_argument("--extra-name-file", action="append",
301
+ help="Extra gazetteer file (one name per line) to merge (repeatable).")
302
+ p.add_argument("--extra-stoplist-file", action="append",
303
+ help="Extra stoplist file (words never treated as names) (repeatable).")
304
+ p.add_argument("--learn-dir",
305
+ help="Directory of learned dictionaries from `deidkit learn`; "
306
+ "auto-loads learned_names.txt + learned_stoplist.txt if present.")
307
+ p.add_argument("--no-medical-vocab", action="store_true",
308
+ help="Do not load the bundled medical safe-vocabulary (drug/terminology terms).")
309
+
310
+
311
+ def build_parser() -> argparse.ArgumentParser:
312
+ parser = argparse.ArgumentParser(
313
+ prog="deidkit",
314
+ description="Schema-driven pseudonymization for clinical / tabular datasets.",
315
+ )
316
+ parser.add_argument("--version", action="version", version=f"deidkit {__version__}")
317
+ sub = parser.add_subparsers(dest="command", required=True)
318
+
319
+ ps = sub.add_parser("init-secret", help="Generate a secret key.")
320
+ ps.add_argument("--out", help="Write the secret to this file (else print it).")
321
+ ps.set_defaults(func=cmd_init_secret)
322
+
323
+ pp = sub.add_parser("plan", help="Show the resolved field plan without transforming.")
324
+ pp.add_argument("input", nargs="?", help="File, glob, or directory of tables.")
325
+ pp.add_argument("--input", dest="input_flag", help=argparse.SUPPRESS)
326
+ pp.add_argument("--schema", help="Data-dictionary JSON.")
327
+ _add_policy_args(pp)
328
+ pp.set_defaults(func=cmd_plan)
329
+
330
+ pr = sub.add_parser(
331
+ "run", help="De-identify a dataset.",
332
+ description="De-identify a dataset. Simplest form: `deidkit run data/` "
333
+ "(auto output dir, auto secret, auto audit report).",
334
+ )
335
+ pr.add_argument("input", nargs="?", help="File, glob, or directory of tables.")
336
+ pr.add_argument("out", nargs="?", help="Output directory (default: <input>_deid).")
337
+ pr.add_argument("--input", dest="input_flag", help=argparse.SUPPRESS)
338
+ pr.add_argument("--out", dest="out_flag", help=argparse.SUPPRESS)
339
+ pr.add_argument("--schema", help="Data-dictionary JSON (auto-detected if omitted).")
340
+ pr.add_argument("--secret", help="Secret string (else --secret-file, $%s, or auto)." % ENV_VAR)
341
+ pr.add_argument("--secret-file", help=f"File holding the secret (or set {ENV_VAR}).")
342
+ pr.add_argument("--mapping", help="Private mapping JSON (default <out>/mapping.private.json).")
343
+ pr.add_argument("--report", help="Audit workbook path (default <out>/deid_audit.xlsx).")
344
+ pr.add_argument("--format", choices=["csv", "parquet", "tsv", "json"],
345
+ help="Output format (default csv).")
346
+ _add_policy_args(pr)
347
+ pr.set_defaults(func=cmd_run)
348
+
349
+ pl = sub.add_parser("learn", help="Fold a reviewed review.csv into learned dictionaries.")
350
+ pl.add_argument("--review", required=True, help="The human-edited review.csv (y/n column).")
351
+ pl.add_argument("--learn-dir", required=True, help="Directory to write/append learned dicts.")
352
+ pl.set_defaults(func=cmd_learn)
353
+
354
+ pm = sub.add_parser("map", help="Dump the private value->surrogate mapping.")
355
+ pm.add_argument("--mapping", required=True, help="Mapping JSON produced by `run`.")
356
+ pm.add_argument("--out", help="Write to this CSV (else print).")
357
+ pm.set_defaults(func=cmd_map)
358
+
359
+ pd_ = sub.add_parser(
360
+ "run-db", help="De-identify a SQL database into a SEPARATE output DB.",
361
+ description="Reads the source DB (read-only) and writes the de-identified result "
362
+ "to a SEPARATE output DB that mirrors the source structure. The output "
363
+ "database must be provisioned by YOU (an empty DB you create) and passed "
364
+ "as --out-db; the run fails with instructions if it is missing or "
365
+ "unreachable. deidkit never edits the source in place.")
366
+ pd_.add_argument("--source", required=True, help="Source DB SQLAlchemy URL (read-only).")
367
+ pd_.add_argument("--out-db", required=True,
368
+ help="Output DB SQLAlchemy URL you created (must differ from source). "
369
+ "REQUIRED — the run fails without it.")
370
+ pd_.add_argument("--tables", help="Comma list of tables (default: all).")
371
+ pd_.add_argument("--schema", help="Data-dictionary JSON (drives the field plan).")
372
+ pd_.add_argument("--out-schema", help="Schema/namespace in the output DB.")
373
+ pd_.add_argument("--if-exists", choices=["fail", "replace", "append"], default="fail",
374
+ help="Behaviour if an output table already exists (default fail).")
375
+ pd_.add_argument("--secret", help="Secret string (else --secret-file, env, or auto).")
376
+ pd_.add_argument("--secret-file", help=f"File holding the secret (or set {ENV_VAR}).")
377
+ pd_.add_argument("--mapping", help="Private mapping JSON (default <private>/mapping.private.json).")
378
+ pd_.add_argument("--private-dir", help="Dir for secret+mapping+audit (default ./deidkit-db-PRIVATE).")
379
+ _add_policy_args(pd_)
380
+ pd_.set_defaults(func=cmd_run_db)
381
+
382
+ px = sub.add_parser("scan", help="Show detections for a text (tuning/debug).")
383
+ g = px.add_mutually_exclusive_group()
384
+ g.add_argument("--text", help="Inline text to scan.")
385
+ g.add_argument("--file", help="Read text from a file.")
386
+ _add_policy_args(px)
387
+ px.set_defaults(func=cmd_scan)
388
+
389
+ return parser
390
+
391
+
392
+ def main(argv=None) -> int:
393
+ parser = build_parser()
394
+ args = parser.parse_args(argv)
395
+ return args.func(args)
396
+
397
+
398
+ if __name__ == "__main__":
399
+ raise SystemExit(main())
deidkit/config.py ADDED
@@ -0,0 +1,285 @@
1
+ """Policy configuration: how each field is transformed.
2
+
3
+ A :class:`Policy` is the single object that describes what the tool should do.
4
+ It combines:
5
+
6
+ * global knobs (language, date-shift window, detection mode),
7
+ * an allow / ignore list of columns,
8
+ * explicit per-column :class:`FieldRule` overrides.
9
+
10
+ Everything else (which strategy a column gets when there is no explicit rule)
11
+ is derived automatically from the schema / data dictionary in :mod:`deidkit.schema`.
12
+ Policies can be built in code, loaded from YAML, or seeded from a data-dictionary
13
+ JSON file.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fnmatch
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Dict, List, Optional, Sequence
21
+
22
+ # --------------------------------------------------------------------------- #
23
+ # Strategy vocabulary
24
+ # --------------------------------------------------------------------------- #
25
+ # Each value column in a table is handled by exactly one strategy.
26
+
27
+ PASSTHROUGH = "passthrough" # leave value untouched (safe default)
28
+ DATE_SHIFT = "date_shift" # shift by a per-entity offset; preserves intervals
29
+ SYNTHETIC_NAME = "synthetic_name" # replace a structured name value with a fake one
30
+ IDENTIFIER = "identifier" # replace an ID-like value with a stable surrogate
31
+ FREETEXT = "freetext" # run the multi-stage detector over free text
32
+ GENERALIZE_YEAR = "generalize_year" # reduce a date to its year only
33
+ AGE_BAND = "age_band" # bucket an age into a band (e.g. 10-year bands, 90+)
34
+ REDACT = "redact" # blank the value out
35
+ DROP = "drop" # drop the whole column from the output
36
+
37
+ ALL_STRATEGIES = frozenset(
38
+ {
39
+ PASSTHROUGH,
40
+ DATE_SHIFT,
41
+ SYNTHETIC_NAME,
42
+ IDENTIFIER,
43
+ FREETEXT,
44
+ GENERALIZE_YEAR,
45
+ AGE_BAND,
46
+ REDACT,
47
+ DROP,
48
+ }
49
+ )
50
+
51
+ # Detection aggressiveness for the free-text stage.
52
+ MODE_CONSERVATIVE = "conservative" # only replace when clearly a name (default)
53
+ MODE_BALANCED = "balanced" # also accept strong single-signal name hits
54
+ MODE_AGGRESSIVE = "aggressive" # accept any plausible person span (higher recall)
55
+ ALL_MODES = frozenset({MODE_CONSERVATIVE, MODE_BALANCED, MODE_AGGRESSIVE})
56
+
57
+ # How replacement person names look. All are OBVIOUSLY synthetic except
58
+ # "realistic" (which reads like a real name and should be used with care).
59
+ NAME_TAGGED = "tagged" # realistic name + visible marker: "Juan Gómez [SINTÉTICO]"
60
+ NAME_PLACEHOLDER = "placeholder" # role + stable code: "Persona 481502" / "Person 481502"
61
+ NAME_TOKEN = "token" # machine token: "[NOMBRE_9F5287]"
62
+ NAME_REALISTIC = "realistic" # realistic, UNMARKED fake name (looks real)
63
+ ALL_NAME_STYLES = frozenset({NAME_TAGGED, NAME_PLACEHOLDER, NAME_TOKEN, NAME_REALISTIC})
64
+
65
+ # Visible markers for the "tagged" style, by primary language.
66
+ TAG_MARKERS = {"es": "[SINTÉTICO]", "en": "[SYNTHETIC]"}
67
+
68
+
69
+ @dataclass
70
+ class FieldRule:
71
+ """An explicit override for one or more columns.
72
+
73
+ ``column`` and ``table`` support ``fnmatch`` globs (``*`` etc.). ``table``
74
+ of ``None`` matches every table. The first matching rule wins, so put more
75
+ specific rules first.
76
+ """
77
+
78
+ column: str
79
+ strategy: str
80
+ table: Optional[str] = None
81
+ options: Dict[str, Any] = field(default_factory=dict)
82
+
83
+ def matches(self, table: Optional[str], column: str) -> bool:
84
+ if self.table is not None and table is not None:
85
+ if not fnmatch.fnmatch(table, self.table):
86
+ return False
87
+ return fnmatch.fnmatch(column, self.column)
88
+
89
+ def __post_init__(self) -> None:
90
+ if self.strategy not in ALL_STRATEGIES:
91
+ raise ValueError(
92
+ f"Unknown strategy {self.strategy!r}. "
93
+ f"Valid: {sorted(ALL_STRATEGIES)}"
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class Policy:
99
+ """The full transformation policy.
100
+
101
+ Attributes
102
+ ----------
103
+ entity_key:
104
+ Column used to group date shifts and to seed per-patient offsets.
105
+ Every date column of a given patient is shifted by the *same* number of
106
+ days, so intervals (length of stay, time-to-result, age) are preserved.
107
+ date_max_days:
108
+ Date offsets are drawn deterministically from ``[-date_max_days,
109
+ +date_max_days]``. The magnitude does not affect interval fidelity; it
110
+ only controls how far the absolute calendar date moves.
111
+ lang:
112
+ Default language for name generation / detection (``"es"`` or ``"en"``).
113
+ mode:
114
+ Free-text detection aggressiveness (see ``MODE_*``).
115
+ name_style:
116
+ How replacement names look (see ``NAME_*``). Default ``"tagged"`` yields
117
+ OBVIOUSLY-synthetic names ("Juan Gómez [SINTÉTICO]"). ``"placeholder"``
118
+ -> "Persona 481502"; ``"token"`` -> "[NOMBRE_9F5287]"; ``"realistic"`` ->
119
+ an unmarked realistic fake (looks real — use with care).
120
+ names_synthetic:
121
+ Legacy switch. ``False`` forces ``name_style="token"``.
122
+ shift_dates_in_text:
123
+ Shift date-like strings found inside free text by the row's offset.
124
+ only:
125
+ If set, ONLY these columns are transformed; everything else passes
126
+ through. (Applied by exact column name.)
127
+ ignore:
128
+ These columns are never transformed. Takes precedence over everything.
129
+ rules:
130
+ Explicit per-column overrides evaluated before the schema auto-plan.
131
+ spacy_model:
132
+ If set (e.g. ``"es_core_news_lg"``) the optional statistical NER stage
133
+ is enabled as a corroborating detector.
134
+ """
135
+
136
+ entity_key: str = "patient_id"
137
+ date_max_days: int = 365
138
+ lang: str = "es"
139
+ mode: str = MODE_CONSERVATIVE
140
+ name_style: str = NAME_TAGGED
141
+ names_synthetic: bool = True
142
+ shift_dates_in_text: bool = True
143
+ only: Optional[set] = None
144
+ ignore: set = field(default_factory=set)
145
+ rules: List[FieldRule] = field(default_factory=list)
146
+ spacy_model: Optional[str] = None
147
+ # Advanced: extra gazetteer/stoplist files to merge on top of the bundled ones.
148
+ extra_name_files: List[str] = field(default_factory=list)
149
+ extra_stoplist_files: List[str] = field(default_factory=list)
150
+ # Known-PHI propagation: values in these per-row columns are KNOWN PII and are
151
+ # force-scrubbed from that row's free text (near-100% recall, no guessing).
152
+ known_name_columns: List[str] = field(default_factory=list)
153
+ known_id_columns: List[str] = field(default_factory=list)
154
+ # Enable checksum-validated detection of bare national IDs, per region, e.g.
155
+ # ["CPF","CNPJ","CNS"] (Brazil), ["DNI","NIE"] (Spain), ["NHS"] (UK),
156
+ # ["CURP","RFC"] (Mexico), or ["ALL"]. Off by default (see detect/checksums).
157
+ id_checksums: List[str] = field(default_factory=list)
158
+ # Load the bundled medical safe-vocabulary (drug/terminology tokens) so
159
+ # capitalized medical terms are never flagged as person names (Philter-style).
160
+ use_medical_vocab: bool = True
161
+ # Global registry of known names (e.g. clinic staff) scrubbed from all free text.
162
+ extra_known_names: List[str] = field(default_factory=list)
163
+ # Only force-scrub a LONE known name token if it is this long (guards against
164
+ # over-redacting short/common surname words like "Cruz"/"León").
165
+ known_token_min_len: int = 5
166
+
167
+ def __post_init__(self) -> None:
168
+ if self.mode not in ALL_MODES:
169
+ raise ValueError(f"mode must be one of {sorted(ALL_MODES)}")
170
+ if not self.names_synthetic: # legacy switch -> tokens
171
+ self.name_style = NAME_TOKEN
172
+ if self.name_style not in ALL_NAME_STYLES:
173
+ raise ValueError(f"name_style must be one of {sorted(ALL_NAME_STYLES)}")
174
+ if self.only is not None:
175
+ self.only = set(self.only)
176
+ self.ignore = set(self.ignore)
177
+
178
+ @property
179
+ def primary_lang(self) -> str:
180
+ """First language of a (possibly multilingual) spec, for name rendering."""
181
+ lg = self.lang
182
+ if isinstance(lg, (list, tuple)):
183
+ return lg[0] if lg else "es"
184
+ if lg in ("multi", "es+en", "en+es", "both"):
185
+ return "es"
186
+ return lg
187
+
188
+ # --- rule resolution --------------------------------------------------- #
189
+ def explicit_rule(self, table: Optional[str], column: str) -> Optional[FieldRule]:
190
+ """Return the first explicit rule matching ``table``/``column``."""
191
+ for rule in self.rules:
192
+ if rule.matches(table, column):
193
+ return rule
194
+ return None
195
+
196
+ def is_ignored(self, column: str) -> bool:
197
+ if column in self.ignore:
198
+ return True
199
+ if self.only is not None and column not in self.only:
200
+ return True
201
+ return False
202
+
203
+ # --- serialization ----------------------------------------------------- #
204
+ @classmethod
205
+ def from_dict(cls, data: Dict[str, Any]) -> "Policy":
206
+ data = dict(data)
207
+ rules = [
208
+ FieldRule(
209
+ column=r["column"],
210
+ strategy=r["strategy"],
211
+ table=r.get("table"),
212
+ options=r.get("options", {}) or {},
213
+ )
214
+ for r in data.pop("rules", []) or []
215
+ ]
216
+ only = data.pop("only", None)
217
+ ignore = data.pop("ignore", []) or []
218
+ known = {
219
+ "entity_key",
220
+ "date_max_days",
221
+ "lang",
222
+ "mode",
223
+ "name_style",
224
+ "names_synthetic",
225
+ "shift_dates_in_text",
226
+ "spacy_model",
227
+ "extra_name_files",
228
+ "extra_stoplist_files",
229
+ "known_name_columns",
230
+ "known_id_columns",
231
+ "extra_known_names",
232
+ "known_token_min_len",
233
+ "id_checksums",
234
+ "use_medical_vocab",
235
+ }
236
+ kwargs = {k: v for k, v in data.items() if k in known}
237
+ return cls(
238
+ rules=rules,
239
+ only=set(only) if only else None,
240
+ ignore=set(ignore),
241
+ **kwargs,
242
+ )
243
+
244
+ @classmethod
245
+ def from_yaml(cls, path: str) -> "Policy":
246
+ import yaml
247
+
248
+ with open(path, "r", encoding="utf-8") as fh:
249
+ data = yaml.safe_load(fh) or {}
250
+ return cls.from_dict(data)
251
+
252
+ def to_dict(self) -> Dict[str, Any]:
253
+ return {
254
+ "entity_key": self.entity_key,
255
+ "date_max_days": self.date_max_days,
256
+ "lang": self.lang,
257
+ "mode": self.mode,
258
+ "name_style": self.name_style,
259
+ "names_synthetic": self.names_synthetic,
260
+ "shift_dates_in_text": self.shift_dates_in_text,
261
+ "spacy_model": self.spacy_model,
262
+ "only": sorted(self.only) if self.only else None,
263
+ "ignore": sorted(self.ignore),
264
+ "rules": [
265
+ {
266
+ "column": r.column,
267
+ "strategy": r.strategy,
268
+ "table": r.table,
269
+ "options": r.options,
270
+ }
271
+ for r in self.rules
272
+ ],
273
+ }
274
+
275
+
276
+ def normalize_only_ignore(
277
+ only: Optional[Sequence[str]], ignore: Optional[Sequence[str]]
278
+ ) -> Dict[str, Any]:
279
+ """Helper for the CLI: turn comma-lists into a dict fragment for a Policy."""
280
+ frag: Dict[str, Any] = {}
281
+ if only:
282
+ frag["only"] = set(only)
283
+ if ignore:
284
+ frag["ignore"] = set(ignore)
285
+ return frag