Instructions to use SlayerLab/NERGAL with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use SlayerLab/NERGAL with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="SlayerLab/NERGAL")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("SlayerLab/NERGAL") model = AutoModelForTokenClassification.from_pretrained("SlayerLab/NERGAL", device_map="auto") - Notebooks
- Google Colab
- Kaggle
2.0.3: values cut or preceded by a placeholder
Browse files- CHANGELOG.md +18 -0
- README.md +8 -7
- hybrid.json +2 -2
- nergal.py +2 -2
- scrub_pii.py +42 -6
- test_nergal.py +54 -2
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ Semver for this island:
|
|
| 8 |
|
| 9 |
Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
## 2.0.2
|
| 12 |
|
| 13 |
Same NERGAL weights, threshold and API. Rules SHA `205a253b…`: an unlabelled PESEL span is marked `bare`; every rule span keeps its start, end and label. With one union change in `nergal.py`, a labelled identifier keeps `[PII]`.
|
|
|
|
| 8 |
|
| 9 |
Accuracy is 841-dev, union at 0.95. Through 1.1.2 the gold has 354 spans; from 1.2.0 it is restated to phone policy v3 (315 spans), and the two are not comparable. A version that changes those numbers must update `hybrid.json` `eval` and the tables below.
|
| 10 |
|
| 11 |
+
## 2.0.3
|
| 12 |
+
|
| 13 |
+
Same NERGAL weights, threshold, union and API. Rules SHA `4d054723…`: input that already holds a `[PII]`, `[PHONE]` or `[Telefon]` placeholder.
|
| 14 |
+
|
| 15 |
+
- **A NIP or REGON label reaches past one placeholder.** `NIP [PII] 526-000-12-46`, `NIP: [PII], 526-000-12-46.` and `REGON [PII] 123456785` now mask the number; in 2.0.2 the placeholder ended the label, and a bare NIP or REGON is never masked. The value still needs a valid checksum, and a second placeholder ends the reach. PESEL, account and email need no label, so they were already masked. The placeholder can also be one this scrub made: a second checksum-valid NIP or REGON after a comma or space is masked too (`NIP 526-000-12-46, 5213017228` → `NIP [PII], [PII]`); after `i` or `/` it is not.
|
| 16 |
+
- **A labelled value cut by a placeholder is masked.** A run of digit groups and placeholders joined by single spaces, tabs or hyphens, with 5+ digits and a `PESEL`, `NIP` or `REGON` label up to 64 characters before it, has every digit fragment masked `[PII]`: `PESEL 8503[PII]1212343` → `PESEL [PII][PII][PII]`, `NIP 526-000-[PII]-46` → `NIP [PII]-[PII]-[PII]`. A fragment has no checksum to test, so the label is required.
|
| 17 |
+
- **A phone cut by a placeholder is masked only after a phone cue.** The same run after a contact or phone label gives `[PHONE]` fragments: `tel. 601 [PII] 200 300` → `tel. [PHONE] [PII] [PHONE]`, `tel. 601 200 [PHONE]` → `tel. [PHONE] [PHONE]`. Spaced digit groups next to a placeholder are also pages, amounts and counts, so without a cue `601 [PII] 200 300` is left as is; a postcode, date or amount fragment never counts as a phone.
|
| 18 |
+
- **Existing placeholders and offsets are kept.** Only digits are replaced; placeholders and separators stay, and span offsets refer to the input text. This pass runs before every other rule, so every placeholder it sees came with the input.
|
| 19 |
+
|
| 20 |
+
Still missed: an unlabelled split value without a cue (`8503[PII]1212343`, `601 [PII] 200 300`); a run that starts with a placeholder (`tel. [PHONE] 200 300`); a run with fewer than 5 digits (`PESEL 1234 [PII]`).
|
| 21 |
+
|
| 22 |
+
841-dev, names off: unchanged from 2.0.2 (no 841-dev passage holds a placeholder). Release gates (`artifacts/nergal_2_0_3_placeholder/`, frozen before scoring, stored spans): the 1,837 bench rows, the 1,000-document Dynaword runtime sample (names off and on) and 41,204 FineWeb miss-hunt passages, rule spans and output identical, 0 masked characters lost; every on-disk Dynaword source, full text, rule spans identical in all 3,378,438 documents, the 997 with a `[PII]` or `[Telefon]` placeholder included. No labelled set or corpus document changes, so the new behaviour is exercised only by the invented cases in `test_nergal.py`.
|
| 23 |
+
|
| 24 |
+
| Version | Whole /315 | Residual | Rules FP | Union FP | Char P | Char R | What changed |
|
| 25 |
+
|---|---:|---:|---:|---:|---:|---:|---|
|
| 26 |
+
| 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
|
| 27 |
+
| 2.0.3, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Values cut or preceded by a placeholder |
|
| 28 |
+
|
| 29 |
## 2.0.2
|
| 30 |
|
| 31 |
Same NERGAL weights, threshold and API. Rules SHA `205a253b…`: an unlabelled PESEL span is marked `bare`; every rule span keeps its start, end and label. With one union change in `nergal.py`, a labelled identifier keeps `[PII]`.
|
README.md
CHANGED
|
@@ -16,7 +16,7 @@ tags:
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
-
# NERGAL 2.0.
|
| 20 |
|
| 21 |
**Named Entity Recognition with Grounded Additive Labels**
|
| 22 |
|
|
@@ -66,9 +66,9 @@ NERGAL is not designed to remove:
|
|
| 66 |
|
| 67 |
These are intended exclusions; false positives can still mask some of this content.
|
| 68 |
|
| 69 |
-
### Known gaps in 2.0.
|
| 70 |
|
| 71 |
-
Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.
|
| 72 |
|
| 73 |
## Versions
|
| 74 |
|
|
@@ -80,7 +80,8 @@ Unlabelled phones and identifiers (the rules take a bare phone only in the group
|
|
| 80 |
| 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
|
| 81 |
| 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
|
| 82 |
| 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
|
| 83 |
-
|
|
|
|
|
| 84 |
|
| 85 |
1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
|
| 86 |
|
|
@@ -111,7 +112,7 @@ Names off hides no name. A component is the independent unit, covered when every
|
|
| 111 |
|
| 112 |
## Compared with other systems
|
| 113 |
|
| 114 |
-
Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0
|
| 115 |
|
| 116 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 117 |
|---|---|---:|---:|---:|---:|---:|
|
|
@@ -121,9 +122,9 @@ Same split and gold. Every model runs through the NERGAL recipe with names off:
|
|
| 121 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 122 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 123 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 124 |
-
| **NERGAL 2.0.
|
| 125 |
|
| 126 |
-
Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.
|
| 127 |
|
| 128 |
XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
|
| 129 |
|
|
|
|
| 16 |
- hybrid
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# NERGAL 2.0.3
|
| 20 |
|
| 21 |
**Named Entity Recognition with Grounded Additive Labels**
|
| 22 |
|
|
|
|
| 66 |
|
| 67 |
These are intended exclusions; false positives can still mask some of this content.
|
| 68 |
|
| 69 |
+
### Known gaps in 2.0.3
|
| 70 |
|
| 71 |
+
Unlabelled phones and identifiers (the rules take a bare phone only in the grouped national forms `601 234 567` and `22 123 45 67`; a plain `601234567` needs a label or the model), unusual formatting and damaged text can escape detection. **VINs and obfuscated emails** (such as `name (at) domain.pl`) are approved annotation targets, but that approval alone does not establish reliable support in the released 2.0.3 model. Do not rely on it to remove them consistently. An email with a lowercase word glued onto its domain (`jan@firma.plkontakt`) is masked together with that word, and text glued before an address can be masked with it. A phone written as short parts joined by two slashes (`12 / 345 / 678`) is left as text. Input that already holds a `[PII]` / `[PHONE]` / `[Telefon]` placeholder: a NIP or REGON label reaches past one placeholder to a checksum-valid value, and a value cut by a placeholder is masked digit group by digit group only after a `PESEL` / `NIP` / `REGON` label (`[PII]`) or a phone cue (`[PHONE]`). Without either (`8503[PII]1212343`, `601 [PII] 200 300`), with fewer than 5 digits, or when the run starts with a placeholder (`tel. [PHONE] 200 300`), the fragments stay. Names-on gaps are under [Names](#names).
|
| 72 |
|
| 73 |
## Versions
|
| 74 |
|
|
|
|
| 80 |
| 1.2.0 | 303 | 9 | 24 | 80 | 98.59% | 97.83% | Phone policy v3 at mask time, rules and model spans |
|
| 81 |
| 2.0.0, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | `[PHONE]` replaces `[Telefon]`; opt-in `[PERSON]` names |
|
| 82 |
| 2.0.1, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A `[PERSON]` placeholder ends an other-number label |
|
| 83 |
+
| 2.0.2, names off | 303 | 9 | 24 | 80 | 98.59% | 97.83% | A rule's label wins on its characters |
|
| 84 |
+
| **2.0.3, names off** | **303** | **9** | **24** | **80** | **98.59%** | **97.83%** | Values cut or preceded by a placeholder |
|
| 85 |
|
| 86 |
1.0.0–1.1.2 were scored on the earlier 354-span gold (whole 323 → 326, union FP 133 → 80); per-version numbers are in `CHANGELOG.md`.
|
| 87 |
|
|
|
|
| 112 |
|
| 113 |
## Compared with other systems
|
| 114 |
|
| 115 |
+
Same split and gold. Every model runs through the NERGAL recipe with names off: its spans at 0.95, phone spans cut by phone policy v3, unioned with the rules. The rows were scored with the 1.2.0 rules; 2.0.0 through 2.0.3 give the same rule spans on this split. **Naked** is the model alone. **Residual** is passages with any gold character left unmasked. Character scores are gold vs masked characters.
|
| 116 |
|
| 117 |
| System | Mode | Whole /315 | Residual | False chars | Char P | Char R |
|
| 118 |
|---|---|---:|---:|---:|---:|---:|
|
|
|
|
| 122 |
| Fine-tuned GLiNER (previous incumbent) | naked | 263 | 28 | 15 | 99.70% | 88.80% |
|
| 123 |
| Fine-tuned GLiNER (previous incumbent) | ∪ regex | 299 | 12 | 39 | 99.30% | 97.18% |
|
| 124 |
| XLM-R epoch 5 | naked | 275 | 28 | 67 | 98.72% | 90.25% |
|
| 125 |
+
| **NERGAL 2.0.3, names off** | **∪ regex** | **303** | **9** | **80** | **98.59%** | **97.83%** |
|
| 126 |
|
| 127 |
+
Zero-shot [GLiNER 2.5](https://huggingface.co/fastino/gliner2.5-multi-v1) is not competitive, especially on non-phone PII (7/185 whole, naked). The fine-tuned GLiNER that NERGAL replaced is the more precise union (39 false characters vs 80) and the stronger phone model (128/130 whole, naked, vs 115), but it masks 4 fewer values whole and leaves 12 residual passages vs 9; XLM-R is the stronger model on other PII (160/185 vs 135). NERGAL 2.0.3 wholly masks 124/130 phones and 179/185 other PII, each under its own placeholder (`[PHONE]` / `[PII]`); exact-span precision 91.05%, recall 93.65%, F1 92.33%.
|
| 128 |
|
| 129 |
XLM-R and epoch 5 were chosen in September 2026, on the earlier gold and before the phone-policy cut. Fine-tuned GLiNER, HerBERT-large and XLM-R-large were trained on the same split; only XLM-R passed the content-preservation gate, and epoch 5 at 0.95 (seed `202609160`) was the only one of 133 epoch/threshold points that covered more than the fine-tuned GLiNER union of that time while adding no false-mask characters it did not already make. Under the 1.2.0 rules, gold and phone cut, that no longer holds: the GLiNER union makes fewer false masks (table above).
|
| 130 |
|
hybrid.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
{
|
| 2 |
"full_name": "Named Entity Recognition with Grounded Additive Labels",
|
| 3 |
"hub_id": "SlayerLab/NERGAL",
|
| 4 |
-
"version": "2.0.
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
| 8 |
"threshold": 0.95,
|
| 9 |
-
"rules_sha256": "
|
| 10 |
"eval": {
|
| 11 |
"split": "841-dev",
|
| 12 |
"gold_amendments": [
|
|
|
|
| 1 |
{
|
| 2 |
"full_name": "Named Entity Recognition with Grounded Additive Labels",
|
| 3 |
"hub_id": "SlayerLab/NERGAL",
|
| 4 |
+
"version": "2.0.3",
|
| 5 |
"mode": "rules_union",
|
| 6 |
"epoch": 5,
|
| 7 |
"seed": 202609160,
|
| 8 |
"threshold": 0.95,
|
| 9 |
+
"rules_sha256": "4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a",
|
| 10 |
"eval": {
|
| 11 |
"split": "841-dev",
|
| 12 |
"gold_amendments": [
|
nergal.py
CHANGED
|
@@ -17,13 +17,13 @@ import scrub_pii
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
-
VERSION = '2.0.
|
| 21 |
GAPS = ['[PII_SPACE]', '[PII_BREAK]']
|
| 22 |
GAP_IDS = [250002, 250003]
|
| 23 |
BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
|
| 24 |
LABELS = ['phone', 'pii']
|
| 25 |
THRESHOLD = 0.95
|
| 26 |
-
RULES_SHA = '
|
| 27 |
DTYPES = ('float32', 'float16')
|
| 28 |
MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
|
| 29 |
TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending rank; a non-bare rule span outranks every model and person span
|
|
|
|
| 17 |
from scrub_pii import LEGACY_PHONE_TAG, PERSON_TAG, PHONE_TAG, PII_TAG
|
| 18 |
|
| 19 |
HUB_ID = 'SlayerLab/NERGAL'
|
| 20 |
+
VERSION = '2.0.3'
|
| 21 |
GAPS = ['[PII_SPACE]', '[PII_BREAK]']
|
| 22 |
GAP_IDS = [250002, 250003]
|
| 23 |
BIO_LABELS = ['O', 'B-phone', 'I-phone', 'B-pii', 'I-pii']
|
| 24 |
LABELS = ['phone', 'pii']
|
| 25 |
THRESHOLD = 0.95
|
| 26 |
+
RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
|
| 27 |
DTYPES = ('float32', 'float16')
|
| 28 |
MARKERS = (PHONE_TAG, PII_TAG, PERSON_TAG, LEGACY_PHONE_TAG)
|
| 29 |
TAGS = {'person': PERSON_TAG, 'pii': PII_TAG, 'phone': PHONE_TAG} # ascending rank; a non-bare rule span outranks every model and person span
|
scrub_pii.py
CHANGED
|
@@ -121,10 +121,12 @@ _DOCUMENT_RE = re.compile(
|
|
| 121 |
r"[:=-]?[ \t]*)"
|
| 122 |
r"(?P<number>[A-Z]{3}[ \t-]?[0-9OIl]{6}|[A-Z]{2}[ \t-]?[0-9OIl]{7}|[A-Z][ \t-]?[0-9]{6,9}|[0-9]{4,12})\b", re.I,
|
| 123 |
)
|
| 124 |
-
|
| 125 |
-
r"\b(?P<
|
| 126 |
r"(?:[ \t]+(?:Gminy|Powiatu|Miasta|firmy)(?:[ \t]+[^\W\d_][^\W\d_-]*){0,4})?[ \t]{0,8}"
|
| 127 |
-
r"(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?
|
|
|
|
|
|
|
| 128 |
r"(?P<number>(?:PL[ \t]*)?(?:[0-9OIl]{9,14}|\d(?:[ \t-]?\d){8,13}))"
|
| 129 |
r"(?!\w|[ \t-]*\d)", re.I,
|
| 130 |
)
|
|
@@ -140,8 +142,18 @@ _ID_LABEL_END = r"[ \t]{0,8}(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\
|
|
| 140 |
# Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
|
| 141 |
# Only checksum-gated NIP/REGON take it; KRS has no checksum.
|
| 142 |
_ID_LABEL_NOTE = r"(?:[ \t]{0,8}\*{1,3})?(?:[ \t]{0,8}\([^()\d\n]{1,40}\))?(?:[ \t]{0,8}\*{1,3})?"
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
_KRS_RE = re.compile(r"\b\d{6,10}(?!\w|[ \t-]*\d)")
|
| 146 |
# Abbreviation or written-out register name, optionally "pod nr/numerem".
|
| 147 |
_KRS_LABEL_RE = re.compile(
|
|
@@ -920,6 +932,30 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
|
|
| 920 |
counts['pin'] += 1
|
| 921 |
return m['label'] + mask(m.start('number'), m.end('number'), PII_TAG)
|
| 922 |
return _PIN_RE.sub(replace, value), None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
apply(pins)
|
| 924 |
foreign = apply(_replace_checked, _IBAN_RE, PII_TAG, _iban_ok)
|
| 925 |
bare = apply(_replace_checked, _ACCOUNT_RE, PII_TAG, _iban_ok)
|
|
@@ -951,7 +987,7 @@ def scrub_pii(text: str, *, spans: list | None = None) -> tuple[str, dict[str, i
|
|
| 951 |
counts["email"] += apply(_replace_checked, _EMAIL_RE, PII_TAG, lambda _: True)
|
| 952 |
counts["email"] += apply(_replace_checked, _EMAIL_FRAGMENT_RE, PII_TAG,
|
| 953 |
lambda _: True, _EMAIL_LABEL_RE)
|
| 954 |
-
counts["phone"] = apply(_replace_phones)
|
| 955 |
# Contact context takes precedence over coincidental PESEL checksums in
|
| 956 |
# foreign phone numbers; explicitly labelled identifiers were handled first.
|
| 957 |
first = len(spans) if spans is not None else 0
|
|
|
|
| 121 |
r"[:=-]?[ \t]*)"
|
| 122 |
r"(?P<number>[A-Z]{3}[ \t-]?[0-9OIl]{6}|[A-Z]{2}[ \t-]?[0-9OIl]{7}|[A-Z][ \t-]?[0-9]{6,9}|[0-9]{4,12})\b", re.I,
|
| 123 |
)
|
| 124 |
+
_LABELLED_ID_LABEL = (
|
| 125 |
+
r"\b(?P<kind>PESEL|NIP|REGON)\b"
|
| 126 |
r"(?:[ \t]+(?:Gminy|Powiatu|Miasta|firmy)(?:[ \t]+[^\W\d_][^\W\d_-]*){0,4})?[ \t]{0,8}"
|
| 127 |
+
r"(?:(?:nr\.?|numer)[ \t]{0,8})?[:=.\-]?[ \t]{0,8}(?:\r?\n[ \t]{0,8})?")
|
| 128 |
+
_LABELLED_ID_RE = re.compile(
|
| 129 |
+
r"(?P<label>" + _LABELLED_ID_LABEL + r")"
|
| 130 |
r"(?P<number>(?:PL[ \t]*)?(?:[0-9OIl]{9,14}|\d(?:[ \t-]?\d){8,13}))"
|
| 131 |
r"(?!\w|[ \t-]*\d)", re.I,
|
| 132 |
)
|
|
|
|
| 142 |
# Form labels may carry footnote asterisks and one short digit-free gloss: "NIP (Wykonawcy)**:".
|
| 143 |
# Only checksum-gated NIP/REGON take it; KRS has no checksum.
|
| 144 |
_ID_LABEL_NOTE = r"(?:[ \t]{0,8}\*{1,3})?(?:[ \t]{0,8}\([^()\d\n]{1,40}\))?(?:[ \t]{0,8}\*{1,3})?"
|
| 145 |
+
# One placeholder on the same line does not end their reach (2.0.3): "NIP [PII] 526-000-12-46", "NIP: [PII], 526…".
|
| 146 |
+
_ID_LABEL_SKIP = r"(?:[ \t]{0,8}[:=.\-]?[ \t]{0,8}(?:" + _BOUNDARY + r")[ \t]{0,3}[,;]?)?"
|
| 147 |
+
_NIP_LABEL_RE = re.compile(r"\bNIP\b" + _ID_LABEL_NOTE + _ID_LABEL_SKIP + _ID_LABEL_END, re.I)
|
| 148 |
+
_REGON_LABEL_RE = re.compile(r"\bREGON\b" + _ID_LABEL_NOTE + _ID_LABEL_SKIP + _ID_LABEL_END, re.I)
|
| 149 |
+
# A value cut by a placeholder (2.0.3): digit groups and placeholders joined by single spaces, tabs or hyphens,
|
| 150 |
+
# starting with a digit ("8503[PII]1212343", "601 [PII] 200 300", "526-000-[PII]-46"). A fragment has no checksum, so
|
| 151 |
+
# it is masked only after a PESEL/NIP/REGON label ([PII]) or a phone cue ([PHONE]), and only with 5+ digits in all.
|
| 152 |
+
_SPLIT_RE = re.compile(r"(?<![\w\]])(?:\+[ \t]?)?\d+(?:[ \t-]?(?:\d+|" + _BOUNDARY + r"))++(?!\w|[.,:/]\d)")
|
| 153 |
+
_SPLIT_FRAGMENT_RE = re.compile(r"(?:\+[ \t]?)?\d+(?:[ \t-]\d+)*")
|
| 154 |
+
_SPLIT_LABEL_RE = re.compile(_LABELLED_ID_LABEL + r"\Z", re.I)
|
| 155 |
+
_SPLIT_NOT_PHONE_RE = re.compile(r"\d{2}-\d{3}|\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}") # postcode, dates
|
| 156 |
+
_SPLIT_MIN_DIGITS = 5
|
| 157 |
_KRS_RE = re.compile(r"\b\d{6,10}(?!\w|[ \t-]*\d)")
|
| 158 |
# Abbreviation or written-out register name, optionally "pod nr/numerem".
|
| 159 |
_KRS_LABEL_RE = re.compile(
|
|
|
|
| 932 |
counts['pin'] += 1
|
| 933 |
return m['label'] + mask(m.start('number'), m.end('number'), PII_TAG)
|
| 934 |
return _PIN_RE.sub(replace, value), None
|
| 935 |
+
# First, so every placeholder it sees came with the input, not from this run's own masks.
|
| 936 |
+
def split_values(value, *, mask):
|
| 937 |
+
def replace(m):
|
| 938 |
+
run, start = m[0], m.start()
|
| 939 |
+
fragments = list(_SPLIT_FRAGMENT_RE.finditer(run))
|
| 940 |
+
if (not re.search(_BOUNDARY, run) or len(_digits(run)) < _SPLIT_MIN_DIGITS
|
| 941 |
+
or _is_amount(value, start, m.end())):
|
| 942 |
+
return run
|
| 943 |
+
label = _SPLIT_LABEL_RE.search(value, max(0, start - 64), start)
|
| 944 |
+
if label:
|
| 945 |
+
tag, kind = PII_TAG, label['kind'].lower()
|
| 946 |
+
elif (_phone_context(value, start, run)
|
| 947 |
+
and not any(_SPLIT_NOT_PHONE_RE.fullmatch(f[0]) for f in fragments)):
|
| 948 |
+
tag, kind = PHONE_TAG, 'phone'
|
| 949 |
+
else:
|
| 950 |
+
return run
|
| 951 |
+
counts[kind] += 1
|
| 952 |
+
out, pos = [], 0
|
| 953 |
+
for f in fragments:
|
| 954 |
+
out += [run[pos:f.start()], mask(start + f.start(), start + f.end(), tag)]
|
| 955 |
+
pos = f.end()
|
| 956 |
+
return ''.join(out) + run[pos:]
|
| 957 |
+
return _SPLIT_RE.sub(replace, value), None
|
| 958 |
+
apply(split_values)
|
| 959 |
apply(pins)
|
| 960 |
foreign = apply(_replace_checked, _IBAN_RE, PII_TAG, _iban_ok)
|
| 961 |
bare = apply(_replace_checked, _ACCOUNT_RE, PII_TAG, _iban_ok)
|
|
|
|
| 987 |
counts["email"] += apply(_replace_checked, _EMAIL_RE, PII_TAG, lambda _: True)
|
| 988 |
counts["email"] += apply(_replace_checked, _EMAIL_FRAGMENT_RE, PII_TAG,
|
| 989 |
lambda _: True, _EMAIL_LABEL_RE)
|
| 990 |
+
counts["phone"] += apply(_replace_phones)
|
| 991 |
# Contact context takes precedence over coincidental PESEL checksums in
|
| 992 |
# foreign phone numbers; explicitly labelled identifiers were handled first.
|
| 993 |
first = len(spans) if spans is not None else 0
|
test_nergal.py
CHANGED
|
@@ -7,7 +7,7 @@ import unittest
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
HERE = Path(__file__).resolve().parent
|
| 10 |
-
RULES_SHA = '
|
| 11 |
|
| 12 |
|
| 13 |
class NergalTests(unittest.TestCase):
|
|
@@ -15,7 +15,7 @@ class NergalTests(unittest.TestCase):
|
|
| 15 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 16 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 17 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 18 |
-
self.assertEqual(VERSION, '2.0.
|
| 19 |
self.assertEqual(card['version'], VERSION)
|
| 20 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 21 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
@@ -95,6 +95,43 @@ class NergalTests(unittest.TestCase):
|
|
| 95 |
with self.subTest(text=text):
|
| 96 |
self.assertEqual([text[s['start']:s['end']] for s in rules(text) if s['label'] == 'phone'], masked)
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def test_grouped_national_phones_mask_without_a_cue(self):
|
| 99 |
from nergal import rules
|
| 100 |
for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),
|
|
@@ -257,6 +294,21 @@ class NergalTests(unittest.TestCase):
|
|
| 257 |
}
|
| 258 |
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
def _names(self):
|
| 261 |
from nergal import Names
|
| 262 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
HERE = Path(__file__).resolve().parent
|
| 10 |
+
RULES_SHA = '4d0547238131908ca498a23607a7877512b2fb6022c484171334af1e604fc95a'
|
| 11 |
|
| 12 |
|
| 13 |
class NergalTests(unittest.TestCase):
|
|
|
|
| 15 |
from nergal import GAP_IDS, GAPS, HUB_ID, RULES_SHA as PINNED, THRESHOLD, VERSION
|
| 16 |
card = json.loads((HERE / 'hybrid.json').read_text())
|
| 17 |
self.assertEqual(HUB_ID, 'SlayerLab/NERGAL')
|
| 18 |
+
self.assertEqual(VERSION, '2.0.3')
|
| 19 |
self.assertEqual(card['version'], VERSION)
|
| 20 |
self.assertEqual(card['eval']['union_fp'], 80)
|
| 21 |
self.assertEqual(card['eval']['rules_fp'], 24)
|
|
|
|
| 95 |
with self.subTest(text=text):
|
| 96 |
self.assertEqual([text[s['start']:s['end']] for s in rules(text) if s['label'] == 'phone'], masked)
|
| 97 |
|
| 98 |
+
def test_a_placeholder_between_a_label_and_its_value_or_inside_a_value(self):
|
| 99 |
+
from nergal import apply_union, rules
|
| 100 |
+
from scrub_pii import scrub_pii
|
| 101 |
+
for text, masked in ( # invented; 5260001246 and 123456785 are checksum-valid
|
| 102 |
+
('NIP [PII] 526-000-12-46', 'NIP [PII] [PII]'), # the label reaches past one placeholder
|
| 103 |
+
('NIP: [PII], 526-000-12-46.', 'NIP: [PII], [PII].'),
|
| 104 |
+
('REGON [PII] 123456785', 'REGON [PII] [PII]'),
|
| 105 |
+
('NIP [PII]\n5260001246', 'NIP [PII]\n[PII]'),
|
| 106 |
+
('NIP [PII] 526-000-12-47', None), # still checksum-gated
|
| 107 |
+
('NIP [PII] [PII] 5260001246', None), # one placeholder only
|
| 108 |
+
('NIP 526-000-12-46, 5260001246', 'NIP [PII], [PII]'), # this scrub's own placeholder counts too
|
| 109 |
+
('NIP 526-000-12-46 i 5260001246', 'NIP [PII] i 5260001246'),
|
| 110 |
+
('PESEL 8503[PII]1212343', 'PESEL [PII][PII][PII]'), # labelled fragments are masked
|
| 111 |
+
('NIP 526-000-[PII]-46', 'NIP [PII]-[PII]-[PII]'),
|
| 112 |
+
('PESEL: 85031[PII]', 'PESEL: [PII][PII]'),
|
| 113 |
+
('PESEL 1234 [PII]', None), # under 5 digits in all
|
| 114 |
+
('tel. 601 [PII] 200 300', 'tel. [PHONE] [PII] [PHONE]'), # a phone cue: fragments are [PHONE]
|
| 115 |
+
('tel. 601 200 [PHONE]', 'tel. [PHONE] [PHONE]'),
|
| 116 |
+
('tel. +48 601 [PII] 300', 'tel. [PHONE] [PII] [PHONE]'),
|
| 117 |
+
('Kontakt: 22 123 [PHONE] 67', 'Kontakt: [PHONE] [PHONE] [PHONE]'),
|
| 118 |
+
('8503[PII]1212343', None), # no label, no cue: known gap
|
| 119 |
+
('601 [PII] 200 300', None), ('601 [PHONE] 200 300', None),
|
| 120 |
+
('tel. 601 200 [PHONE]abc', None), # glued to a word
|
| 121 |
+
('tel. 00-950 [PHONE]', None), ('tel. 2023-10-16 [PHONE]', None), # postcode, date
|
| 122 |
+
('tel. 12 345 [PII] zł', None), # amount
|
| 123 |
+
('Strona [PII] 12 z 40', None), ('[PII] 2019 r.', None), ('art. [PII] ust. 2', None),
|
| 124 |
+
('tel. [PHONE], godz. 8–16', None), ('[PHONE] 123 osób', None),
|
| 125 |
+
('Kontakt: [PHONE], 2 000 000 zł', None), ('NIP [PII], rok 2023', None),
|
| 126 |
+
('tel. [PHONE], pok. 101', None), ('tel. [PHONE], kod 00-950', None), ('[PII] 12 345 678 zł', None),
|
| 127 |
+
('tel. [PHONE] w godz. 10–18, sala 204', None)):
|
| 128 |
+
with self.subTest(text=text):
|
| 129 |
+
spans = rules(text)
|
| 130 |
+
self.assertEqual(apply_union(text, spans)[0], masked or text)
|
| 131 |
+
self.assertTrue(all('[' not in text[s['start']:s['end']] for s in spans)) # placeholders stay as they are
|
| 132 |
+
self.assertEqual(scrub_pii('PESEL 8503[PII]1212343')[1]['pesel'], 1)
|
| 133 |
+
self.assertEqual(scrub_pii('tel. 601 [PII] 200 300')[1]['phone'], 1)
|
| 134 |
+
|
| 135 |
def test_grouped_national_phones_mask_without_a_cue(self):
|
| 136 |
from nergal import rules
|
| 137 |
for text, masked in (('Sklep Ala, 601 234 567, czynne 9-17', ['601 234 567']),
|
|
|
|
| 294 |
}
|
| 295 |
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 296 |
|
| 297 |
+
def test_published_weights_mask_values_split_by_a_placeholder(self):
|
| 298 |
+
from nergal import Nergal
|
| 299 |
+
nergal = Nergal(HERE, device='cpu')
|
| 300 |
+
cases = { # invented; the model adds nothing to the rules here, and the gaps stay unmasked
|
| 301 |
+
'NIP [PII] 526-000-12-46': 'NIP [PII] [PII]', 'NIP: [PII], 526-000-12-46.': 'NIP: [PII], [PII].',
|
| 302 |
+
'REGON [PII] 123456785': 'REGON [PII] [PII]', 'PESEL 8503[PII]1212343': 'PESEL [PII][PII][PII]',
|
| 303 |
+
'NIP 526-000-[PII]-46': 'NIP [PII]-[PII]-[PII]', 'tel. 601 [PII] 200 300': 'tel. [PHONE] [PII] [PHONE]',
|
| 304 |
+
'tel. 601 200 [PHONE]': 'tel. [PHONE] [PHONE]', 'tel. [Telefon], 601 200 300': 'tel. [Telefon], [PHONE]',
|
| 305 |
+
**{t: t for t in ('8503[PII]1212343', '601 [PII] 200 300', '601 [PHONE] 200 300', 'Strona [PII] 12 z 40',
|
| 306 |
+
'[PII] 2019 r.', 'art. [PII] ust. 2', 'tel. [PHONE], godz. 8–16', '[PHONE] 123 osób',
|
| 307 |
+
'Kontakt: [PHONE], 2 000 000 zł', 'NIP [PII], rok 2023', 'tel. [PHONE], pok. 101',
|
| 308 |
+
'tel. [PHONE], kod 00-950', '[PII] 12 345 678 zł', 'tel. [PHONE] w godz. 10–18, sala 204')},
|
| 309 |
+
}
|
| 310 |
+
self.assertEqual({t: m for t, (m, _) in zip(cases, nergal.scrub_many(list(cases)))}, cases)
|
| 311 |
+
|
| 312 |
def _names(self):
|
| 313 |
from nergal import Names
|
| 314 |
return Names(HERE, json.loads((HERE / 'hybrid.json').read_text())['names'])
|