deidkit 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {deidkit-0.1.4/src/deidkit.egg-info → deidkit-0.1.6}/PKG-INFO +50 -24
  2. {deidkit-0.1.4 → deidkit-0.1.6}/README.md +46 -21
  3. {deidkit-0.1.4 → deidkit-0.1.6}/examples/quickstart.md +4 -3
  4. {deidkit-0.1.4 → deidkit-0.1.6}/pyproject.toml +15 -5
  5. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/__init__.py +1 -1
  6. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/cli.py +14 -9
  7. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/config.py +10 -7
  8. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/pipeline.py +3 -5
  9. deidkit-0.1.6/src/deidkit/detect/spacy_ner.py +131 -0
  10. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/version.py +1 -1
  11. {deidkit-0.1.4 → deidkit-0.1.6/src/deidkit.egg-info}/PKG-INFO +50 -24
  12. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit.egg-info/requires.txt +1 -0
  13. deidkit-0.1.6/tests/test_ner_model_helpers.py +43 -0
  14. deidkit-0.1.4/src/deidkit/detect/spacy_ner.py +0 -63
  15. deidkit-0.1.4/tests/test_ner_model_helpers.py +0 -30
  16. {deidkit-0.1.4 → deidkit-0.1.6}/LICENSE +0 -0
  17. {deidkit-0.1.4 → deidkit-0.1.6}/MANIFEST.in +0 -0
  18. {deidkit-0.1.4 → deidkit-0.1.6}/examples/benchmark.py +0 -0
  19. {deidkit-0.1.4 → deidkit-0.1.6}/examples/make_sample.py +0 -0
  20. {deidkit-0.1.4 → deidkit-0.1.6}/examples/policy.example.yaml +0 -0
  21. {deidkit-0.1.4 → deidkit-0.1.6}/examples/policy.meridian.yaml +0 -0
  22. {deidkit-0.1.4 → deidkit-0.1.6}/setup.cfg +0 -0
  23. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/__main__.py +0 -0
  24. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/api.py +0 -0
  25. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/NOTICES.md +0 -0
  26. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/context_triggers_en.txt +0 -0
  27. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/context_triggers_es.txt +0 -0
  28. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/en_given_names.txt +0 -0
  29. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/en_surnames.txt +0 -0
  30. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/es_given_names.txt +0 -0
  31. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/es_surnames.txt +0 -0
  32. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/honorifics_en.txt +0 -0
  33. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/honorifics_es.txt +0 -0
  34. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/medical_stoplist_en.txt +0 -0
  35. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/medical_stoplist_es.txt +0 -0
  36. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/data/medical_vocab.txt +0 -0
  37. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/dates.py +0 -0
  38. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/__init__.py +0 -0
  39. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/checksums.py +0 -0
  40. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/context.py +0 -0
  41. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/gazetteer.py +0 -0
  42. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/patterns.py +0 -0
  43. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/detect/types.py +0 -0
  44. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/engine.py +0 -0
  45. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/generators/__init__.py +0 -0
  46. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/generators/names.py +0 -0
  47. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/generators/surrogates.py +0 -0
  48. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/io.py +0 -0
  49. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/learn.py +0 -0
  50. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/mapping.py +0 -0
  51. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/report.py +0 -0
  52. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/resources.py +0 -0
  53. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/schema.py +0 -0
  54. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/secret.py +0 -0
  55. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit/textnorm.py +0 -0
  56. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit.egg-info/SOURCES.txt +0 -0
  57. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit.egg-info/dependency_links.txt +0 -0
  58. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit.egg-info/entry_points.txt +0 -0
  59. {deidkit-0.1.4 → deidkit-0.1.6}/src/deidkit.egg-info/top_level.txt +0 -0
  60. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_api.py +0 -0
  61. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_checksums.py +0 -0
  62. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_dates.py +0 -0
  63. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_db.py +0 -0
  64. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_detect.py +0 -0
  65. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_engine.py +0 -0
  66. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_hardening.py +0 -0
  67. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_known_phi.py +0 -0
  68. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_learn.py +0 -0
  69. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_medical_vocab.py +0 -0
  70. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_names.py +0 -0
  71. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_qa_fixes.py +0 -0
  72. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_qa_freetext_leaks.py +0 -0
  73. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_qa_hardening.py +0 -0
  74. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_schema_lang.py +0 -0
  75. {deidkit-0.1.4 → deidkit-0.1.6}/tests/test_scoring.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deidkit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports.
5
5
  Author: Meridian Data
6
6
  License: MIT
@@ -26,13 +26,14 @@ Requires-Dist: openpyxl>=3.0
26
26
  Requires-Dist: PyYAML>=5.4
27
27
  Requires-Dist: SQLAlchemy>=1.4
28
28
  Requires-Dist: pyodbc>=4.0
29
- Provides-Extra: ner
30
- Requires-Dist: spacy>=3.4; extra == "ner"
29
+ Requires-Dist: spacy>=3.4
31
30
  Provides-Extra: parquet
32
31
  Requires-Dist: pyarrow>=7.0; extra == "parquet"
33
32
  Provides-Extra: sql
34
33
  Requires-Dist: SQLAlchemy>=1.4; extra == "sql"
35
34
  Requires-Dist: pyodbc>=4.0; extra == "sql"
35
+ Provides-Extra: ner
36
+ Requires-Dist: spacy>=3.4; extra == "ner"
36
37
  Provides-Extra: all
37
38
  Requires-Dist: spacy>=3.4; extra == "all"
38
39
  Requires-Dist: pyarrow>=7.0; extra == "all"
@@ -283,8 +284,7 @@ REPLACE PHONE 0.99 regex '300 123 4567'
283
284
  ```
284
285
  `REPLACE` = will be scrubbed. `review` = queued for a human instead.
285
286
 
286
- *In code:* `dk.Detector("es", "conservative").detect(text)` returns the same
287
- detections.
287
+ *In code:* `dk.Detector(lang="es").detect(text)` returns the same detections.
288
288
 
289
289
  ### The audit workbook — evidence of every change
290
290
 
@@ -408,29 +408,43 @@ extra_stoplist_files=["learned/learned_stoplist.txt"])`.
408
408
 
