gdpr-pseudonymizer 2.1.3__tar.gz → 2.2.0__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 (132) hide show
  1. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/PKG-INFO +13 -8
  2. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/README.md +11 -6
  3. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/batch.py +2 -0
  4. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/process.py +2 -0
  5. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/config.py +19 -2
  6. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/main.py +6 -2
  7. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/validators.py +17 -0
  8. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/core/document_processor.py +21 -17
  9. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/models.py +1 -6
  10. gdpr_pseudonymizer-2.2.0/gdpr_pseudonymizer/locale/fr/LC_MESSAGES/cli.mo +0 -0
  11. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/locale/fr/LC_MESSAGES/cli.po +2 -2
  12. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/hybrid_detector.py +95 -4
  13. gdpr_pseudonymizer-2.2.0/gdpr_pseudonymizer/nlp/model_names.py +46 -0
  14. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/regex_matcher.py +31 -0
  15. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/detection_patterns.yaml +12 -7
  16. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/models.py +32 -1
  17. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/ui.py +7 -2
  18. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/workflow.py +51 -2
  19. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/pyproject.toml +2 -2
  20. gdpr_pseudonymizer-2.1.3/gdpr_pseudonymizer/locale/fr/LC_MESSAGES/cli.mo +0 -0
  21. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/LICENSE +0 -0
  22. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/__init__.py +0 -0
  23. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/__init__.py +0 -0
  24. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/__init__.py +0 -0
  25. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/config_show.py +0 -0
  26. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/delete_mapping.py +0 -0
  27. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/destroy_table.py +0 -0
  28. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/export.py +0 -0
  29. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/import_mappings.py +0 -0
  30. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/init.py +0 -0
  31. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/list_entities.py +0 -0
  32. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/list_mappings.py +0 -0
  33. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/stats.py +0 -0
  34. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/commands/validate_mappings.py +0 -0
  35. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/formatters.py +0 -0
  36. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/i18n.py +0 -0
  37. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/passphrase.py +0 -0
  38. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/progress.py +0 -0
  39. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/cli/validation_stub.py +0 -0
  40. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/core/__init__.py +0 -0
  41. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/core/naive_processor.py +0 -0
  42. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/__init__.py +0 -0
  43. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/database.py +0 -0
  44. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/encryption.py +0 -0
  45. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/naive_data.py +0 -0
  46. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/repositories/__init__.py +0 -0
  47. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/repositories/audit_repository.py +0 -0
  48. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/repositories/mapping_repository.py +0 -0
  49. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/data/repositories/metadata_repository.py +0 -0
  50. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/exceptions.py +0 -0
  51. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/__init__.py +0 -0
  52. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/accessibility/__init__.py +0 -0
  53. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/accessibility/focus_manager.py +0 -0
  54. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/accessibility/shortcuts.py +0 -0
  55. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/app.py +0 -0
  56. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/config.py +0 -0
  57. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/dialogs/shortcuts_help.py +0 -0
  58. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/error_handler.py +0 -0
  59. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/__init__.py +0 -0
  60. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/en.qm +0 -0
  61. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/en.ts +0 -0
  62. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/fr.qm +0 -0
  63. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/fr.ts +0 -0
  64. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/i18n/translator.py +0 -0
  65. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/main_window.py +0 -0
  66. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/models/__init__.py +0 -0
  67. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/models/validation_state.py +0 -0
  68. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/app.ico +0 -0
  69. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/icon_16.png +0 -0
  70. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/icon_256.png +0 -0
  71. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/icon_32.png +0 -0
  72. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/icon_48.png +0 -0
  73. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/icons/icon_512.png +0 -0
  74. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/themes/dark.qss +0 -0
  75. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/themes/high-contrast.qss +0 -0
  76. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/resources/themes/light.qss +0 -0
  77. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/__init__.py +0 -0
  78. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/batch.py +0 -0
  79. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/database.py +0 -0
  80. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/home.py +0 -0
  81. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/processing.py +0 -0
  82. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/results.py +0 -0
  83. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/settings.py +0 -0
  84. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/stub.py +0 -0
  85. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/screens/validation.py +0 -0
  86. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/__init__.py +0 -0
  87. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/confirm_dialog.py +0 -0
  88. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/drop_zone.py +0 -0
  89. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/entity_editor.py +0 -0
  90. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/entity_panel.py +0 -0
  91. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/passphrase_dialog.py +0 -0
  92. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/step_indicator.py +0 -0
  93. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/widgets/toast.py +0 -0
  94. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/__init__.py +0 -0
  95. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/batch_worker.py +0 -0
  96. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/database_worker.py +0 -0
  97. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/detection_worker.py +0 -0
  98. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/finalization_worker.py +0 -0
  99. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/model_manager.py +0 -0
  100. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/processing_worker.py +0 -0
  101. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/gui/workers/signals.py +0 -0
  102. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/locale/en/LC_MESSAGES/cli.mo +0 -0
  103. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/locale/en/LC_MESSAGES/cli.po +0 -0
  104. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/__init__.py +0 -0
  105. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/entity_detector.py +0 -0
  106. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/entity_grouping.py +0 -0
  107. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/geography_dictionary.py +0 -0
  108. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/name_dictionary.py +0 -0
  109. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/spacy_detector.py +0 -0
  110. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/nlp/stanza_detector.py +0 -0
  111. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/pseudonym/__init__.py +0 -0
  112. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/pseudonym/assignment_engine.py +0 -0
  113. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/pseudonym/gender_detector.py +0 -0
  114. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/pseudonym/library_manager.py +0 -0
  115. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/pseudonym/neutral_id_generator.py +0 -0
  116. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/__init__.py +0 -0
  117. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/french_gender_lookup.json +0 -0
  118. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/french_geography.json +0 -0
  119. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/french_names.json +0 -0
  120. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/pseudonyms/lotr.json +0 -0
  121. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/pseudonyms/neutral.json +0 -0
  122. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/resources/pseudonyms/star_wars.json +0 -0
  123. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/__init__.py +0 -0
  124. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/config_manager.py +0 -0
  125. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/file_handler.py +0 -0
  126. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/french_patterns.py +0 -0
  127. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/logger.py +0 -0
  128. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/tabular_reader.py +0 -0
  129. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/utils/tabular_writer.py +0 -0
  130. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/__init__.py +0 -0
  131. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/actions.py +0 -0
  132. {gdpr_pseudonymizer-2.1.3 → gdpr_pseudonymizer-2.2.0}/gdpr_pseudonymizer/validation/context_precomputer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gdpr-pseudonymizer
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: CLI and GUI tool for GDPR-compliant pseudonymization of French text documents using NLP-based entity detection and reversible mapping
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,7 +30,7 @@ Provides-Extra: gui
30
30
  Provides-Extra: pdf
