SqueakyCleanText 0.7.0__tar.gz → 0.7.1__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 (59) hide show
  1. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/PKG-INFO +11 -2
  2. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/README.md +10 -1
  3. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/PKG-INFO +11 -2
  4. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/SOURCES.txt +4 -0
  5. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/__init__.py +1 -1
  6. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/adapters.py +16 -7
  7. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/browser.py +53 -2
  8. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/config.py +45 -1
  9. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/model_benchmark.py +2 -0
  10. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/privacy.py +5 -1
  11. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/sct.py +68 -25
  12. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/anonymization_map.py +4 -2
  13. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/constants.py +63 -6
  14. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/contact.py +35 -5
  15. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/ner.py +74 -46
  16. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/onnx_pipeline.py +5 -1
  17. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/stopwords.py +2 -2
  18. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_adapters.py +45 -0
  19. squeakycleantext-0.7.1/tests/test_benchmark_release.py +67 -0
  20. squeakycleantext-0.7.1/tests/test_chunking.py +170 -0
  21. squeakycleantext-0.7.1/tests/test_config_state.py +121 -0
  22. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_mcp_server.py +50 -0
  23. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_privacy.py +67 -0
  24. squeakycleantext-0.7.1/tests/test_regex_boundaries.py +334 -0
  25. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_sct.py +3 -2
  26. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/LICENSE +0 -0
  27. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/dependency_links.txt +0 -0
  28. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/entry_points.txt +0 -0
  29. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/requires.txt +0 -0
  30. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/SqueakyCleanText.egg-info/top_level.txt +0 -0
  31. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/pyproject.toml +0 -0
  32. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/cli.py +0 -0
  33. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/evaluation.py +0 -0
  34. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/mcp_server.py +0 -0
  35. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/model_candidates.py +0 -0
  36. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/ocr.py +0 -0
  37. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/risk.py +0 -0
  38. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/streaming.py +0 -0
  39. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/__init__.py +0 -0
  40. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/datetime.py +0 -0
  41. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/gliclass_adapter.py +0 -0
  42. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/gliner_adapter.py +0 -0
  43. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/normtext.py +0 -0
  44. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/process_result.py +0 -0
  45. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/resources.py +0 -0
  46. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/special.py +0 -0
  47. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/synthetic.py +0 -0
  48. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/sct/utils/torch_pipeline.py +0 -0
  49. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/setup.cfg +0 -0
  50. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/setup.py +0 -0
  51. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_browser.py +0 -0
  52. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_cli.py +0 -0
  53. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_contracts.py +0 -0
  54. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_evaluation.py +0 -0
  55. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_functional.py +0 -0
  56. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_model_benchmarks.py +0 -0
  57. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_ocr.py +0 -0
  58. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_risk.py +0 -0
  59. {squeakycleantext-0.7.0 → squeakycleantext-0.7.1}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SqueakyCleanText
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Text preprocessing & PII anonymization pipeline for NLP/ML: ONNX NER ensemble, language detection, stopword removal, and configurable token replacement.
5
5
  Author: Rehan Fazal
6
6
  License-Expression: MIT