409
409
  ## 8. Tuning recall & precision
410
410
 
411
+ **By default deidkit auto-redacts** (`balanced` mode) **with the statistical NER
412
+ stage on**, so it catches names automatically — including novel names with no
413
+ surrounding cue — **with no setup and no flags**. spaCy ships with deidkit, and
414
+ the ~500 MB model (too big for the pip package) is **downloaded automatically on
415
+ the first run** that needs it:
416
+
417
+ ```bash
418
+ deidkit run <input> deid_out
419
+ # first run only: "[deidkit] NER model not found — downloading once (~500 MB)…"
420
+ # then it's cached and every later run uses it instantly.
421
+ ```
422
+
423
+ Nothing to remember, nothing to install separately. Fallbacks, if the auto-
424
+ download can't happen (no network, read-only box):
425
+
426
+ - deidkit **never fails** — it warns once and runs on the deterministic rules
427
+ (recall on bare/novel names is lower until the model is present).
428
+ - Pre-warm it deliberately with `deidkit download-model`, or install the model
429
+ wheel offline (see the offline note below).
430
+ - Turn auto-download off with `DEIDKIT_AUTO_DOWNLOAD=0` (then it's rules-only
431
+ unless the model is already installed), or drop NER entirely with `--no-ner`.
432
+
433
+ Knobs, only if you want to change the defaults:
434
+
411
435
  | Lever | What it does |
412
436
  | --- | --- |
413
- | `--mode conservative` (default) | replace only clear names; queue the rest to `review.csv` |
414
- | `--mode balanced` / `aggressive` | replace more eagerly (higher recall, more to review) |
415
- | `--spacy-model es_core_news_lg` | add a statistical NER second opinion (needs `deidkit[ner]`) |
437
+ | `--mode balanced` (default) | auto-redact confident detections, incl. single NER hits |
438
+ | `--mode conservative` | stricter only clear names; send the ambiguous tail to `review.csv` |
439
+ | `--mode aggressive` | highest recall (also lone dictionary tokens); more to review |
440
+ | `--no-ner` | turn the NER stage off (deterministic rules only) |
441
+ | `--spacy-model NAME` | use a different spaCy model (default `es_core_news_lg`) |
416
442
  | `--id-checksums DNI,NIE` | catch *bare* national IDs (no cue word) with near-zero false positives |
417
443
  | `--name-style tagged\|placeholder\|token\|realistic` | how fakes look — `tagged` = "Juan Gómez [SINTÉTICO]" (obvious); `realistic` = unmarked |
418
444
  | `--no-medical-vocab` | (advanced) stop treating ~21k drug/disease terms as safe non-names |
419
445
 
420
- Start conservative, work the review queue down, and turn on the NER stage only if
421
- you need more recall — the audit is how you prove the result.
422
-
423
- **Enabling NER** is a one-time model install (the model is ~500 MB and ships
424
- separately from the package, not bundled):
425
-
426
- ```bash
427
- pip install "deidkit[ner]" # installs spaCy
428
- deidkit download-model es_core_news_lg # fetches the model (once)
429
- deidkit run <input> deid_out --spacy-model es_core_news_lg
430
- ```
431
-
432
- If the model isn't installed, deidkit still runs on the deterministic rules and
433
- prints a clear warning — it never fails, but free-text name recall is lower.
446
+ Work the `review.csv` queue for whatever the automatic pass leaves uncertain the
447
+ audit is how you prove the result.
434
448
 
435
449
  *In code*, the same knobs are `Policy` fields:
436
450
 
@@ -439,6 +453,17 @@ dk.Policy(mode="balanced", spacy_model="es_core_news_lg",
439
453
  id_checksums=["DNI", "NIE"], name_style="tagged")
440
454
  ```
441
455
 
456
+ **Offline / locked-down machine** (no internet on the box that runs deidkit): the
457
+ model is a normal pip wheel, so fetch it once on a connected machine and copy it
458
+ over — no runtime download needed.
459
+
460
+ ```bash
461
+ # on a machine with internet:
462
+ pip download es_core_news_lg -d ./models
463
+ # on the offline runtime box:
464
+ pip install ./models/es_core_news_lg-*.whl
465
+ ```
466
+
442
467
  ---
443
468
 
444
469
  ## 9. Reading from a database (Fabric / Azure SQL)
@@ -704,7 +729,8 @@ Run `deidkit <command> --help` for any command's flags.
704
729
  ### Python `Policy` fields
705
730
 
706
731
  `entity_key` (default `"patient_id"`), `date_max_days` (365), `lang` (`"es"`),
707
- `mode` (`"conservative"`), `name_style` (`"tagged"`), `shift_dates_in_text`
732
+ `mode` (`"balanced"`), `name_style` (`"tagged"`), `spacy_model`
733
+ (`"es_core_news_lg"`, NER on by default), `shift_dates_in_text`
708
734
  (True), `only`, `ignore`, `rules` (`list[FieldRule]`), `spacy_model`,
709
735
  `known_name_columns`, `known_id_columns`, `extra_known_names`,
710
736
  `known_token_min_len` (5), `id_checksums`, `use_medical_vocab` (True),
@@ -744,7 +770,7 @@ per-patient date offsets, and write the audit yourself:
744
770
  ```python
745
771
  import deidkit as dk
746
772
 