31
31
  Requires-Dist: PySide6-Essentials (>=6.7.0,<6.8.0) ; (python_version >= "3.10" and python_version < "3.13") and (extra == "gui" or extra == "all")
32
32
  Requires-Dist: PyYAML (>=6.0,<7.0)
33
- Requires-Dist: SQLAlchemy (>=2.0.0,<3.0.0)
33
+ Requires-Dist: SQLAlchemy (>=2.0.0,<2.1.0)
34
34
  Requires-Dist: click (>=8.0.0,<8.2.0)
35
35
  Requires-Dist: cryptography (>=50.0.1,<51.0.0)
36
36
  Requires-Dist: markdown-it-py (>=3.0.0,<4.0.0)
@@ -85,10 +85,14 @@ Pre-built standalone executables are available for Windows, macOS, and Linux. No
85
85
 
86
86
  | Platform | File | Notes |
87
87
  |----------|------|-------|
88
- | **Windows** | `gdpr-pseudonymizer-2.1.0-windows-setup.exe` | Run the installer. Adds Start Menu shortcut. |
89
- | **macOS (Apple Silicon)** | `gdpr-pseudonymizer-2.1.0-macos-arm64.dmg` | Open DMG, drag to Applications. |
90
- | **macOS (Intel)** | `gdpr-pseudonymizer-2.1.0-macos-x86_64.dmg` | Open DMG, drag to Applications. |
91
- | **Linux** | `gdpr-pseudonymizer-2.1.0-linux.AppImage` | `chmod +x` then run. |
88
+ | **Windows** | `gdpr-pseudonymizer-2.1.3-windows-setup.exe` | Run the installer. Adds Start Menu shortcut. |
89
+ | **macOS (Apple Silicon)** | `gdpr-pseudonymizer-2.1.3-macos-arm64.dmg` | Open DMG, drag to Applications. |
90
+ | **Linux** | `gdpr-pseudonymizer-2.1.3-linux.AppImage` | `chmod +x` then run. |
91
+
92
+ > **Intel Macs:** no DMG is published. GitHub retired its Intel macOS runners, and an
93
+ > Apple Silicon runner cannot cross-build a working x86_64 bundle. Install with
94
+ > `pip install gdpr-pseudonymizer` instead — the CLI and GUI both work on Intel.
95
+
92
96
 
93
97
  ### Platform Notes
94
98
 