@@ -308,7 +308,7 @@ lm_text, stat_text, lang = cleaner.process(
308
308
  # Entities are anonymized: names, SSNs, emails, dates of birth, and 50+ more PII types
309
309
  ```
310
310
 
311
- PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets threshold to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
311
+ PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets `gliner_threshold` and the final `ner_confidence_threshold` to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
312
312
 
313
313
  **Alternative PII models** (pass as `gliner_model`):
314
314
 
@@ -592,11 +592,14 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
592
592
  | `check_replace_html` | `True` | Strip/replace HTML tags |
593
593
  | `check_replace_urls` | `True` | Replace URLs with token |
594
594
  | `check_replace_emails` | `True` | Replace emails with token |
595
+ | `check_replace_ips` | `True` | Replace IPv4/IPv6 addresses (runs before dates, phones, years) |
596
+ | `replace_ips_public_only` | `False` | Keep private, loopback and reserved IPs; replace only public ones |
595
597
  | `check_replace_years` | `True` | Replace years (1900-2099) |
596
598
  | `check_replace_dates` | `False` | Replace full dates (ISO 8601, month names) |
597
599
  | `check_fuzzy_replace_dates` | `False` | Fuzzy match misspelled months (requires `[fuzzy]`) |
598
600
  | `check_replace_phone_numbers` | `True` | Replace phone numbers |
599
601
  | `check_replace_numbers` | `True` | Replace standalone numbers |
602
+ | `replace_numbers_in_words` | `False` | Also replace digits inside Latin/Greek/Cyrillic words (`GPT4` to `GPT<NUMBER>`) |
600
603
  | `check_replace_currency_symbols` | `True` | Replace currency symbols |
601
604
  | `check_ner_process` | `True` | Run NER entity recognition |
602
605
  | `check_remove_isolated_letters` | `True` | Remove single letters |
@@ -619,6 +622,7 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
619
622
  | `replace_with_url` | `"<URL>"` |
620
623
  | `replace_with_html` | `"<HTML>"` |
621
624
  | `replace_with_email` | `"<EMAIL>"` |
625
+ | `replace_with_ips` | `"<IP>"` |
622
626
  | `replace_with_years` | `"<YEAR>"` |
623
627
  | `replace_with_dates` | `"<DATE>"` |
624
628
  | `replace_with_phone_numbers` | `"<PHONE>"` |
@@ -686,6 +690,11 @@ print(result.metadata["findings"])
686
690
  print(result.metadata["detector"])
687
691
  ```
688
692
 
693
+ Finding offsets (`start`, `end`) and NER entries in the reversible map are
694
+ document-relative positions in the post-regex text: the text after the regex
695
+ stages replaced URLs, emails, phones and the like. They are not offsets into the
696
+ raw input.
697
+
689
698
  Reversible output is pseudonymized personal data. Anyone with the token map can
690
699
  restore the source values. Keep token maps outside logs and cleaned datasets.
691
700
 
@@ -229,7 +229,7 @@ lm_text, stat_text, lang = cleaner.process(
229
229
  # Entities are anonymized: names, SSNs, emails, dates of birth, and 50+ more PII types
230
230
  ```
231
231
 
232
- PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets threshold to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
232
+ PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets `gliner_threshold` and the final `ner_confidence_threshold` to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
233
233
 
234
234
  **Alternative PII models** (pass as `gliner_model`):
235
235
 
@@ -513,11 +513,14 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
513
513
  | `check_replace_html` | `True` | Strip/replace HTML tags |
514
514
  | `check_replace_urls` | `True` | Replace URLs with token |
515
515
  | `check_replace_emails` | `True` | Replace emails with token |
516
+ | `check_replace_ips` | `True` | Replace IPv4/IPv6 addresses (runs before dates, phones, years) |
517
+ | `replace_ips_public_only` | `False` | Keep private, loopback and reserved IPs; replace only public ones |
516
518
  | `check_replace_years` | `True` | Replace years (1900-2099) |
517
519
  | `check_replace_dates` | `False` | Replace full dates (ISO 8601, month names) |
518
520
  | `check_fuzzy_replace_dates` | `False` | Fuzzy match misspelled months (requires `[fuzzy]`) |
519
521
  | `check_replace_phone_numbers` | `True` | Replace phone numbers |
520
522
  | `check_replace_numbers` | `True` | Replace standalone numbers |
523
+ | `replace_numbers_in_words` | `False` | Also replace digits inside Latin/Greek/Cyrillic words (`GPT4` to `GPT<NUMBER>`) |
521
524
  | `check_replace_currency_symbols` | `True` | Replace currency symbols |
522
525
  | `check_ner_process` | `True` | Run NER entity recognition |
523
526
  | `check_remove_isolated_letters` | `True` | Remove single letters |
@@ -540,6 +543,7 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
540
543
  | `replace_with_url` | `"<URL>"` |
541
544
  | `replace_with_html` | `"<HTML>"` |
542
545
  | `replace_with_email` | `"<EMAIL>"` |
546
+ | `replace_with_ips` | `"<IP>"` |
543
547
  | `replace_with_years` | `"<YEAR>"` |
544
548
  | `replace_with_dates` | `"<DATE>"` |
545
549
  | `replace_with_phone_numbers` | `"<PHONE>"` |
@@ -607,6 +611,11 @@ print(result.metadata["findings"])
607
611
  print(result.metadata["detector"])
608
612
  ```
609
613
 
614
+ Finding offsets (`start`, `end`) and NER entries in the reversible map are
615
+ document-relative positions in the post-regex text: the text after the regex
616
+ stages replaced URLs, emails, phones and the like. They are not offsets into the
617
+ raw input.
618
+
610
619
  Reversible output is pseudonymized personal data. Anyone with the token map can
611
620
  restore the source values. Keep token maps outside logs and cleaned datasets.
612
621
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SqueakyCleanText
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Text preprocessing & PII anonymization pipeline for NLP/ML: ONNX NER ensemble, language detection, stopword removal, and configurable token replacement.
5
5
  Author: Rehan Fazal
6
6
  License-Expression: MIT
@@ -308,7 +308,7 @@ lm_text, stat_text, lang = cleaner.process(
308
308
  # Entities are anonymized: names, SSNs, emails, dates of birth, and 50+ more PII types
309
309
  ```
310
310
 
311
- PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets threshold to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
311
+ PII mode auto-configures: `ner_backend='gliner'`, uses [`knowledgator/gliner-pii-base-v1.0`](https://huggingface.co/knowledgator/gliner-pii-base-v1.0), sets `gliner_threshold` and the final `ner_confidence_threshold` to 0.3 (recall-focused), and expands positional tags. User-provided values always take priority.
312
312
 
313
313
  **Alternative PII models** (pass as `gliner_model`):
314
314
 
@@ -592,11 +592,14 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
592
592
  | `check_replace_html` | `True` | Strip/replace HTML tags |
593
593
  | `check_replace_urls` | `True` | Replace URLs with token |
594
594
  | `check_replace_emails` | `True` | Replace emails with token |
595
+ | `check_replace_ips` | `True` | Replace IPv4/IPv6 addresses (runs before dates, phones, years) |
596
+ | `replace_ips_public_only` | `False` | Keep private, loopback and reserved IPs; replace only public ones |
595
597
  | `check_replace_years` | `True` | Replace years (1900-2099) |
596
598
  | `check_replace_dates` | `False` | Replace full dates (ISO 8601, month names) |
597
599
  | `check_fuzzy_replace_dates` | `False` | Fuzzy match misspelled months (requires `[fuzzy]`) |
598
600
  | `check_replace_phone_numbers` | `True` | Replace phone numbers |
599
601
  | `check_replace_numbers` | `True` | Replace standalone numbers |
602
+ | `replace_numbers_in_words` | `False` | Also replace digits inside Latin/Greek/Cyrillic words (`GPT4` to `GPT<NUMBER>`) |
600
603
  | `check_replace_currency_symbols` | `True` | Replace currency symbols |
601
604
  | `check_ner_process` | `True` | Run NER entity recognition |
602
605
  | `check_remove_isolated_letters` | `True` | Remove single letters |
@@ -619,6 +622,7 @@ new_cfg = dataclasses.replace(cfg, check_ner_process=False)
619
622
  | `replace_with_url` | `"<URL>"` |
620
623
  | `replace_with_html` | `"<HTML>"` |
621
624
  | `replace_with_email` | `"<EMAIL>"` |
625
+ | `replace_with_ips` | `"<IP>"` |
622
626
  | `replace_with_years` | `"<YEAR>"` |
623
627
  | `replace_with_dates` | `"<DATE>"` |
624
628
  | `replace_with_phone_numbers` | `"<PHONE>"` |
@@ -686,6 +690,11 @@ print(result.metadata["findings"])
686
690
  print(result.metadata["detector"])
687
691
  ```
688
692
 
693
+ Finding offsets (`start`, `end`) and NER entries in the reversible map are
694
+ document-relative positions in the post-regex text: the text after the regex
695
+ stages replaced URLs, emails, phones and the like. They are not offsets into the
696
+ raw input.
697
+
689
698
  Reversible output is pseudonymized personal data. Anyone with the token map can
690
699
  restore the source values. Keep token maps outside logs and cleaned datasets.
691
700
 
@@ -39,8 +39,11 @@ sct/utils/stopwords.py
39
39
  sct/utils/synthetic.py
40
40
  sct/utils/torch_pipeline.py
41
41
  tests/test_adapters.py
42
+ tests/test_benchmark_release.py
42
43
  tests/test_browser.py
44
+ tests/test_chunking.py
43
45
  tests/test_cli.py
46
+ tests/test_config_state.py
44
47
  tests/test_contracts.py
45
48
  tests/test_evaluation.py
46
49
  tests/test_functional.py
@@ -48,6 +51,7 @@ tests/test_mcp_server.py
48
51
  tests/test_model_benchmarks.py
49
52
  tests/test_ocr.py
50
53
  tests/test_privacy.py
54
+ tests/test_regex_boundaries.py
51
55
  tests/test_risk.py
52
56
  tests/test_sct.py
53
57
  tests/test_streaming.py
@@ -49,7 +49,7 @@ from sct.streaming import (
49
49
  from sct.utils.anonymization_map import AnonymizationMap, MapEntry
50
50
  from sct.utils.process_result import ProcessResult
51
51
 
52
- __version__ = "0.7.0"
52
+ __version__ = "0.7.1"
53
53
  __all__ = [
54
54
  "TextCleaner", "TextCleanerConfig",
55
55
  "PII_LABELS", "PII_LABEL_MAP",
@@ -6,6 +6,7 @@ from typing import Any, Iterable, Mapping, Optional
6
6
 
7
7
  from sct.privacy import EntityFinding
8
8
  from sct.sct import TextCleaner
9
+ from sct.utils import constants
9
10
 
10
11
 
11
12
  @dataclass(frozen=True)
@@ -75,10 +76,16 @@ def is_sensitive_result(
75
76
  and result.lm_text != source
76
77
  ):
77
78
  return True
79
+ # The HTML step rewrites marker-like tags already in the source, so they
80
+ # cannot offset markers emitted later (``<EMAIL> Alice`` -> ``<PERSON>``).
81
+ baseline = source
82
+ if cleaner.cfg.check_replace_html:
83
+ baseline = constants.HTML_REGEX.sub("", source)
78
84
  configured_markers = (
79
85
  cleaner.cfg.replace_with_url,
80
86
  cleaner.cfg.replace_with_html,
81
87
  cleaner.cfg.replace_with_email,
88
+ cleaner.cfg.replace_with_ips,
82
89
  cleaner.cfg.replace_with_years,
83
90
  cleaner.cfg.replace_with_dates,
84
91
  cleaner.cfg.replace_with_phone_numbers,
@@ -88,14 +95,15 @@ def is_sensitive_result(
88
95
  if any(
89
96
  isinstance(marker, str)
90
97
  and marker
91
- and marker in result.lm_text
92
- and marker not in source
98
+ and result.lm_text.count(marker) > baseline.count(marker)
93
99
  for marker in configured_markers
94
100
  ):
95
101
  return True
96
- return bool(
97
- re.search(r"<[A-Z][A-Z0-9_]*>", result.lm_text)
98
- and not re.search(r"<[A-Z][A-Z0-9_]*>", source)
102
+ # Compare counts, not presence: a source that already holds a marker
103
+ # still gains one per replacement.
104
+ marker_pattern = r"<[A-Z][A-Z0-9_]*>"
105
+ return len(re.findall(marker_pattern, result.lm_text)) > len(
106
+ re.findall(marker_pattern, baseline)
99
107
  )
100
108
 
101
109
 
@@ -122,9 +130,10 @@ def process_json(cleaner: TextCleaner, value: Any) -> StructuredResult:
122
130
  return result.lm_text
123
131
  if isinstance(item, dict):
124
132
  processed = {}
125
- for key, child in item.items():
133
+ for index, (key, child) in enumerate(item.items()):
134
+ # The raw key may itself be PII, so its path uses the index.
126
135
  processed_key = (
127
- visit(key, path + ("@key", key))
136
+ visit(key, path + ("@key", index))
128
137
  if isinstance(key, str) else key
129
138
  )
130
139
  if processed_key in processed:
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  from sct.config import TextCleanerConfig
9
9
  from sct.utils import constants
10
+ from sct.utils.contact import numbers_regex
10
11
 
11
12
 
12
13
  def _rule(pattern, replacement, enabled: bool) -> dict[str, Any]:
@@ -30,6 +31,51 @@ def build_browser_manifest(config: TextCleanerConfig) -> dict[str, Any]:
30
31
  "Browser export currently supports placeholder replacement only"
31
32
  )
32
33
 
34
+ if config.check_replace_ips and config.replace_ips_public_only:
35
+ raise ValueError(
36
+ "Browser export does not support replace_ips_public_only=True"
37
+ )
38
+
39
+ # The browser runs rules in order with nothing protecting emitted tokens,
40
+ # so a digit in one ("<EMAIL1>") would be rewritten by the number rules.
41
+ tokens = (
42
+ config.replace_with_html, config.replace_with_url, config.replace_with_email,
43
+ config.replace_with_ips, config.replace_with_dates,
44
+ config.replace_with_phone_numbers, config.replace_with_years,
45
+ )
46
+ if any(ch.isdigit() for token in tokens for ch in token):
47
+ raise ValueError("Browser export requires replacement tokens without digits")
48
+ if config.check_replace_currency_symbols and any(
49
+ constants.CURRENCY_REGEX.search(token) for token in tokens
50
+ ):
51
+ raise ValueError(
52
+ "Browser export requires replacement tokens without currency symbols"
53
+ )
54
+
55
+ # Same reason for any token a later enabled rule would match
56
+ # (an HTML token holding an email or URL, a URL token holding an email).
57
+ ordered = [
58
+ ("html", constants.HTML_REGEX, config.replace_with_html, config.check_replace_html),
59
+ ("url", constants.URL_REGEX, config.replace_with_url, config.check_replace_urls),
60
+ ("email", constants.EMAIL_REGEX, config.replace_with_email, config.check_replace_emails),
61
+ ("ip", constants.IP_REGEX, config.replace_with_ips, config.check_replace_ips),
62
+ ("date", constants.DATE_REGEX, config.replace_with_dates, config.check_replace_dates),
63
+ ("phone", constants.PHONE_REGEX, config.replace_with_phone_numbers,
64
+ config.check_replace_phone_numbers),
65
+ ("year", constants.YEAR_REGEX, config.replace_with_years, config.check_replace_years),
66
+ ("number", numbers_regex(config.replace_numbers_in_words), config.replace_with_numbers,
67
+ config.check_replace_numbers),
68
+ ("currency", constants.CURRENCY_REGEX, config.replace_with_currency_symbols,
69
+ config.check_replace_currency_symbols),
70
+ ]
71
+ for i, (name, _, token, enabled) in enumerate(ordered):
72
+ for later, pattern, _, later_enabled in ordered[i + 1:]:
73
+ if enabled and later_enabled and pattern.search(token):
74
+ raise ValueError(
75
+ f"Browser export: the {name} replacement token would be "
76
+ f"rewritten by the later {later} rule"
77
+ )
78
+
33
79
  model_revisions = config.ner_model_revisions or {}
34
80
  unpinned_models = sorted(
35
81
  key
@@ -54,7 +100,7 @@ def build_browser_manifest(config: TextCleanerConfig) -> dict[str, Any]:
54
100
  "runtime": "onnxruntime-web",
55
101
  "models": models,
56
102
  "rule_order": [
57
- "html", "url", "email", "date", "year", "phone", "number",
103
+ "html", "url", "email", "ip", "date", "phone", "year", "number",
58
104
  "currency",
59
105
  ],
60
106
  "rules": {
@@ -73,6 +119,11 @@ def build_browser_manifest(config: TextCleanerConfig) -> dict[str, Any]:
73
119
  config.replace_with_email,
74
120
  config.check_replace_emails,
75
121
  ),
122
+ "ip": _rule(
123
+ constants.IP_REGEX,
124
+ config.replace_with_ips,
125
+ config.check_replace_ips,
126
+ ),
76
127
  "date": _rule(
77
128
  constants.DATE_REGEX,
78
129
  config.replace_with_dates,
@@ -89,7 +140,7 @@ def build_browser_manifest(config: TextCleanerConfig) -> dict[str, Any]:
89
140
  config.check_replace_phone_numbers,
90
141
  ),
91
142
  "number": _rule(
92
- constants.NUMBERS_REGEX,
143
+ numbers_regex(config.replace_numbers_in_words),
93
144
  config.replace_with_numbers,
94
145
  config.check_replace_numbers,
95
146
  ),
@@ -287,6 +287,12 @@ class TextCleanerConfig:
287
287
  # Computed at __post_init__ — union of all language sources
288
288
  supported_languages: frozenset = frozenset()
289
289
 
290
+ # Added in 0.7.1; kept last so existing positional arguments keep their meaning.
291
+ check_replace_ips: bool = True
292
+ replace_ips_public_only: bool = False # True keeps private/loopback/reserved IPs
293
+ replace_with_ips: str = "<IP>"
294
+ replace_numbers_in_words: bool = False # True also replaces digits glued to Latin/Greek/Cyrillic letters (GPT4)
295
+
290
296
  def __post_init__(self):
291
297
  if self.gliclass_onnx:
292
298
  raise ValueError(
@@ -357,6 +363,11 @@ class TextCleanerConfig:
357
363
  object.__setattr__(self, 'gliner_labels', PII_LABELS)
358
364
  if self.gliner_threshold == 0.4:
359
365
  object.__setattr__(self, 'gliner_threshold', PII_DEFAULT_THRESHOLD)
366
+ # The final ensemble filter must not drop what GLiNER kept.
367
+ if self.ner_confidence_threshold == 0.85:
368
+ object.__setattr__(
369
+ self, 'ner_confidence_threshold', self.gliner_threshold,
370
+ )
360
371
  if self.gliner_label_map is None:
361
372
  object.__setattr__(self, 'gliner_label_map', PII_LABEL_MAP)
362
373
  # Expand positional_tags to include all PII label tags
@@ -439,7 +450,17 @@ class TextCleanerConfig:
439
450
  f"ner_models must include {sorted(REQUIRED_NER_KEYS)}, "
440
451
  f"missing: {sorted(missing)}"
441
452
  )
442
- merged = {**DEFAULT_NER_MODELS, **self.ner_models}
453
+ # A map of exactly LANG_KEYS matching ner_models_list is a normalized
454
+ # legacy/default config (replace(), copy): keep it exact. Decided by
455
+ # content only, so a dict and a mapping proxy give the same config.
456
+ derived = (
457
+ set(self.ner_models) == set(LANG_KEYS)
458
+ and tuple(self.ner_models[k] for k in LANG_KEYS) == self.ner_models_list
459
+ )
460
+ merged = (
461
+ dict(self.ner_models) if derived
462
+ else {**DEFAULT_NER_MODELS, **self.ner_models}
463
+ )
443
464
  object.__setattr__(self, 'ner_models', MappingProxyType(merged))
444
465
  object.__setattr__(
445
466
  self, 'ner_models_list',
@@ -532,6 +553,21 @@ class TextCleanerConfig:
532
553
  f"{sorted(self.supported_languages)}"
533
554
  )
534
555
 
556
+ def __getstate__(self):
557
+ # Restore the exact normalized state; never re-run __post_init__.
558
+ state = dict(self.__dict__)
559
+ frozen = [k for k, v in state.items() if isinstance(v, MappingProxyType)]
560
+ for key in frozen:
561
+ state[key] = dict(state[key])
562
+ return state, frozen
563
+
564
+ def __setstate__(self, packed):
565
+ state, frozen = packed
566
+ for key, value in state.items():
567
+ if key in frozen:
568
+ value = MappingProxyType(value)
569
+ object.__setattr__(self, key, value)
570
+
535
571
 
536
572
  def _config_from_module_globals() -> TextCleanerConfig:
537
573
  """Build a TextCleanerConfig from the current module-level variable state.
@@ -546,12 +582,15 @@ def _config_from_module_globals() -> TextCleanerConfig:
546
582
  check_replace_html=m.CHECK_REPLACE_HTML,
547
583
  check_replace_urls=m.CHECK_REPLACE_URLS,
548
584
  check_replace_emails=m.CHECK_REPLACE_EMAILS,
585
+ check_replace_ips=getattr(m, 'CHECK_REPLACE_IPS', True),
586
+ replace_ips_public_only=getattr(m, 'REPLACE_IPS_PUBLIC_ONLY', False),
549
587
  check_replace_years=m.CHECK_REPLACE_YEARS,
550
588
  check_replace_dates=getattr(m, 'CHECK_REPLACE_DATES', False),
551
589
  check_fuzzy_replace_dates=getattr(m, 'CHECK_FUZZY_REPLACE_DATES', False),
552
590
  fuzzy_date_score_cutoff=getattr(m, 'FUZZY_DATE_SCORE_CUTOFF', 85),
553
591
  check_replace_phone_numbers=m.CHECK_REPLACE_PHONE_NUMBERS,
554
592
  check_replace_numbers=m.CHECK_REPLACE_NUMBERS,
593
+ replace_numbers_in_words=getattr(m, 'REPLACE_NUMBERS_IN_WORDS', False),
555
594
  check_replace_currency_symbols=m.CHECK_REPLACE_CURRENCY_SYMBOLS,
556
595
  check_ner_process=m.CHECK_NER_PROCESS,
557
596
  check_remove_isolated_letters=m.CHECK_REMOVE_ISOLATED_LETTERS,
@@ -569,6 +608,7 @@ def _config_from_module_globals() -> TextCleanerConfig:
569
608
  replace_with_url=m.REPLACE_WITH_URL,
570
609
  replace_with_html=m.REPLACE_WITH_HTML,
571
610
  replace_with_email=m.REPLACE_WITH_EMAIL,
611
+ replace_with_ips=getattr(m, 'REPLACE_WITH_IPS', '<IP>'),
572
612
  replace_with_years=m.REPLACE_WITH_YEARS,
573
613
  replace_with_dates=getattr(m, 'REPLACE_WITH_DATES', '<DATE>'),
574
614
  replace_with_phone_numbers=m.REPLACE_WITH_PHONE_NUMBERS,
@@ -597,12 +637,15 @@ CHECK_TO_ASCII_UNICODE = True
597
637
  CHECK_REPLACE_HTML = True
598
638
  CHECK_REPLACE_URLS = True
599
639
  CHECK_REPLACE_EMAILS = True
640
+ CHECK_REPLACE_IPS = True
641
+ REPLACE_IPS_PUBLIC_ONLY = False
600
642
  CHECK_REPLACE_YEARS = True
601
643
  CHECK_REPLACE_DATES = False
602
644
  CHECK_FUZZY_REPLACE_DATES = False
603
645
  FUZZY_DATE_SCORE_CUTOFF = 85
604
646
  CHECK_REPLACE_PHONE_NUMBERS = True
605
647
  CHECK_REPLACE_NUMBERS = True
648
+ REPLACE_NUMBERS_IN_WORDS = False
606
649
  CHECK_REPLACE_CURRENCY_SYMBOLS = True
607
650
  CHECK_NER_PROCESS = True
608
651
  CHECK_REMOVE_ISOLATED_LETTERS = True
@@ -621,6 +664,7 @@ CHECK_REMOVE_EMOJI = False
621
664
  REPLACE_WITH_URL = "<URL>"
622
665
  REPLACE_WITH_HTML = "<HTML>"
623
666
  REPLACE_WITH_EMAIL = "<EMAIL>"
667
+ REPLACE_WITH_IPS = "<IP>"
624
668
  REPLACE_WITH_YEARS = "<YEAR>"
625
669
  REPLACE_WITH_DATES = "<DATE>"
626
670
  REPLACE_WITH_PHONE_NUMBERS = "<PHONE>"
@@ -56,6 +56,7 @@ def candidate_config(candidate: ModelCandidate) -> TextCleanerConfig:
56
56
  "check_replace_html": False,
57
57
  "check_replace_urls": False,
58
58
  "check_replace_emails": False,
59
+ "check_replace_ips": False,
59
60
  "check_replace_years": False,
60
61
  "check_replace_dates": False,
61
62
  "check_replace_phone_numbers": False,
@@ -82,6 +83,7 @@ def candidate_config(candidate: ModelCandidate) -> TextCleanerConfig:
82
83
  gliner_labels=PII_LABELS,
83
84
  gliner_label_map=PII_LABEL_MAP,
84
85
  gliner_threshold=0.3,
86
+ ner_confidence_threshold=0.3,
85
87
  gliner_onnx=candidate.supports_onnx,
86
88
  )
87
89
  if candidate.backend == "torch":
@@ -42,7 +42,11 @@ class InMemoryTokenStore:
42
42
 
43
43
  @dataclass(frozen=True)
44
44
  class DetectedEntity:
45
- """One entity produced by a detector."""
45
+ """One entity produced by a detector.
46
+
47
+ ``start``/``end`` index the post-regex text (after the regex replacement
48
+ stages ran), not the raw input passed to ``process()``.
49
+ """
46
50
 
47
51
  entity_type: str
48
52
  score: float