747
- policy = dk.Policy(lang="es", mode="conservative", entity_key="patient_id",
773
+ policy = dk.Policy(lang="es", entity_key="patient_id", # mode defaults to balanced + NER
748
774
  known_id_columns=["patient_id"], ignore={"blood_type"})
749
775
 
750
776
  deid = dk.Deidentifier(
@@ -239,8 +239,7 @@ REPLACE PHONE 0.99 regex '300 123 4567'
239
239
  ```
240
240
  `REPLACE` = will be scrubbed. `review` = queued for a human instead.
241
241
 
242
- *In code:* `dk.Detector("es", "conservative").detect(text)` returns the same
243
- detections.
242
+ *In code:* `dk.Detector(lang="es").detect(text)` returns the same detections.
244
243
 
245
244
  ### The audit workbook — evidence of every change
246
245
 
@@ -364,29 +363,43 @@ extra_stoplist_files=["learned/learned_stoplist.txt"])`.
364
363
 
365
364
  ## 8. Tuning recall & precision
366
365
 
366
+ **By default deidkit auto-redacts** (`balanced` mode) **with the statistical NER
367
+ stage on**, so it catches names automatically — including novel names with no
368
+ surrounding cue — **with no setup and no flags**. spaCy ships with deidkit, and
369
+ the ~500 MB model (too big for the pip package) is **downloaded automatically on
370
+ the first run** that needs it:
371
+
372
+ ```bash
373
+ deidkit run <input> deid_out
374
+ # first run only: "[deidkit] NER model not found — downloading once (~500 MB)…"
375
+ # then it's cached and every later run uses it instantly.
376
+ ```
377
+
378
+ Nothing to remember, nothing to install separately. Fallbacks, if the auto-
379
+ download can't happen (no network, read-only box):
380
+
381
+ - deidkit **never fails** — it warns once and runs on the deterministic rules
382
+ (recall on bare/novel names is lower until the model is present).
383
+ - Pre-warm it deliberately with `deidkit download-model`, or install the model
384
+ wheel offline (see the offline note below).
385
+ - Turn auto-download off with `DEIDKIT_AUTO_DOWNLOAD=0` (then it's rules-only
386
+ unless the model is already installed), or drop NER entirely with `--no-ner`.
387
+
388
+ Knobs, only if you want to change the defaults:
389
+
367
390
  | Lever | What it does |
368
391
  | --- | --- |
369
- | `--mode conservative` (default) | replace only clear names; queue the rest to `review.csv` |
370
- | `--mode balanced` / `aggressive` | replace more eagerly (higher recall, more to review) |
371
- | `--spacy-model es_core_news_lg` | add a statistical NER second opinion (needs `deidkit[ner]`) |
392
+ | `--mode balanced` (default) | auto-redact confident detections, incl. single NER hits |
393
+ | `--mode conservative` | stricter only clear names; send the ambiguous tail to `review.csv` |
394
+ | `--mode aggressive` | highest recall (also lone dictionary tokens); more to review |
395
+ | `--no-ner` | turn the NER stage off (deterministic rules only) |
396
+ | `--spacy-model NAME` | use a different spaCy model (default `es_core_news_lg`) |
372
397
  | `--id-checksums DNI,NIE` | catch *bare* national IDs (no cue word) with near-zero false positives |
373
398
  | `--name-style tagged\|placeholder\|token\|realistic` | how fakes look — `tagged` = "Juan Gómez [SINTÉTICO]" (obvious); `realistic` = unmarked |
374
399
  | `--no-medical-vocab` | (advanced) stop treating ~21k drug/disease terms as safe non-names |
375
400
 
376
- Start conservative, work the review queue down, and turn on the NER stage only if
377
- you need more recall — the audit is how you prove the result.
378
-
379
- **Enabling NER** is a one-time model install (the model is ~500 MB and ships
380
- separately from the package, not bundled):
381
-
382
- ```bash
383
- pip install "deidkit[ner]" # installs spaCy
384
- deidkit download-model es_core_news_lg # fetches the model (once)
385
- deidkit run <input> deid_out --spacy-model es_core_news_lg
386
- ```
387
-
388
- If the model isn't installed, deidkit still runs on the deterministic rules and
389
- prints a clear warning — it never fails, but free-text name recall is lower.
401
+ Work the `review.csv` queue for whatever the automatic pass leaves uncertain the
402
+ audit is how you prove the result.
390
403
 
391
404
  *In code*, the same knobs are `Policy` fields:
392
405
 
@@ -395,6 +408,17 @@ dk.Policy(mode="balanced", spacy_model="es_core_news_lg",
395
408
  id_checksums=["DNI", "NIE"], name_style="tagged")
396
409
  ```
397
410
 
411
+ **Offline / locked-down machine** (no internet on the box that runs deidkit): the
412
+ model is a normal pip wheel, so fetch it once on a connected machine and copy it
413
+ over — no runtime download needed.
414
+
415
+ ```bash
416
+ # on a machine with internet:
417
+ pip download es_core_news_lg -d ./models
418
+ # on the offline runtime box:
419
+ pip install ./models/es_core_news_lg-*.whl
420
+ ```
421
+
398
422
  ---
399
423
 
400
424
  ## 9. Reading from a database (Fabric / Azure SQL)
@@ -660,7 +684,8 @@ Run `deidkit <command> --help` for any command's flags.
660
684
  ### Python `Policy` fields
661
685
 
662
686
  `entity_key` (default `"patient_id"`), `date_max_days` (365), `lang` (`"es"`),
663
- `mode` (`"conservative"`), `name_style` (`"tagged"`), `shift_dates_in_text`
687
+ `mode` (`"balanced"`), `name_style` (`"tagged"`), `spacy_model`
688
+ (`"es_core_news_lg"`, NER on by default), `shift_dates_in_text`
664
689
  (True), `only`, `ignore`, `rules` (`list[FieldRule]`), `spacy_model`,
665
690
  `known_name_columns`, `known_id_columns`, `extra_known_names`,
666
691
  `known_token_min_len` (5), `id_checksums`, `use_medical_vocab` (True),
@@ -700,7 +725,7 @@ per-patient date offsets, and write the audit yourself:
700
725
  ```python
701
726
  import deidkit as dk
702
727
 
703
- policy = dk.Policy(lang="es", mode="conservative", entity_key="patient_id",
728
+ policy = dk.Policy(lang="es", entity_key="patient_id", # mode defaults to balanced + NER
704
729
  known_id_columns=["patient_id"], ignore={"blood_type"})
705
730
 
706
731
  deid = dk.Deidentifier(
@@ -4,8 +4,9 @@
4
4
 
5
5
  ```bash
6
6
  pip install -e .
7
- # optional: statistical NER second opinion
8
- pip install -e ".[ner]" && python -m spacy download es_core_news_lg
7
+ # NER runs by default; its ~500 MB model auto-downloads on the first run that
8
+ # needs it (spaCy already ships with deidkit). Pre-fetch it with:
9
+ # deidkit download-model
9
10
  ```
10
11
 
11
12
  If the `deidkit` command isn't on your PATH, use `python -m deidkit ...`
@@ -66,6 +67,6 @@ python examples/benchmark.py --n 400 --spacy es_core_news_lg
66
67
  | tokens instead of fake names | `--names-token` |
67
68
  | don't shift dates inside free text | `--no-date-text` |
68
69
  | wider/narrower date window | `--date-max-days 30` |
69
- | enable statistical NER | `--spacy-model es_core_news_lg` |
70
+ | turn OFF the default NER stage | `--no-ner` |
70
71
  | Parquet output | `--format parquet` |
71
72
  | load a YAML policy | `--policy examples/policy.example.yaml` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deidkit"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,16 +40,17 @@ dependencies = [
40
40
  # from the Microsoft Fabric SQL analytics endpoint (the primary data source).
41
41
  "SQLAlchemy>=1.4",
42
42
  "pyodbc>=4.0",
43
+ # spaCy is core so NER is on by default with no extra install step or bracket.
44
+ # The model itself is separate (too big for PyPI): `deidkit download-model`.
45
+ "spacy>=3.4",
43
46
  ]
44
47
 
45
48
  [project.optional-dependencies]
46
- # Statistical NER stage (second-opinion recall). After install run:
47
- # python -m spacy download es_core_news_lg
48
- ner = ["spacy>=3.4"]
49
49
  # Read/write Parquet.
50
50
  parquet = ["pyarrow>=7.0"]
51
- # Back-compat alias: the DB drivers are now core, so `deidkit[sql]` == `deidkit`.
51
+ # Back-compat aliases: these are now core, so `deidkit[sql]` / `deidkit[ner]` == `deidkit`.
52
52
  sql = ["SQLAlchemy>=1.4", "pyodbc>=4.0"]
53
+ ner = ["spacy>=3.4"]
53
54
  # Everything except the heavy spaCy model download.
54
55
  all = ["spacy>=3.4", "pyarrow>=7.0", "SQLAlchemy>=1.4", "pyodbc>=4.0"]
55
56
  dev = ["pytest>=7.0"]
@@ -70,3 +71,12 @@ where = ["src"]
70
71
 
71
72
  [tool.setuptools.package-data]
72
73
  deidkit = ["data/*.txt", "data/*.md"]
74
+
75
+ [tool.pytest.ini_options]
76
+ filterwarnings = [
77
+ # The NER model / spaCy aren't present in CI; NER's graceful-fallback warnings
78
+ # are expected there and are exercised directly in test_ner_model_helpers.py.
79
+ "ignore:spaCy is missing.*:UserWarning",
80
+ "ignore:NER model.*auto-download is off.*:UserWarning",
81
+ "ignore:could not auto-download.*:UserWarning",
82
+ ]
@@ -4,7 +4,7 @@ Quick start (library)::
4
4
 
5
5
  import deidkit as dk
6
6
 
7
- policy = dk.Policy(lang="es", mode="conservative", ignore=["blood_type"])
7
+ policy = dk.Policy(lang="es", ignore=["blood_type"]) # balanced + NER by default
8
8
  deid = dk.Deidentifier(
9
9
  policy,
10
10
  secret="keep-this-secret",
@@ -50,6 +50,8 @@ def build_policy(args) -> Policy:
50
50
  policy.date_max_days = args.date_max_days
51
51
  if getattr(args, "spacy_model", None):
52
52
  policy.spacy_model = args.spacy_model
53
+ if getattr(args, "no_ner", False):
54
+ policy.spacy_model = None
53
55
  if getattr(args, "names_token", False):
54
56
  policy.names_synthetic = False
55
57
  if getattr(args, "no_date_text", False):
@@ -222,14 +224,14 @@ def cmd_run_db(args) -> int:
222
224
 
223
225
 
224
226
  def cmd_download_model(args) -> int:
225
- """Download the spaCy model the optional NER stage uses (one-time, ~500 MB)."""
227
+ """Download the spaCy NER model (used by default). One-time, ~500 MB."""
226
228
  model = args.model
227
229
  try:
228
230
  import spacy # noqa: F401
229
231
  from spacy.cli import download
230
232
  except ImportError:
231
- print('error: spaCy is not installed. First run: pip install "deidkit[ner]"',
232
- file=sys.stderr)
233
+ print("error: spaCy is missing (it ships with deidkit) — reinstall with "
234
+ "`pip install --force-reinstall deidkit`.", file=sys.stderr)
233
235
  return 2
234
236
  print(f"Downloading spaCy model '{model}' (~500 MB, one-time)…")
235
237
  try:
@@ -239,8 +241,8 @@ def cmd_download_model(args) -> int:
239
241
  print(f"error: could not download '{model}'. Check the model name and "
240
242
  f"your network.", file=sys.stderr)
241
243
  return 2
242
- print(f"Done. Enable NER with: --spacy-model {model} "
243
- f"(or spacy_model='{model}' in code).")
244
+ print("Done. NER now runs by default; no flag needed. "
245
+ "(Turn it off with --no-ner if you ever want rules-only.)")
244
246
  return 0
245
247
 
246
248
 
@@ -282,7 +284,8 @@ def cmd_run(args) -> int:
282
284
  print(f" secret : deidkit-secret.key (KEEP — needed to re-run consistently)")
283
285
  print(f"\n cells changed : {summ['cells_changed']:,}")
284
286
  print(f" to review : {summ['review_detections']:,}")
285
- print(f" detection : {summ['ner_status']} (deterministic rules; no AI by default)")
287
+ print(f" NER stage : {summ['ner_status']} (deterministic rules always run; "
288
+ f"{'add --no-ner for rules-only' if summ['ner_status'].startswith('active') else 'download-model to enable'})")
286
289
  for etype, cnt in sorted(summ["entity_counts"].items(), key=lambda x: -x[1]):
287
290
  print(f" {etype:<14} {cnt:,}")
288
291
  return 0
@@ -294,7 +297,7 @@ def _add_policy_args(p):
294
297
  p.add_argument("--lang", choices=["es", "en", "multi"],
295
298
  help="Language(s) for names/detection ('multi' = ES+EN).")
296
299
  p.add_argument("--mode", choices=sorted(ALL_MODES),
297
- help="Free-text detection aggressiveness (default conservative).")
300
+ help="Free-text detection aggressiveness (default balanced = auto-redact).")
298
301
  p.add_argument("--name-style", choices=["tagged", "placeholder", "token", "realistic"],
299
302
  help="How fake names look (default tagged = obviously synthetic).")
300
303
  p.add_argument("--only", help="Comma list: transform ONLY these columns.")
@@ -308,7 +311,9 @@ def _add_policy_args(p):
308
311
  p.add_argument("--no-date-text", action="store_true",
309
312
  help="Do not shift date strings found inside free text.")
310
313
  p.add_argument("--spacy-model",
311
- help="Enable spaCy NER stage with this model (e.g. es_core_news_lg).")
314
+ help="Override the NER model (default es_core_news_lg; on by default).")
315
+ p.add_argument("--no-ner", action="store_true",
316
+ help="Turn off the statistical NER stage (deterministic rules only).")
312
317
  p.add_argument("--known-name-column", action="append",
313
318
  help="Column whose value is a known person name to force-scrub "
314
319
  "from that row's free text (repeatable).")
@@ -404,7 +409,7 @@ def build_parser() -> argparse.ArgumentParser:
404
409
 
405
410
  pdm = sub.add_parser(
406
411
  "download-model",
407
- help="Download the spaCy NER model for the optional NER stage (needs deidkit[ner]).")
412
+ help="Download the spaCy NER model (used by default; one-time ~500 MB).")
408
413
  pdm.add_argument("model", nargs="?", default="es_core_news_lg",
409
414
  help="spaCy model name (default es_core_news_lg).")
410
415
  pdm.set_defaults(func=cmd_download_model)
@@ -49,9 +49,9 @@ ALL_STRATEGIES = frozenset(
49
49
  )
50
50
 
51
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)
52
+ MODE_CONSERVATIVE = "conservative" # only replace when clearly a name (review the rest)
53
+ MODE_BALANCED = "balanced" # also auto-redact single-signal / NER hits (default)
54
+ MODE_AGGRESSIVE = "aggressive" # accept any plausible person span (highest recall)
55
55
  ALL_MODES = frozenset({MODE_CONSERVATIVE, MODE_BALANCED, MODE_AGGRESSIVE})