@@ -370,7 +374,8 @@ After comprehensive benchmarking on 25 French interview/business documents (1,73
370
374
  |----------|----------|-----------|--------|-------|
371
375
  | **spaCy only** `fr_core_news_lg` | 29.5% | 27.0% | 32.7% | Story 1.2 baseline |
372
376
  | **Hybrid** (spaCy + regex) | 59.97% | 48.17% | 79.45% | Story 5.3 |
373
- | **Hybrid + expanded patterns** | 31.79% | 19.49% | 85.15% | Story 7.5 (current) |
377
+ | **Hybrid + expanded patterns** | 31.79% | 25.38% | 42.54% | Story 7.5 |
378
+ | **Span-bleed fixes** | 32.34% | 26.75% | 40.88% | 2026-09-29 (current) — recall drop is a ground-truth artefact, see QA report |
374
379
 
375
380
  **Accuracy trajectory:** spaCy-only baseline → hybrid approach with annotation cleanup, expanded regex patterns, and French geography dictionary doubled F1 score. Story 7.5 added 12 ORG pattern keywords, POS-tag disambiguation for geography matching, and 7 international locations — reducing LOCATION false-negative rate from 27.42% to 12.90%.
376
381
 
@@ -389,7 +394,7 @@ The validation UI provides an intuitive keyboard-driven interface for reviewing
389
394
  - ✅ **Entity-by-type grouping** - Review PERSON → ORG → LOCATION in logical order
390
395
  - ✅ **Context display** - See 10 words before/after each entity with highlighting
391
396
  - ✅ **Confidence scores** - Color-coded confidence from spaCy NER (green >80%, yellow 60-80%, red <60%)
392
- - ✅ **Keyboard shortcuts** - Single-key actions: [Space] Confirm, [R] Reject, [E] Modify, [A] Add, [C] Change pseudonym
397
+ - ✅ **Keyboard shortcuts** - Single-key actions: [Space] Confirm, [R] Reject, [E] Modify, [T] Change type, [A] Add, [C] Change pseudonym
393
398
  - ✅ **Batch operations** - Accept/reject all entities of a type at once (Shift+A/R) with entity count feedback
394
399
  - ✅ **Context cycling indicator** - Dot indicator (`● ○ ○ ○ ○`) shows current context position; `[Press X to cycle]` hint improves discoverability
395
400
  - ✅ **Help overlay** - Press [H] for full command reference
@@ -36,10 +36,14 @@ Pre-built standalone executables are available for Windows, macOS, and Linux. No
36
36
 
37
37
  | Platform | File | Notes |
38
38
  |----------|------|-------|
39
- | **Windows** | `gdpr-pseudonymizer-2.1.0-windows-setup.exe` | Run the installer. Adds Start Menu shortcut. |
40
- | **macOS (Apple Silicon)** | `gdpr-pseudonymizer-2.1.0-macos-arm64.dmg` | Open DMG, drag to Applications. |
41
- | **macOS (Intel)** | `gdpr-pseudonymizer-2.1.0-macos-x86_64.dmg` | Open DMG, drag to Applications. |
42
- | **Linux** | `gdpr-pseudonymizer-2.1.0-linux.AppImage` | `chmod +x` then run. |
39
+ | **Windows** | `gdpr-pseudonymizer-2.1.3-windows-setup.exe` | Run the installer. Adds Start Menu shortcut. |
40
+ | **macOS (Apple Silicon)** | `gdpr-pseudonymizer-2.1.3-macos-arm64.dmg` | Open DMG, drag to Applications. |
41
+ | **Linux** | `gdpr-pseudonymizer-2.1.3-linux.AppImage` | `chmod +x` then run. |
42
+
43
+ > **Intel Macs:** no DMG is published. GitHub retired its Intel macOS runners, and an
44
+ > Apple Silicon runner cannot cross-build a working x86_64 bundle. Install with
45
+ > `pip install gdpr-pseudonymizer` instead — the CLI and GUI both work on Intel.
46
+
43
47
 
44
48
  ### Platform Notes
45
49
 
@@ -321,7 +325,8 @@ After comprehensive benchmarking on 25 French interview/business documents (1,73
321
325
  |----------|----------|-----------|--------|-------|
322
326
  | **spaCy only** `fr_core_news_lg` | 29.5% | 27.0% | 32.7% | Story 1.2 baseline |
323
327
  | **Hybrid** (spaCy + regex) | 59.97% | 48.17% | 79.45% | Story 5.3 |
324
- | **Hybrid + expanded patterns** | 31.79% | 19.49% | 85.15% | Story 7.5 (current) |
328
+ | **Hybrid + expanded patterns** | 31.79% | 25.38% | 42.54% | Story 7.5 |
329
+ | **Span-bleed fixes** | 32.34% | 26.75% | 40.88% | 2026-09-29 (current) — recall drop is a ground-truth artefact, see QA report |
325
330
 
326
331
  **Accuracy trajectory:** spaCy-only baseline → hybrid approach with annotation cleanup, expanded regex patterns, and French geography dictionary doubled F1 score. Story 7.5 added 12 ORG pattern keywords, POS-tag disambiguation for geography matching, and 7 international locations — reducing LOCATION false-negative rate from 27.42% to 12.90%.
327
332
 
@@ -340,7 +345,7 @@ The validation UI provides an intuitive keyboard-driven interface for reviewing
340
345
  - ✅ **Entity-by-type grouping** - Review PERSON → ORG → LOCATION in logical order
341
346
  - ✅ **Context display** - See 10 words before/after each entity with highlighting
342
347
  - ✅ **Confidence scores** - Color-coded confidence from spaCy NER (green >80%, yellow 60-80%, red <60%)
343
- - ✅ **Keyboard shortcuts** - Single-key actions: [Space] Confirm, [R] Reject, [E] Modify, [A] Add, [C] Change pseudonym
348
+ - ✅ **Keyboard shortcuts** - Single-key actions: [Space] Confirm, [R] Reject, [E] Modify, [T] Change type, [A] Add, [C] Change pseudonym
344
349
  - ✅ **Batch operations** - Accept/reject all entities of a type at once (Shift+A/R) with entity count feedback
345
350
  - ✅ **Context cycling indicator** - Dot indicator (`● ○ ○ ○ ○`) shows current context position; `[Press X to cycle]` hint improves discoverability
346
351
  - ✅ **Help overlay** - Press [H] for full command reference
@@ -37,6 +37,7 @@ from gdpr_pseudonymizer.cli.progress import ETAColumn, ProgressTracker
37
37
  from gdpr_pseudonymizer.cli.validators import (
38
38
  ensure_database,
39
39
  parse_entity_type_filter,
40
+ validate_model_or_exit,
40
41
  validate_theme_or_exit,
41
42
  )
42
43
  from gdpr_pseudonymizer.core.document_processor import DocumentProcessor
@@ -438,6 +439,7 @@ def batch_command(
438
439
 
439
440
  # Validate theme
440
441
  validate_theme_or_exit(effective_theme)
442
+ validate_model_or_exit(effective_model)
441
443
 
442
444
  # Get passphrase
443
445
  resolved_passphrase = resolve_passphrase(
@@ -25,6 +25,7 @@ from gdpr_pseudonymizer.cli.passphrase import resolve_passphrase
25
25
  from gdpr_pseudonymizer.cli.validators import (
26
26
  ensure_database,
27
27
  parse_entity_type_filter,
28
+ validate_model_or_exit,
28
29
  validate_theme_or_exit,
29
30
  )
30
31
  from gdpr_pseudonymizer.core.document_processor import DocumentProcessor
@@ -161,6 +162,7 @@ def process_command(
161
162
 
162
163
  # Validate theme
163
164
  validate_theme_or_exit(effective_theme)
165
+ validate_model_or_exit(effective_model)
164
166
 
165
167
  # Get passphrase (with warning if --passphrase flag used)
166
168
  passphrase = resolve_passphrase(cli_passphrase=passphrase)
@@ -22,6 +22,7 @@ from gdpr_pseudonymizer.exceptions import (
22
22
  ConfigValidationError,
23
23
  PassphraseInConfigError,
24
24
  )
25
+ from gdpr_pseudonymizer.nlp.model_names import is_spacy_package_name
25
26
 
26
27
 
27
28
  @dataclass
@@ -73,6 +74,21 @@ VALID_LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR"]
73
74
  VALID_MODELS = ["spacy"]
74
75
 
75
76
 
77
+ def is_valid_model(model: str) -> bool:
78
+ """Check if a model value is accepted.
79
+
80
+ "spacy" selects the default French model; any well-formed spaCy package
81
+ name (e.g., "en_core_web_trf") selects that model.
82
+
83
+ Args:
84
+ model: Model value from config or CLI
85
+
86
+ Returns:
87
+ True if the value is accepted
88
+ """
89
+ return model in VALID_MODELS or is_spacy_package_name(model)
90
+
91
+
76
92
  def get_default_config() -> AppConfig:
77
93
  """Get default application configuration.
78
94
 
@@ -117,10 +133,11 @@ def validate_config_dict(config_dict: dict[str, Any], source: str = "config") ->
117
133
  )
118
134
 
119
135
  model = pseudonymization.get("model")
120
- if model is not None and model not in VALID_MODELS:
136
+ if model is not None and not is_valid_model(str(model)):
121
137
  raise ConfigValidationError(
122
138
  f"Invalid model '{model}' in {source}. "
123
- f"Valid models: {', '.join(VALID_MODELS)}"
139
+ f"Valid models: {', '.join(VALID_MODELS)}, "
140
+ f"or a spaCy package name (e.g. en_core_web_trf)"
124
141
  )
125
142
 
126
143
  # Validate logging level
@@ -220,7 +220,9 @@ def _process(
220
220
  None,
221
221
  "--model",
222
222
  "-m",
223
- help=_("NLP model name (spacy). Default from config."),
223
+ help=_(
224
+ "NLP model: 'spacy' (default French model) or a spaCy package name, e.g. en_core_web_trf. Default from config."
225
+ ),
224
226
  ),
225
227
  db_path: Optional[str] = typer.Option(
226
228
  None,
@@ -287,7 +289,9 @@ def _batch(
287
289
  None,
288
290
  "--model",
289
291
  "-m",
290
- help=_("NLP model name (spacy). Default from config."),
292
+ help=_(
293
+ "NLP model: 'spacy' (default French model) or a spaCy package name, e.g. en_core_web_trf. Default from config."
294
+ ),
291
295
  ),
292
296
  db_path: Optional[str] = typer.Option(
293
297
  None,
@@ -12,6 +12,7 @@ from typing import Optional
12
12
  from rich.console import Console
13
13
  from rich.progress import Progress, SpinnerColumn, TextColumn
14
14
 
15
+ from gdpr_pseudonymizer.cli.config import VALID_MODELS, is_valid_model
15
16
  from gdpr_pseudonymizer.cli.formatters import (
16
17
  ErrorCode,
17
18
  format_error_message,
@@ -302,6 +303,22 @@ def validate_theme_or_exit(theme: str) -> None:
302
303
  sys.exit(1)
303
304
 
304
305
 
306
+ def validate_model_or_exit(model: str) -> None:
307
+ """Validate NLP model value and exit(1) if invalid.
308
+
309
+ Args:
310
+ model: "spacy" or a spaCy package name (e.g., "en_core_web_trf")
311
+ """
312
+ if not is_valid_model(model):
313
+ format_error_message(
314
+ "Invalid Model",
315
+ f"Model '{model}' is not recognized.",
316
+ f"Use {', '.join(VALID_MODELS)} (default French model) or a spaCy "
317
+ f"package name, e.g. en_core_web_trf",
318
+ )
319
+ sys.exit(1)
320
+
321
+
305
322
  def ensure_database(db_path: str, passphrase: str, console: Console) -> None:
306
323
  """Initialize the database if it does not exist, with a progress spinner.
307
324
 
@@ -27,6 +27,7 @@ from gdpr_pseudonymizer.data.repositories.mapping_repository import (
27
27
  )
28
28
  from gdpr_pseudonymizer.nlp.entity_detector import DetectedEntity
29
29
  from gdpr_pseudonymizer.nlp.hybrid_detector import HybridDetector
30
+ from gdpr_pseudonymizer.nlp.model_names import resolve_spacy_model
30
31
  from gdpr_pseudonymizer.pseudonym.assignment_engine import (
31
32
  CompositionalPseudonymEngine,
32
33
  )
@@ -132,7 +133,8 @@ class DocumentProcessor:
132
133
  db_path: Path to SQLite database file
133
134
  passphrase: Encryption passphrase for database access
134
135
  theme: Pseudonym library theme (neutral/star_wars/lotr)
135
- model_name: NLP model name (spacy)
136
+ model_name: NLP model: "spacy" (default French model) or a spaCy
137
+ package name (e.g., "en_core_web_trf")
136
138
  notifier: Optional callback for user-facing messages.
137
139
  Decouples core from CLI presentation layer.
138
140
 
@@ -162,7 +164,9 @@ class DocumentProcessor:
162
164
  OSError: If spaCy model not installed
163
165
  """
164
166
  if self._detector is None:
165
- self._detector = HybridDetector()
167
+ self._detector = HybridDetector(
168
+ default_model=resolve_spacy_model(self.model_name)
169
+ )
166
170
  return self._detector
167
171
 
168
172
  def _detect_and_filter_entities(
@@ -1100,21 +1104,21 @@ class DocumentProcessor:
1100
1104
  Returns:
1101
1105
  Model version (e.g., "fr_core_news_lg-3.8.0")
1102
1106
  """
1103
- if self.model_name == "spacy":
1104
- try:
1105
- detector = self._get_detector()
1106
- if hasattr(detector, "nlp") and detector.nlp is not None:
1107
- # Extract model name and version from spaCy meta
1108
- meta = detector.nlp.meta
1109
- return (
1110
- f"{meta.get('name', 'unknown')}-{meta.get('version', '0.0.0')}"
1111
- )
1112
- except (OSError, AttributeError, ImportError) as e:
1113
- logger.warning(
1114
- "model_version_unavailable",
1115
- error_type=type(e).__name__,
1116
- )
1117
- return f"{self.model_name}-unknown"
1107
+ model = resolve_spacy_model(self.model_name)
1108
+ try:
1109
+ info = self._get_detector().spacy_detector.get_model_info()
1110
+ if isinstance(info, dict) and "version" in info:
1111
+ # spaCy meta names omit the language prefix ("core_news_lg")
1112
+ lang, name = info.get("language", ""), info.get("name", "")
1113
+ if lang and not name.startswith(f"{lang}_"):
1114
+ name = f"{lang}_{name}"
1115
+ return f"{name}-{info['version']}"
1116
+ except (OSError, AttributeError, ImportError) as e:
1117
+ logger.warning(
1118
+ "model_version_unavailable",
1119
+ error_type=type(e).__name__,
1120
+ )
1121
+ return f"{model}-unknown"
1118
1122
 
1119
1123
  def _log_failed_operation(
1120
1124
  self,
@@ -45,7 +45,6 @@ class Entity(Base):
45
45
  DateTime,
46
46
  nullable=False,
47
47
  default=datetime.utcnow,
48
- insert_default=datetime.utcnow,
49
48
  )
50
49
  gender: Mapped[str | None] = mapped_column(
51
50
  String, nullable=True
@@ -54,9 +53,7 @@ class Entity(Base):
54
53
  theme: Mapped[str] = mapped_column(String, nullable=False) # neutral/star_wars/lotr
55
54
 
56
55
  # Validation support fields
57
- is_ambiguous: Mapped[bool] = mapped_column(
58
- Boolean, nullable=False, default=False, insert_default=False
59
- )
56
+ is_ambiguous: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
60
57
  ambiguity_reason: Mapped[str | None] = mapped_column(String, nullable=True)
61
58
 
62
59
  def __init__(self, **kwargs: Any) -> None:
@@ -84,7 +81,6 @@ class Operation(Base):
84
81
  DateTime,
85
82
  nullable=False,
86
83
  default=datetime.utcnow,
87
- insert_default=datetime.utcnow,
88
84
  )
89
85
  operation_type: Mapped[str] = mapped_column(
90
86
  String, nullable=False
@@ -132,7 +128,6 @@ class Metadata(Base):
132
128
  DateTime,
133
129
  nullable=False,
134
130
  default=datetime.utcnow,
135
- insert_default=datetime.utcnow,
136
131
  onupdate=datetime.utcnow,
137
132
  )
138
133
 
@@ -64,8 +64,8 @@ msgstr "Chemin du fichier de sortie (par défaut : <entrée>_pseudonymized.ext)"
64
64
  msgid "Pseudonym library theme (neutral/star_wars/lotr). Default from config."
65
65
  msgstr "Thème de la bibliothèque de pseudonymes (neutral/star_wars/lotr). Par défaut depuis la configuration."
66
66
 
67
- msgid "NLP model name (spacy). Default from config."
68
- msgstr "Nom du modèle NLP (spacy). Par défaut depuis la configuration."
67
+ msgid "NLP model: 'spacy' (default French model) or a spaCy package name, e.g. en_core_web_trf. Default from config."
68
+ msgstr "Modèle NLP : « spacy » (modèle français par défaut) ou un nom de paquet spaCy, p. ex. en_core_web_trf. Par défaut depuis la configuration."
69
69
 
70
70
  msgid "Database file path. Default from config."
71
71
  msgstr "Chemin du fichier de base de données. Par défaut depuis la configuration."
@@ -10,7 +10,10 @@ Implements EntityDetector interface using a hybrid approach:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import re
14
+
13
15
  from gdpr_pseudonymizer.nlp.entity_detector import DetectedEntity, EntityDetector
16
+ from gdpr_pseudonymizer.nlp.model_names import DEFAULT_SPACY_MODEL
14
17
  from gdpr_pseudonymizer.nlp.regex_matcher import RegexMatcher
15
18
  from gdpr_pseudonymizer.nlp.spacy_detector import SpaCyDetector
16
19
  from gdpr_pseudonymizer.utils.french_patterns import strip_french_titles
@@ -38,10 +41,16 @@ class HybridDetector(EntityDetector):
38
41
  regex_matcher: RegexMatcher instance for pattern-based detection
39
42
  """
40
43
 
41
- def __init__(self) -> None:
42
- """Initialize hybrid detector with spaCy and regex components."""
44
+ def __init__(self, default_model: str = DEFAULT_SPACY_MODEL) -> None:
45
+ """Initialize hybrid detector with spaCy and regex components.
46
+
47
+ Args:
48
+ default_model: spaCy model loaded lazily on first detection when
49
+ load_model() was not called (e.g., "en_core_web_trf")
50
+ """
43
51
  self.spacy_detector = SpaCyDetector()
44
52
  self.regex_matcher = RegexMatcher()
53
+ self.default_model = default_model
45
54
  self._model_loaded = False
46
55
 
47
56
  def load_model(self, model_name: str) -> None:
@@ -87,14 +96,20 @@ class HybridDetector(EntityDetector):
87
96
 
88
97
  if not self._model_loaded:
89
98
  # Lazy load with default model
90
- logger.warning("hybrid_detector_lazy_loading_model")
91
- self.load_model("fr_core_news_lg")
99
+ logger.warning(
100
+ "hybrid_detector_lazy_loading_model", model=self.default_model
101
+ )
102
+ self.load_model(self.default_model)
92
103
 
93
104
  # Step 1: spaCy NER
94
105
  spacy_entities = self.spacy_detector.detect_entities(text)
95
106
  for entity in spacy_entities:
96
107
  entity.source = "spacy"
97
108
 
109
+ # Trim non-name tokens (timestamps, punctuation) off span edges before
110
+ # merging, so dedup and mapping lookup see the bare name
111
+ spacy_entities = self._trim_entity_boundaries(spacy_entities)
112
+
98
113
  logger.debug("spacy_detection_complete", entities_found=len(spacy_entities))
99
114
 
100
115
  # Step 2: Regex pattern matching (pass spaCy Doc for POS disambiguation)
@@ -236,6 +251,82 @@ class HybridDetector(EntityDetector):
236
251
 
237
252
  return False
238
253
 
254
+ @staticmethod
255
+ def _is_edge_junk_token(token: str, entity_type: str) -> bool:
256
+ """Check if a span-edge token cannot be part of an entity name.
257
+
258
+ Tokens with letters are always kept. PERSON names never contain
259
+ letterless tokens, so any is junk. ORG/LOCATION keep bare numbers
260
+ ("Studio 54", "Paris 2024") and only lose timestamps and punctuation.
261
+
262
+ Args:
263
+ token: Whitespace-delimited token at the edge of an entity span
264
+ entity_type: PERSON, LOCATION, or ORG
265
+
266
+ Returns:
267
+ True if the token should be trimmed off the span
268
+ """
269
+ if any(char.isalpha() for char in token):
270
+ return False
271
+ if entity_type == "PERSON":
272
+ return True
273
+ is_timestamp = ":" in token and any(char.isdigit() for char in token)
274
+ is_punctuation = not any(char.isalnum() for char in token)
275
+ return is_timestamp or is_punctuation
276
+
277
+ def _trim_entity_boundaries(
278
+ self, entities: list[DetectedEntity]
279
+ ) -> list[DetectedEntity]:
280
+ """Trim timestamps and punctuation that NER models glue to entity spans.
281
+
282
+ Transformer models (e.g. en_core_web_trf) often draw spans too wide on
283
+ transcripts: "Aino Virtanen 24:85:01" comes back as one PERSON.
284
+ Since mappings are keyed on the full entity text, every timestamp would
285
+ otherwise create a new entity with its own pseudonym.
286
+
287
+ Args:
288
+ entities: List of detected entities
289
+
290
+ Returns:
291
+ Entities with edge junk trimmed; entities that are only junk are dropped
292
+ """
293
+ trimmed = []
294
+ for entity in entities:
295
+ tokens = list(re.finditer(r"\S+", entity.text))
296
+ first, last = 0, len(tokens) - 1
297
+ while first <= last and self._is_edge_junk_token(
298
+ tokens[first].group(), entity.entity_type
299
+ ):
300
+ first += 1
301
+ while last >= first and self._is_edge_junk_token(
302
+ tokens[last].group(), entity.entity_type
303
+ ):
304
+ last -= 1
305
+
306
+ if first > last:
307
+ logger.debug(
308
+ "junk_only_entity_filtered",
309
+ text=entity.text,
310
+ entity_type=entity.entity_type,
311
+ reason="no_name_tokens",
312
+ )
313
+ continue
314
+
315
+ start = tokens[first].start()
316
+ end = tokens[last].end()
317
+ if start != 0 or end != len(entity.text):
318
+ logger.debug(
319
+ "entity_boundary_trimmed",
320
+ original=entity.text,
321
+ trimmed=entity.text[start:end],
322
+ )
323
+ entity.start_pos += start
324
+ entity.end_pos = entity.start_pos + (end - start)
325
+ entity.text = entity.text[start:end]
326
+ trimmed.append(entity)
327
+
328
+ return trimmed
329
+
239
330
  def _filter_title_only_entities(
240
331
  self, entities: list[DetectedEntity]
241
332
  ) -> list[DetectedEntity]:
@@ -0,0 +1,46 @@
1
+ """
2
+ spaCy model name resolution
3
+
4
+ Lightweight on purpose: imported by CLI config validation, so it must not
5
+ import spaCy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ DEFAULT_SPACY_MODEL = "fr_core_news_lg"
13
+
14
+ # spaCy package names: language code + underscore-separated parts
15
+ # (fr_core_news_lg, en_core_web_trf, xx_ent_wiki_sm). Strict on purpose: the
16
+ # name is passed to `python -m spacy download` when the model is missing.
17
+ _SPACY_PACKAGE_NAME = re.compile(r"^[a-z]{2,3}(_[a-z0-9]+)+$")
18
+
19
+
20
+ def is_spacy_package_name(name: str) -> bool:
21
+ """Check if a string is a well-formed spaCy model package name.
22
+
23
+ Args:
24
+ name: Candidate model name (e.g., "en_core_web_trf")
25
+
26
+ Returns:
27
+ True if the name looks like a spaCy package name
28
+ """
29
+ return bool(_SPACY_PACKAGE_NAME.match(name))
30
+
31
+
32
+ def resolve_spacy_model(model_name: str) -> str:
33
+ """Map a configured model value to the spaCy package to load.
34
+
35
+ The legacy backend name "spacy" (and any other non-package value) maps to
36
+ the default French model, so existing configurations keep working.
37
+
38
+ Args:
39
+ model_name: Value from config or --model (e.g., "spacy", "en_core_web_trf")
40
+
41
+ Returns:
42
+ spaCy package name to load
43
+ """
44
+ if is_spacy_package_name(model_name):
45
+ return model_name
46
+ return DEFAULT_SPACY_MODEL
@@ -121,6 +121,9 @@ class RegexMatcher:
121
121
  "entity_type": config.get("entity_type", "PERSON"),
122
122
  "confidence": config.get("confidence", 0.5),
123
123
  "description": pattern_def.get("description", ""),
124
+ "require_known_first_name": bool(
125
+ config.get("require_known_first_name", False)
126
+ ),
124
127
  }
125
128
  )
126
129
  except re.error as e:
@@ -169,6 +172,11 @@ class RegexMatcher:
169
172
  for pattern_def in pattern_list:
170
173
  matches = pattern_def["regex"].finditer(text)
171
174
  for match in matches:
175
+ if pattern_def[
176
+ "require_known_first_name"
177
+ ] and not self._has_known_first_name(match):
178
+ continue
179
+
172
180
  # Extract entity text (full match or last capturing group)
173
181
  entity_text = match.group(0)
174
182
  start_pos = match.start()
@@ -198,6 +206,29 @@ class RegexMatcher:
198
206
  logger.debug("regex_matching_complete", entities_found=len(entities))
199
207
  return entities
200
208
 
209
+ def _has_known_first_name(self, match: re.Match[str]) -> bool:
210
+ """Check that the match's last group contains a known first name.
211
+
212
+ Guards "Last, First" matching: without it, any "Word, Word" pair
213
+ ("Merci, Jean", "Thanks, Aino", "Weber, Directeur") becomes a PERSON.
214
+ Compound first names pass if any part is known ("Jean-Marc").
215
+ Without a loaded name dictionary the match is kept.
216
+
217
+ Args:
218
+ match: Regex match whose last capturing group is the first name
219
+
220
+ Returns:
221
+ True if the first name is in the name dictionary (or no dictionary)
222
+ """
223
+ if self.name_dictionary is None:
224
+ return True
225
+ first_name = match.group(match.re.groups)
226
+ return any(
227
+ self.name_dictionary.is_first_name(part)
228
+ for part in first_name.split("-")
229
+ if part
230
+ )
231
+
201
232
  def _is_full_names_enabled(self) -> bool:
202
233
  """Check if full names pattern matching is enabled."""
203
234
  pattern_config = self._config.get("patterns", {})
@@ -1,4 +1,6 @@
1
1
  # Regex Pattern Library for French Entity Detection
2
+ # Letter classes cover Latin-1 + Latin Extended-A (í ñ ø å ş ı ł č š ž ő...),
3
+ # not only French accents, so non-French names are not cut mid-word.
2
4
  # Each pattern category can be enabled/disabled for hybrid detection
3
5
 
4
6
  patterns:
@@ -8,8 +10,8 @@ patterns:
8
10
  confidence: 0.85
9
11
  entity_type: PERSON
10
12
  patterns:
11
- - pattern: '\b(M\.|Mme|Mlle|Dr\.|Pr\.|Maître|Me\.?)\s+([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+(?:\s+[A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+)?)'
12
- description: "Matches titles + names: M. Dupont, Dr. Marie Dubois, Mme Laurent, Maître Dupont, Me Mercier"
13
+ - pattern: '\b(M\.|Mme|Mlle|Dr\.|Pr\.|Maître|Me\.?)\s+([A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+(?:-[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)*(?:\s+[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+(?:-[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)*)?)'
14
+ description: "Matches titles + names: M. Dupont, Dr. Marie Dubois, Mme Laurent, Maître Dupont, Me Mercier, Dr. Marja-Liisa Mikkola"
13
15
  examples:
14
16
  - "M. Dupont"
15
17
  - "Mme Laurent"
@@ -25,7 +27,7 @@ patterns:
25
27
  confidence: 0.80
26
28
  entity_type: PERSON
27
29
  patterns:
28
- - pattern: '\b([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+-[A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+)\b'
30
+ - pattern: '\b([A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+-[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)\b'
29
31
  description: "Matches hyphenated first names: Jean-Pierre, Marie-Claire"
30
32
  examples:
31
33
  - "Jean-Pierre"
@@ -38,8 +40,11 @@ patterns:
38
40
  enabled: true
39
41
  confidence: 0.80
40
42
  entity_type: PERSON
43
+ # First name (after the comma) must be in the name dictionary, otherwise
44
+ # any "Word, Word" pair matches ("Merci, Jean", "Weber, Directeur")
45
+ require_known_first_name: true
41
46
  patterns:
42
- - pattern: '\b([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+(?:-[A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+)?)\s*,\s*(?!Cabinet|Société|Association|Fondation|Institut|Groupe|Consortium|Fédération|Entreprise|Compagnie|Direction|Service|Département|Commission|Comité|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Ligue)([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+(?:-[A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+)?)'
47
+ - pattern: '\b([A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+(?:-[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)?)\s*,\s*(?!Cabinet|Société|Association|Fondation|Institut|Groupe|Consortium|Fédération|Entreprise|Compagnie|Direction|Service|Département|Commission|Comité|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Ligue)([A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+(?:-[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)?)'
43
48
  description: "Last, First format (e.g., Dubois, Jean-Marc) with exclusion of ORG/function words"
44
49
  examples:
45
50
  - "Dubois, Jean-Marc"
@@ -52,7 +57,7 @@ patterns:
52
57
  confidence: 0.65
53
58
  entity_type: LOCATION
54
59
  patterns:
55
- - pattern: '\b(à|en|dans|près de|ville de)\s+([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+(?:\s+[A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][a-zàâäéèêëïîôöùûü]+)?)'
60
+ - pattern: '\b(à|en|dans|près de|ville de)\s+([A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+(?:\s+[A-ZÀ-ÖØ-ÞĀ-ſ][a-zß-öø-ÿĀ-ſ]+)?)'
56
61
  description: "Matches location indicators: à Paris, en France, près de Lyon"
57
62
  examples:
58
63
  - "à Paris"
@@ -67,7 +72,7 @@ patterns:
67
72
  confidence: 0.70
68
73
  entity_type: ORG
69
74
  patterns:
70
- - pattern: '\b([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôöùûü\s&,''\.\-]+?)\s+(SA|SARL|SAS|SASU|EURL|SNC|SCM|SCI|GIE|EI|SCOP|SEL|Association|Fondation|Institut|Groupe|Consortium|Fédération|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Comité|Commission|Ligue)\b'
75
+ - pattern: '\b([A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zß-öø-ÿĀ-ſ\s&,''\.\-]+?)\s+(SA|SARL|SAS|SASU|EURL|SNC|SCM|SCI|GIE|EI|SCOP|SEL|Association|Fondation|Institut|Groupe|Consortium|Fédération|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Comité|Commission|Ligue)\b'
71
76
  description: "Organization with legal form suffix (30 forms)"
72
77
  examples:
73
78
  - "TechCorp SA"
@@ -78,7 +83,7 @@ patterns:
78
83
  - "Solutions SCOP"
79
84
  - "TechCorp Association"
80
85
  - "Marie Curie Fondation"
81
- - pattern: '\b(Société|Entreprise|Cabinet|Groupe|Compagnie|Association|Fondation|Institut|Consortium|Fédération|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Comité|Commission|Ligue)\s+([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôöùûü\s&,\-]+)'
86
+ - pattern: '\b(Société|Entreprise|Cabinet|Groupe|Compagnie|Association|Fondation|Institut|Consortium|Fédération|Syndicat|Chambre|Mutuelle|Coopérative|Ordre|Caisse|Union|Confédération|Agence|Comité|Commission|Ligue)\s+([A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zß-öø-ÿĀ-ſ\s&,\-]+)'
82
87
  description: "Organization with prefix keyword (22 prefixes)"
83
88
  examples:
84
89
  - "Société TechCorp"