56
56
 
57
57
  # How replacement person names look. All are OBVIOUSLY synthetic except
@@ -129,21 +129,24 @@ class Policy:
129
129
  rules:
130
130
  Explicit per-column overrides evaluated before the schema auto-plan.
131
131
  spacy_model:
132
- If set (e.g. ``"es_core_news_lg"``) the optional statistical NER stage
133
- is enabled as a corroborating detector.
132
+ The statistical NER model, ON by default (``"es_core_news_lg"``) so novel
133
+ names are caught with no extra flags. It loads lazily and only if present
134
+ — if the model isn't downloaded yet, deidkit warns once and runs on the
135
+ deterministic stages. Download it with ``deidkit download-model``; set to
136
+ ``None`` to turn NER off.
134
137
  """
135
138
 
136
139
  entity_key: str = "patient_id"
137
140
  date_max_days: int = 365
138
141
  lang: str = "es"
139
- mode: str = MODE_CONSERVATIVE
142
+ mode: str = MODE_BALANCED
140
143
  name_style: str = NAME_TAGGED
141
144
  names_synthetic: bool = True
142
145
  shift_dates_in_text: bool = True
143
146
  only: Optional[set] = None
144
147
  ignore: set = field(default_factory=set)
145
148
  rules: List[FieldRule] = field(default_factory=list)
146
- spacy_model: Optional[str] = None
149
+ spacy_model: Optional[str] = "es_core_news_lg"
147
150
  # Advanced: extra gazetteer/stoplist files to merge on top of the bundled ones.
148
151
  extra_name_files: List[str] = field(default_factory=list)
149
152
  extra_stoplist_files: List[str] = field(default_factory=list)
@@ -52,7 +52,7 @@ class Detector:
52
52
  def __init__(
53
53
  self,
54
54
  lang: str = "es",
55
- mode: str = "conservative",
55
+ mode: str = "balanced",
56
56
  spacy_model: Optional[str] = None,
57
57
  extra_name_files=(),
58
58
  extra_stoplist_files=(),
@@ -70,9 +70,7 @@ class Detector:
70
70
 
71
71
  @property
72
72
  def ner_status(self) -> str:
73
- if self.ner is None:
74
- return "disabled"
75
- return "active" if self.ner.available else f"unavailable ({self.ner.error})"
73
+ return "disabled" if self.ner is None else self.ner.ner_status
76
74
 
77
75
  # ------------------------------------------------------------------ #
78
76
  def detect(self, text: str) -> List[Detection]:
@@ -94,7 +92,7 @@ class Detector:
94
92
  raw += checksums.find_valid_ids(text, self.id_checksums)
95
93
  raw += context.find(text, tokens, self.gaz)
96
94
  raw += gazetteer.find(text, tokens, self.gaz)
97
- if self.ner is not None and self.ner.available:
95
+ if self.ner is not None: # find() lazy-loads the model and returns [] if absent
98
96
  raw += self.ner.find(text)
99
97
 
100
98
  idents = [d for d in raw if d.entity_type in _IDENTIFIER_TYPES]
@@ -0,0 +1,131 @@
1
+ """Statistical NER stage (spaCy).
2
+
3
+ On by default (``Policy.spacy_model`` = ``"es_core_news_lg"``). The model loads
4
+ **lazily** on first use, and if it isn't installed yet deidkit **auto-downloads
5
+ it once** — so there is no separate install step for anyone to forget. Everything
6
+ degrades gracefully: if the download can't happen (no network, read-only env),
7
+ ``available`` is ``False``, :meth:`find` returns nothing, a one-time warning names
8
+ the fix, and the tool still runs on the deterministic stages.
9
+
10
+ Auto-download is on by default; set ``DEIDKIT_AUTO_DOWNLOAD=0`` to disable it (for
11
+ offline/locked-down machines, pre-install the model wheel instead).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import os
18
+ import sys
19
+ import warnings
20
+ from typing import List
21
+
22
+ from .types import Detection, PERSON
23
+
24
+ _PERSON_LABELS = {"PER", "PERSON"}
25
+
26
+
27
+ def _auto_download_enabled() -> bool:
28
+ return os.environ.get("DEIDKIT_AUTO_DOWNLOAD", "1").strip().lower() not in (
29
+ "0", "false", "no", "off",
30
+ )
31
+
32
+
33
+ class SpacyNER:
34
+ def __init__(self, model: str):
35
+ self.model = model
36
+ self._nlp = None
37
+ self._loaded = False
38
+ self.available = False
39
+ self.error = None
40
+
41
+ def _try_load(self) -> bool:
42
+ try:
43
+ import spacy
44
+
45
+ self._nlp = spacy.load(self.model, disable=["lemmatizer", "textcat"])
46
+ self.available = True
47
+ return True
48
+ except Exception as exc: # OSError (model absent) / ImportError (spaCy absent)
49
+ self.error = str(exc)
50
+ return False
51
+
52
+ def _ensure_loaded(self) -> None:
53
+ """Load on first use; auto-download the model once if it isn't installed."""
54
+ if self._loaded:
55
+ return
56
+ self._loaded = True
57
+
58
+ try:
59
+ import spacy # noqa: F401
60
+ except Exception as exc:
61
+ self.error = str(exc)
62
+ warnings.warn(
63
+ f"spaCy is missing (it ships with deidkit); NER is off. Reinstall "
64
+ f"with `pip install --force-reinstall deidkit`. ({exc})",
65
+ stacklevel=3,
66
+ )
67
+ return
68
+
69
+ if self._try_load():
70
+ return # already installed
71
+
72
+ if not _auto_download_enabled():
73
+ warnings.warn(
74
+ f"NER model {self.model!r} is not installed and auto-download is off "
75
+ f"(DEIDKIT_AUTO_DOWNLOAD=0); running rules-only. Pre-install it with "
76
+ f"`deidkit download-model {self.model}`.",
77
+ stacklevel=3,
78
+ )
79
+ return
80
+
81
+ # Auto-download once (this run), then load in-process.
82
+ try:
83
+ print(f"[deidkit] NER model {self.model!r} not found — downloading once "
84
+ f"(~500 MB, first run only)…", file=sys.stderr, flush=True)
85
+ from spacy.cli import download
86
+
87
+ download(self.model)
88
+ importlib.invalidate_caches() # make the just-installed package importable now
89
+ if self._try_load():
90
+ print("[deidkit] NER model ready.", file=sys.stderr, flush=True)
91
+ return
92
+ except SystemExit as exc: # spacy.cli.download can sys.exit on failure
93
+ self.error = f"download exited ({exc.code})"
94
+ except Exception as exc:
95
+ self.error = str(exc)
96
+
97
+ warnings.warn(
98
+ f"could not auto-download NER model {self.model!r} ({self.error}); running "
99
+ f"on the deterministic rules only. Fixes: ensure network access and rerun, "
100
+ f"run `deidkit download-model {self.model}`, or set DEIDKIT_AUTO_DOWNLOAD=0 "
101
+ f"to stay rules-only quietly.",
102
+ stacklevel=3,
103
+ )
104
+
105
+ @property
106
+ def ner_status(self) -> str:
107
+ if not self._loaded:
108
+ return "pending (loads on first use)"
109
+ return "active" if self.available else f"unavailable ({self.error})"
110
+
111
+ def find(self, text: str) -> List[Detection]:
112
+ if not text:
113
+ return []
114
+ self._ensure_loaded()
115
+ if not self.available:
116
+ return []
117
+ doc = self._nlp(text)
118
+ out: List[Detection] = []
119
+ for ent in doc.ents:
120
+ if ent.label_ in _PERSON_LABELS:
121
+ out.append(
122
+ Detection(
123
+ start=ent.start_char,
124
+ end=ent.end_char,
125
+ text=ent.text,
126
+ entity_type=PERSON,
127
+ methods={"ner"},
128
+ meta={"gender": "u"},
129
+ )
130
+ )
131
+ return out
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deidkit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Schema-driven pseudonymization for clinical/tabular datasets: synthetic names, interval-preserving date shifting, multi-stage free-text PII detection, and before/after audit exports.
5
5
  Author: Meridian Data
6
6
  License: MIT
@@ -26,13 +26,14 @@ Requires-Dist: openpyxl>=3.0
26
26
  Requires-Dist: PyYAML>=5.4
27
27
  Requires-Dist: SQLAlchemy>=1.4
28
28
  Requires-Dist: pyodbc>=4.0
29
- Provides-Extra: ner
30
- Requires-Dist: spacy>=3.4; extra == "ner"
29
+ Requires-Dist: spacy>=3.4
31
30
  Provides-Extra: parquet
32
31
  Requires-Dist: pyarrow>=7.0; extra == "parquet"
33
32
  Provides-Extra: sql
34
33
  Requires-Dist: SQLAlchemy>=1.4; extra == "sql"
35
34
  Requires-Dist: pyodbc>=4.0; extra == "sql"
35
+ Provides-Extra: ner
36
+ Requires-Dist: spacy>=3.4; extra == "ner"
36
37
  Provides-Extra: all
37
38
  Requires-Dist: spacy>=3.4; extra == "all"
38
39
  Requires-Dist: pyarrow>=7.0; extra == "all"
@@ -283,8 +284,7 @@ REPLACE PHONE 0.99 regex '300 123 4567'
283
284
  ```
284
285
  `REPLACE` = will be scrubbed. `review` = queued for a human instead.
285
286
 
286
- *In code:* `dk.Detector("es", "conservative").detect(text)` returns the same
287
- detections.
287
+ *In code:* `dk.Detector(lang="es").detect(text)` returns the same detections.
288
288
 
289
289
  ### The audit workbook — evidence of every change
290
290
 
@@ -408,29 +408,43 @@ extra_stoplist_files=["learned/learned_stoplist.txt"])`.
408
408
 
409
409
  ## 8. Tuning recall & precision
410
410
 
411
+ **By default deidkit auto-redacts** (`balanced` mode) **with the statistical NER
412
+ stage on**, so it catches names automatically — including novel names with no
413
+ surrounding cue — **with no setup and no flags**. spaCy ships with deidkit, and
414
+ the ~500 MB model (too big for the pip package) is **downloaded automatically on
415
+ the first run** that needs it:
416
+
417
+ ```bash
418
+ deidkit run <input> deid_out
419
+ # first run only: "[deidkit] NER model not found — downloading once (~500 MB)…"
420
+ # then it's cached and every later run uses it instantly.
421
+ ```
422
+
423
+ Nothing to remember, nothing to install separately. Fallbacks, if the auto-
424
+ download can't happen (no network, read-only box):
425
+
426
+ - deidkit **never fails** — it warns once and runs on the deterministic rules
427
+ (recall on bare/novel names is lower until the model is present).
428
+ - Pre-warm it deliberately with `deidkit download-model`, or install the model
429
+ wheel offline (see the offline note below).
430
+ - Turn auto-download off with `DEIDKIT_AUTO_DOWNLOAD=0` (then it's rules-only
431
+ unless the model is already installed), or drop NER entirely with `--no-ner`.
432
+
433
+ Knobs, only if you want to change the defaults:
434
+
411
435
  | Lever | What it does |
412
436
  | --- | --- |
413
- | `--mode conservative` (default) | replace only clear names; queue the rest to `review.csv` |
414
- | `--mode balanced` / `aggressive` | replace more eagerly (higher recall, more to review) |
415
- | `--spacy-model es_core_news_lg` | add a statistical NER second opinion (needs `deidkit[ner]`) |
437
+ | `--mode balanced` (default) | auto-redact confident detections, incl. single NER hits |
438
+ | `--mode conservative` | stricter only clear names; send the ambiguous tail to `review.csv` |
439
+ | `--mode aggressive` | highest recall (also lone dictionary tokens); more to review |
440
+ | `--no-ner` | turn the NER stage off (deterministic rules only) |
441
+ | `--spacy-model NAME` | use a different spaCy model (default `es_core_news_lg`) |
416
442
  | `--id-checksums DNI,NIE` | catch *bare* national IDs (no cue word) with near-zero false positives |
417
443
  | `--name-style tagged\|placeholder\|token\|realistic` | how fakes look — `tagged` = "Juan Gómez [SINTÉTICO]" (obvious); `realistic` = unmarked |
418
444
  | `--no-medical-vocab` | (advanced) stop treating ~21k drug/disease terms as safe non-names |
419
445
 
420
- Start conservative, work the review queue down, and turn on the NER stage only if
421
- you need more recall — the audit is how you prove the result.
422
-
423
- **Enabling NER** is a one-time model install (the model is ~500 MB and ships
424
- separately from the package, not bundled):
425
-
426
- ```bash
427
- pip install "deidkit[ner]" # installs spaCy
428
- deidkit download-model es_core_news_lg # fetches the model (once)
429
- deidkit run <input> deid_out --spacy-model es_core_news_lg
430
- ```
431
-
432
- If the model isn't installed, deidkit still runs on the deterministic rules and
433
- prints a clear warning — it never fails, but free-text name recall is lower.
446
+ Work the `review.csv` queue for whatever the automatic pass leaves uncertain the
447
+ audit is how you prove the result.
434
448
 
435
449
  *In code*, the same knobs are `Policy` fields:
436
450
 
@@ -439,6 +453,17 @@ dk.Policy(mode="balanced", spacy_model="es_core_news_lg",
439
453
  id_checksums=["DNI", "NIE"], name_style="tagged")
440
454
  ```
441
455
 
456
+ **Offline / locked-down machine** (no internet on the box that runs deidkit): the
457
+ model is a normal pip wheel, so fetch it once on a connected machine and copy it
458
+ over — no runtime download needed.
459
+
460
+ ```bash
461
+ # on a machine with internet:
462
+ pip download es_core_news_lg -d ./models
463
+ # on the offline runtime box:
464
+ pip install ./models/es_core_news_lg-*.whl
465
+ ```
466
+
442
467
  ---
443
468
 
444
469
  ## 9. Reading from a database (Fabric / Azure SQL)
@@ -704,7 +729,8 @@ Run `deidkit <command> --help` for any command's flags.
704
729
  ### Python `Policy` fields
705
730
 
706
731
  `entity_key` (default `"patient_id"`), `date_max_days` (365), `lang` (`"es"`),
707
- `mode` (`"conservative"`), `name_style` (`"tagged"`), `shift_dates_in_text`
732
+ `mode` (`"balanced"`), `name_style` (`"tagged"`), `spacy_model`
733
+ (`"es_core_news_lg"`, NER on by default), `shift_dates_in_text`
708
734
  (True), `only`, `ignore`, `rules` (`list[FieldRule]`), `spacy_model`,
709
735
  `known_name_columns`, `known_id_columns`, `extra_known_names`,
710
736
  `known_token_min_len` (5), `id_checksums`, `use_medical_vocab` (True),
@@ -744,7 +770,7 @@ per-patient date offsets, and write the audit yourself:
744
770
  ```python
745
771
  import deidkit as dk
746
772
 
747
- policy = dk.Policy(lang="es", mode="conservative", entity_key="patient_id",
773
+ policy = dk.Policy(lang="es", entity_key="patient_id", # mode defaults to balanced + NER
748
774
  known_id_columns=["patient_id"], ignore={"blood_type"})
749
775
 
750
776
  deid = dk.Deidentifier(
@@ -3,6 +3,7 @@ openpyxl>=3.0
3
3
  PyYAML>=5.4
4
4
  SQLAlchemy>=1.4
5
5
  pyodbc>=4.0
6
+ spacy>=3.4
6
7
 
7
8
  [all]
8
9
  spacy>=3.4
@@ -0,0 +1,43 @@
1
+ """The NER model is separate from the package (too big for PyPI); it must be a
2
+ one-command install, and a missing model must warn loudly (not silently reduce
3
+ recall). These tests cover the helper command and the warning — without ever
4
+ downloading a model."""
5
+
6
+ import warnings
7
+
8
+ from deidkit.cli import build_parser, cmd_download_model
9
+ from deidkit.detect.spacy_ner import SpacyNER
10
+
11
+
12
+ def test_missing_model_degrades_gracefully(monkeypatch):
13
+ monkeypatch.setenv("DEIDKIT_AUTO_DOWNLOAD", "0") # never hit the network here
14
+ ner = SpacyNER("definitely_not_a_real_model_zzz")
15
+ assert ner.available is False # lazy: not loaded yet
16
+ with warnings.catch_warnings(record=True) as caught:
17
+ warnings.simplefilter("always")
18
+ result = ner.find("Paciente Juan Perez consulta.") # loads lazily -> warns
19
+ assert result == [] # degrades, no crash
20
+ assert ner.available is False # load failed
21
+ msgs = " ".join(str(w.message) for w in caught)
22
+ assert "NER" in msgs and "deidkit" in msgs # loud + actionable
23
+
24
+
25
+ def test_auto_download_enabled_env_toggle(monkeypatch):
26
+ from deidkit.detect.spacy_ner import _auto_download_enabled
27
+
28
+ monkeypatch.delenv("DEIDKIT_AUTO_DOWNLOAD", raising=False)
29
+ assert _auto_download_enabled() is True # on by default
30
+ for off in ("0", "false", "no", "off"):
31
+ monkeypatch.setenv("DEIDKIT_AUTO_DOWNLOAD", off)
32
+ assert _auto_download_enabled() is False
33
+
34
+
35
+ def test_cli_wires_download_model_with_default():
36
+ args = build_parser().parse_args(["download-model"])
37
+ assert args.func is cmd_download_model
38
+ assert args.model == "es_core_news_lg"
39
+
40
+
41
+ def test_cli_download_model_accepts_explicit_name():
42
+ args = build_parser().parse_args(["download-model", "en_core_web_sm"])
43
+ assert args.model == "en_core_web_sm"
@@ -1,63 +0,0 @@
1
- """Optional statistical NER stage (spaCy).
2
-
3
- Enabled only when ``Policy.spacy_model`` is set and spaCy + the model are
4
- installed. It is used as an independent PERSON detector and to corroborate
5
- gazetteer hits. Everything degrades gracefully: if the model is missing,
6
- ``available`` is ``False`` and :meth:`find` returns nothing, so the tool still
7
- runs on the deterministic stages alone.
8
-
9
- pip install "deidkit[ner]"
10
- python -m spacy download es_core_news_lg
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from typing import List
16
-
17
- from .types import Detection, PERSON
18
-
19
- _PERSON_LABELS = {"PER", "PERSON"}
20
-
21
-
22
- class SpacyNER:
23
- def __init__(self, model: str):
24
- self.model = model
25
- self._nlp = None
26
- self.available = False
27
- self.error = None
28
- try:
29
- import spacy
30
-
31
- self._nlp = spacy.load(model, disable=["lemmatizer", "textcat"])
32
- self.available = True
33
- except Exception as exc: # ImportError or OSError (model not found)
34
- self.error = str(exc)
35
- import warnings
36
-
37
- warnings.warn(
38
- f"NER requested but spaCy model {model!r} is not available "
39
- f"({type(exc).__name__}). The NER stage is OFF, so free-text name "
40
- f"recall is reduced — names not in the gazetteer/anchored may be "
41
- f"missed. Install it once with: deidkit download-model {model} "
42
- f'(after: pip install "deidkit[ner]").',
43
- stacklevel=3,
44
- )
45
-
46
- def find(self, text: str) -> List[Detection]:
47
- if not self.available or not text:
48
- return []
49
- doc = self._nlp(text)
50
- out: List[Detection] = []
51
- for ent in doc.ents:
52
- if ent.label_ in _PERSON_LABELS:
53
- out.append(
54
- Detection(
55
- start=ent.start_char,
56
- end=ent.end_char,
57
- text=ent.text,
58
- entity_type=PERSON,
59
- methods={"ner"},
60
- meta={"gender": "u"},
61
- )
62
- )
63
- return out
@@ -1,30 +0,0 @@
1
- """The NER model is separate from the package (too big for PyPI); it must be a
2
- one-command install, and a missing model must warn loudly (not silently reduce
3
- recall). These tests cover the helper command and the warning — without ever
4
- downloading a model."""
5
-
6
- import warnings
7
-
8
- from deidkit.cli import build_parser, cmd_download_model
9
- from deidkit.detect.spacy_ner import SpacyNER
10
-
11
-
12
- def test_missing_model_warns_and_disables():
13
- with warnings.catch_warnings(record=True) as caught:
14
- warnings.simplefilter("always")
15
- ner = SpacyNER("definitely_not_a_real_model_zzz")
16
- assert ner.available is False
17
- assert ner.find("Paciente Juan Perez consulta.") == [] # degrades, no crash
18
- msgs = " ".join(str(w.message) for w in caught)
19
- assert "download-model" in msgs and "NER" in msgs # loud + actionable
20
-
21
-
22
- def test_cli_wires_download_model_with_default():
23
- args = build_parser().parse_args(["download-model"])
24
- assert args.func is cmd_download_model
25
- assert args.model == "es_core_news_lg"
26
-
27
-
28
- def test_cli_download_model_accepts_explicit_name():
29
- args = build_parser().parse_args(["download-model", "en_core_web_sm"])
30
- assert args.model == "en_core_web_sm"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes