pii-eraser 0.1.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.
- pii_eraser-0.1.0/.gitignore +6 -0
- pii_eraser-0.1.0/LICENSE +21 -0
- pii_eraser-0.1.0/PKG-INFO +119 -0
- pii_eraser-0.1.0/README.md +96 -0
- pii_eraser-0.1.0/pii_eraser/__init__.py +9 -0
- pii_eraser-0.1.0/pii_eraser/cli.py +60 -0
- pii_eraser-0.1.0/pii_eraser/detectors/__init__.py +4 -0
- pii_eraser-0.1.0/pii_eraser/detectors/base.py +13 -0
- pii_eraser-0.1.0/pii_eraser/detectors/gliner.py +335 -0
- pii_eraser-0.1.0/pii_eraser/detectors/pii_rules.py +333 -0
- pii_eraser-0.1.0/pii_eraser/detectors/regex.py +51 -0
- pii_eraser-0.1.0/pii_eraser/detectors/secret_rules.py +77 -0
- pii_eraser-0.1.0/pii_eraser/detectors/validators.py +38 -0
- pii_eraser-0.1.0/pii_eraser/eraser.py +91 -0
- pii_eraser-0.1.0/pii_eraser/session.py +103 -0
- pii_eraser-0.1.0/pii_eraser/types.py +48 -0
- pii_eraser-0.1.0/pyproject.toml +42 -0
- pii_eraser-0.1.0/tests/test_eraser.py +225 -0
pii_eraser-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 37lab OÜ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pii-eraser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find personal data and secrets in text and code, replace them with stable placeholders, locally.
|
|
5
|
+
Project-URL: Homepage, https://37lab.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/tkenaz/pii-eraser
|
|
7
|
+
Author-email: 37lab OÜ <mv@37lab.ai>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: anonymization,gdpr,llm,pii,privacy,redaction,secrets
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Text Processing
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
20
|
+
Provides-Extra: gliner
|
|
21
|
+
Requires-Dist: gliner>=0.2.20; extra == 'gliner'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pii-eraser
|
|
25
|
+
|
|
26
|
+
Find personal data and secrets in text and code. Replace them with stable
|
|
27
|
+
placeholders. Do it locally, before the text goes to a model, a log or a vendor.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Contact Jane Doe <jane.doe@example.org>, key AKIA… → Contact [NAME_1] <[EMAIL_1]>, key [SECRET_1]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **Local.** Regex rules and an optional NER model run on your machine. No network.
|
|
34
|
+
- **Stable.** A session keeps one placeholder table across many texts:
|
|
35
|
+
`Jane Doe` is `[NAME_1]` in the diff, in every file an agent reads afterwards,
|
|
36
|
+
and in the next file, even where no rule fires again.
|
|
37
|
+
- **Code-aware.** The code profile drops rules that misfire on source (dates in
|
|
38
|
+
changelogs, numbers that look like phones) and checks card numbers (Luhn) and
|
|
39
|
+
IBANs (mod-97). Line counts never change, so line numbers in a review still hold.
|
|
40
|
+
- **Reversible for the owner.** `session.restore(text)` puts the originals back
|
|
41
|
+
into a model's answer, on your side.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install pii-eraser # regex rules only, no dependencies
|
|
47
|
+
pip install 'pii-eraser[gliner]' # + GLiNER NER for names and organisations (~400 MB model)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pii_eraser import Eraser
|
|
54
|
+
|
|
55
|
+
eraser = Eraser.for_prose() # documents, emails, MR descriptions
|
|
56
|
+
result = eraser.erase("Name: Jane Doe, jane.doe@example.org")
|
|
57
|
+
result.text # 'Name: [NAME_1], [EMAIL_1]'
|
|
58
|
+
result.mapping # {'[NAME_1]': 'Jane Doe', '[EMAIL_1]': 'jane.doe@example.org'}
|
|
59
|
+
|
|
60
|
+
session = Eraser.for_code().session() # many texts, one table
|
|
61
|
+
session.erase(diff_text)
|
|
62
|
+
session.erase(file_the_agent_read)
|
|
63
|
+
answer = session.restore(model_answer)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
With NER:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pii_eraser.detectors.gliner import GlinerDetector
|
|
70
|
+
|
|
71
|
+
Eraser.for_prose(GlinerDetector()) # NER hits are replaced
|
|
72
|
+
Eraser.for_code(GlinerDetector()) # NER hits are only reported in result.signals
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
On source code an NER model tags variable names and authors as people. In the
|
|
76
|
+
code profile its hits are signals for a human, not replacements.
|
|
77
|
+
|
|
78
|
+
## CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pii-eraser contract.txt > contract.clean.txt
|
|
82
|
+
git diff origin/main | pii-eraser --profile code --report --fail-on-secret > /dev/null
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The report on stderr lists kind, line and rule, never the original value, so it
|
|
86
|
+
is safe for CI logs. `--fail-on-secret` exits 1 when a secret is found.
|
|
87
|
+
|
|
88
|
+
## What it finds
|
|
89
|
+
|
|
90
|
+
| Kind | Examples |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `SECRET` | private key blocks, AWS / GitHub / GitLab / Slack / Anthropic / OpenAI / Google / Stripe keys, JWTs, passwords in URLs, `password = "…"` / `API_KEY=…` assignments, long random quoted strings |
|
|
93
|
+
| `EMAIL`, `PHONE` | emails; international, US, Swiss, German phone formats |
|
|
94
|
+
| `NAME` | labelled names, titles, salutations, signature blocks, contract parties; with NER: any person or organisation |
|
|
95
|
+
| `ADDRESS` | US street / city / state / ZIP, UK postcodes, German streets, labelled addresses |
|
|
96
|
+
| `FINANCIAL` | payment cards (Luhn), IBAN (mod-97), account, routing, tax ids |
|
|
97
|
+
| `SSN`, `NATIONAL_ID`, `DOB`, `MRN`, `HEALTH_PLAN_ID` | US SSN, Swiss AHV, Spanish NIE/DNI, UK NI, passports, licences, dates of birth, medical record and insurance numbers (EN/DE) |
|
|
98
|
+
|
|
99
|
+
References to secrets are left alone: `${DB_PASSWORD}`, `<your-token>`,
|
|
100
|
+
`os.environ['SECRET']`, `changeme`.
|
|
101
|
+
|
|
102
|
+
## Limits
|
|
103
|
+
|
|
104
|
+
Detection is heuristic. Rules miss formats they don't know; NER misses rare
|
|
105
|
+
names and catches some non-names. Treat the output as reduced exposure, not as
|
|
106
|
+
anonymisation in the legal sense, and keep a secret scanner such as gitleaks in
|
|
107
|
+
your pipeline: a leaked key must be rotated, not just masked.
|
|
108
|
+
|
|
109
|
+
## Origin
|
|
110
|
+
|
|
111
|
+
The personal-data rules and NER filters come from the scrubber of
|
|
112
|
+
37lab's contract analyzer, tuned on real contracts, medical notes and business
|
|
113
|
+
email. The secret rules, sessions and the code profile were added for agentic
|
|
114
|
+
code review in [semantic-diff](https://github.com/tkenaz/semantic_diff).
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT — [37lab](https://37lab.ai). The default NER model
|
|
119
|
+
`urchade/gliner_medium-v2.1` is Apache-2.0.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# pii-eraser
|
|
2
|
+
|
|
3
|
+
Find personal data and secrets in text and code. Replace them with stable
|
|
4
|
+
placeholders. Do it locally, before the text goes to a model, a log or a vendor.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
Contact Jane Doe <jane.doe@example.org>, key AKIA… → Contact [NAME_1] <[EMAIL_1]>, key [SECRET_1]
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- **Local.** Regex rules and an optional NER model run on your machine. No network.
|
|
11
|
+
- **Stable.** A session keeps one placeholder table across many texts:
|
|
12
|
+
`Jane Doe` is `[NAME_1]` in the diff, in every file an agent reads afterwards,
|
|
13
|
+
and in the next file, even where no rule fires again.
|
|
14
|
+
- **Code-aware.** The code profile drops rules that misfire on source (dates in
|
|
15
|
+
changelogs, numbers that look like phones) and checks card numbers (Luhn) and
|
|
16
|
+
IBANs (mod-97). Line counts never change, so line numbers in a review still hold.
|
|
17
|
+
- **Reversible for the owner.** `session.restore(text)` puts the originals back
|
|
18
|
+
into a model's answer, on your side.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pii-eraser # regex rules only, no dependencies
|
|
24
|
+
pip install 'pii-eraser[gliner]' # + GLiNER NER for names and organisations (~400 MB model)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pii_eraser import Eraser
|
|
31
|
+
|
|
32
|
+
eraser = Eraser.for_prose() # documents, emails, MR descriptions
|
|
33
|
+
result = eraser.erase("Name: Jane Doe, jane.doe@example.org")
|
|
34
|
+
result.text # 'Name: [NAME_1], [EMAIL_1]'
|
|
35
|
+
result.mapping # {'[NAME_1]': 'Jane Doe', '[EMAIL_1]': 'jane.doe@example.org'}
|
|
36
|
+
|
|
37
|
+
session = Eraser.for_code().session() # many texts, one table
|
|
38
|
+
session.erase(diff_text)
|
|
39
|
+
session.erase(file_the_agent_read)
|
|
40
|
+
answer = session.restore(model_answer)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
With NER:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pii_eraser.detectors.gliner import GlinerDetector
|
|
47
|
+
|
|
48
|
+
Eraser.for_prose(GlinerDetector()) # NER hits are replaced
|
|
49
|
+
Eraser.for_code(GlinerDetector()) # NER hits are only reported in result.signals
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
On source code an NER model tags variable names and authors as people. In the
|
|
53
|
+
code profile its hits are signals for a human, not replacements.
|
|
54
|
+
|
|
55
|
+
## CLI
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pii-eraser contract.txt > contract.clean.txt
|
|
59
|
+
git diff origin/main | pii-eraser --profile code --report --fail-on-secret > /dev/null
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The report on stderr lists kind, line and rule, never the original value, so it
|
|
63
|
+
is safe for CI logs. `--fail-on-secret` exits 1 when a secret is found.
|
|
64
|
+
|
|
65
|
+
## What it finds
|
|
66
|
+
|
|
67
|
+
| Kind | Examples |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `SECRET` | private key blocks, AWS / GitHub / GitLab / Slack / Anthropic / OpenAI / Google / Stripe keys, JWTs, passwords in URLs, `password = "…"` / `API_KEY=…` assignments, long random quoted strings |
|
|
70
|
+
| `EMAIL`, `PHONE` | emails; international, US, Swiss, German phone formats |
|
|
71
|
+
| `NAME` | labelled names, titles, salutations, signature blocks, contract parties; with NER: any person or organisation |
|
|
72
|
+
| `ADDRESS` | US street / city / state / ZIP, UK postcodes, German streets, labelled addresses |
|
|
73
|
+
| `FINANCIAL` | payment cards (Luhn), IBAN (mod-97), account, routing, tax ids |
|
|
74
|
+
| `SSN`, `NATIONAL_ID`, `DOB`, `MRN`, `HEALTH_PLAN_ID` | US SSN, Swiss AHV, Spanish NIE/DNI, UK NI, passports, licences, dates of birth, medical record and insurance numbers (EN/DE) |
|
|
75
|
+
|
|
76
|
+
References to secrets are left alone: `${DB_PASSWORD}`, `<your-token>`,
|
|
77
|
+
`os.environ['SECRET']`, `changeme`.
|
|
78
|
+
|
|
79
|
+
## Limits
|
|
80
|
+
|
|
81
|
+
Detection is heuristic. Rules miss formats they don't know; NER misses rare
|
|
82
|
+
names and catches some non-names. Treat the output as reduced exposure, not as
|
|
83
|
+
anonymisation in the legal sense, and keep a secret scanner such as gitleaks in
|
|
84
|
+
your pipeline: a leaked key must be rotated, not just masked.
|
|
85
|
+
|
|
86
|
+
## Origin
|
|
87
|
+
|
|
88
|
+
The personal-data rules and NER filters come from the scrubber of
|
|
89
|
+
37lab's contract analyzer, tuned on real contracts, medical notes and business
|
|
90
|
+
email. The secret rules, sessions and the code profile were added for agentic
|
|
91
|
+
code review in [semantic-diff](https://github.com/tkenaz/semantic_diff).
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT — [37lab](https://37lab.ai). The default NER model
|
|
96
|
+
`urchade/gliner_medium-v2.1` is Apache-2.0.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""pii-eraser: find personal data and secrets in text and replace them with
|
|
2
|
+
stable placeholders, locally, before the text goes anywhere."""
|
|
3
|
+
|
|
4
|
+
from pii_eraser.eraser import Eraser, ErasingSession, Stage
|
|
5
|
+
from pii_eraser.types import EraseResult, Kind, Span
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["Eraser", "ErasingSession", "Stage", "EraseResult", "Kind", "Span", "__version__"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""pii-eraser FILE|- → erased text on stdout, a report on stderr.
|
|
2
|
+
|
|
3
|
+
The report names kind, line and rule, never the original value: it is meant
|
|
4
|
+
for CI logs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from pii_eraser.eraser import Eraser
|
|
12
|
+
from pii_eraser.types import EraseResult, Kind
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _line_of(text: str, offset: int) -> int:
|
|
16
|
+
return text.count("\n", 0, offset) + 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _report(original: str, result: EraseResult) -> dict:
|
|
20
|
+
def row(span):
|
|
21
|
+
return {"kind": span.kind.value, "line": _line_of(original, span.start), "rule": span.source}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"found": [row(s) for s in result.found],
|
|
25
|
+
"signals": [row(s) for s in result.signals],
|
|
26
|
+
"secrets": sum(1 for s in result.found if s.kind is Kind.SECRET),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv=None) -> int:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="pii-eraser", description=__doc__.split("\n")[0])
|
|
32
|
+
parser.add_argument("path", nargs="?", default="-", help="file to read, '-' for stdin")
|
|
33
|
+
parser.add_argument("--profile", choices=("prose", "code"), default="prose")
|
|
34
|
+
parser.add_argument("--ner", action="store_true", help="add GLiNER (pip install 'pii-eraser[gliner]')")
|
|
35
|
+
parser.add_argument("--report", action="store_true", help="JSON report on stderr")
|
|
36
|
+
parser.add_argument("--fail-on-secret", action="store_true", help="exit 1 when a secret is found")
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
|
|
40
|
+
|
|
41
|
+
ner = None
|
|
42
|
+
if args.ner:
|
|
43
|
+
from pii_eraser.detectors.gliner import GlinerDetector
|
|
44
|
+
|
|
45
|
+
ner = GlinerDetector()
|
|
46
|
+
eraser = Eraser.for_code(ner) if args.profile == "code" else Eraser.for_prose(ner)
|
|
47
|
+
result = eraser.erase(text)
|
|
48
|
+
|
|
49
|
+
sys.stdout.write(result.text)
|
|
50
|
+
report = _report(text, result)
|
|
51
|
+
if args.report:
|
|
52
|
+
print(json.dumps(report, indent=2), file=sys.stderr)
|
|
53
|
+
if args.fail_on_secret and report["secrets"]:
|
|
54
|
+
print(f"pii-eraser: {report['secrets']} secret(s) found", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The one interface every detector implements."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Protocol
|
|
4
|
+
|
|
5
|
+
from pii_eraser.types import Span
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Detector(Protocol):
|
|
9
|
+
name: str
|
|
10
|
+
|
|
11
|
+
def detect(self, text: str) -> List[Span]:
|
|
12
|
+
"""Return candidate spans with absolute offsets into `text`."""
|
|
13
|
+
...
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""NER detection with GLiNER (zero-shot labels, runs locally, ~400 MB model).
|
|
2
|
+
|
|
3
|
+
Ported from the contract-analyzer scrubber. Install with `pip install
|
|
4
|
+
pii-eraser[gliner]`; the model downloads on first use.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from pii_eraser.types import Kind, Span
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_MODEL = "urchade/gliner_medium-v2.1" # Apache-2.0
|
|
15
|
+
|
|
16
|
+
LABEL_TO_KIND: Dict[str, Kind] = {
|
|
17
|
+
"person": Kind.NAME,
|
|
18
|
+
"organization": Kind.NAME,
|
|
19
|
+
"company": Kind.NAME,
|
|
20
|
+
"corporation": Kind.NAME,
|
|
21
|
+
"government": Kind.NAME,
|
|
22
|
+
"county": Kind.NAME,
|
|
23
|
+
"city": Kind.NAME,
|
|
24
|
+
"municipality": Kind.NAME,
|
|
25
|
+
"location": Kind.ADDRESS,
|
|
26
|
+
"address": Kind.ADDRESS,
|
|
27
|
+
"ssn": Kind.SSN,
|
|
28
|
+
"tax_id": Kind.FINANCIAL,
|
|
29
|
+
"passport_number": Kind.NATIONAL_ID,
|
|
30
|
+
"drivers_license": Kind.NATIONAL_ID,
|
|
31
|
+
"bank_account": Kind.FINANCIAL,
|
|
32
|
+
"routing_number": Kind.FINANCIAL,
|
|
33
|
+
"credit_card": Kind.FINANCIAL,
|
|
34
|
+
"phone_number": Kind.PHONE,
|
|
35
|
+
"email": Kind.EMAIL,
|
|
36
|
+
"date_of_birth": Kind.DOB,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
DEFAULT_LABELS = list(LABEL_TO_KIND)
|
|
40
|
+
|
|
41
|
+
# Role nouns, pronouns and bare titles that NER tags as person/organization but
|
|
42
|
+
# that never identify anyone. Scrubbing them corrupts quotes ("[NAME_5] owning
|
|
43
|
+
# 95% of the Shares") and a downstream model then reports the corruption.
|
|
44
|
+
GENERIC_TERMS = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"client",
|
|
47
|
+
"contractor",
|
|
48
|
+
"vendor",
|
|
49
|
+
"supplier",
|
|
50
|
+
"customer",
|
|
51
|
+
"employee",
|
|
52
|
+
"employees",
|
|
53
|
+
"employer",
|
|
54
|
+
"party",
|
|
55
|
+
"parties",
|
|
56
|
+
"company",
|
|
57
|
+
"corporation",
|
|
58
|
+
"llc",
|
|
59
|
+
"inc",
|
|
60
|
+
"ltd",
|
|
61
|
+
"agreement",
|
|
62
|
+
"contract",
|
|
63
|
+
"document",
|
|
64
|
+
"plaintiff",
|
|
65
|
+
"defendant",
|
|
66
|
+
"attorney",
|
|
67
|
+
"counsel",
|
|
68
|
+
"licensor",
|
|
69
|
+
"licensee",
|
|
70
|
+
"lessor",
|
|
71
|
+
"lessee",
|
|
72
|
+
"buyer",
|
|
73
|
+
"seller",
|
|
74
|
+
"purchaser",
|
|
75
|
+
"shareholder",
|
|
76
|
+
"shareholders",
|
|
77
|
+
"stockholder",
|
|
78
|
+
"stockholders",
|
|
79
|
+
"member",
|
|
80
|
+
"members",
|
|
81
|
+
"director",
|
|
82
|
+
"directors",
|
|
83
|
+
"officer",
|
|
84
|
+
"officers",
|
|
85
|
+
"authorized officer",
|
|
86
|
+
"founder",
|
|
87
|
+
"founders",
|
|
88
|
+
"partner",
|
|
89
|
+
"partners",
|
|
90
|
+
"trustee",
|
|
91
|
+
"beneficiary",
|
|
92
|
+
"executor",
|
|
93
|
+
"administrator",
|
|
94
|
+
"guarantor",
|
|
95
|
+
"borrower",
|
|
96
|
+
"lender",
|
|
97
|
+
"investor",
|
|
98
|
+
"investors",
|
|
99
|
+
"signatory",
|
|
100
|
+
"signatories",
|
|
101
|
+
"witness",
|
|
102
|
+
"board",
|
|
103
|
+
"board of directors",
|
|
104
|
+
"the board",
|
|
105
|
+
"person",
|
|
106
|
+
"persons",
|
|
107
|
+
"individual",
|
|
108
|
+
"individuals",
|
|
109
|
+
"entity",
|
|
110
|
+
"entities",
|
|
111
|
+
"the undersigned",
|
|
112
|
+
"undersigned",
|
|
113
|
+
"each party",
|
|
114
|
+
"either party",
|
|
115
|
+
"i",
|
|
116
|
+
"we",
|
|
117
|
+
"you",
|
|
118
|
+
"he",
|
|
119
|
+
"she",
|
|
120
|
+
"they",
|
|
121
|
+
"heirs",
|
|
122
|
+
"successors",
|
|
123
|
+
"assigns",
|
|
124
|
+
"spouse",
|
|
125
|
+
"affiliate",
|
|
126
|
+
"affiliates",
|
|
127
|
+
"cto",
|
|
128
|
+
"ceo",
|
|
129
|
+
"cfo",
|
|
130
|
+
"coo",
|
|
131
|
+
"cio",
|
|
132
|
+
"president",
|
|
133
|
+
"chairman",
|
|
134
|
+
"chairperson",
|
|
135
|
+
"secretary",
|
|
136
|
+
"treasurer",
|
|
137
|
+
"general counsel",
|
|
138
|
+
"managing director",
|
|
139
|
+
"email address",
|
|
140
|
+
"e-mail address",
|
|
141
|
+
"facsimile number",
|
|
142
|
+
"fax number",
|
|
143
|
+
"telephone number",
|
|
144
|
+
"phone number",
|
|
145
|
+
"mobile number",
|
|
146
|
+
"capital account",
|
|
147
|
+
"bank name",
|
|
148
|
+
"financial institution",
|
|
149
|
+
"account number",
|
|
150
|
+
"registered address",
|
|
151
|
+
"principal place of business",
|
|
152
|
+
"transferee",
|
|
153
|
+
"transferees",
|
|
154
|
+
"transferor",
|
|
155
|
+
"addressee",
|
|
156
|
+
"recipient",
|
|
157
|
+
"recipients",
|
|
158
|
+
"someone",
|
|
159
|
+
"anyone",
|
|
160
|
+
"competitor",
|
|
161
|
+
"competitors",
|
|
162
|
+
"representative",
|
|
163
|
+
"representatives",
|
|
164
|
+
"court",
|
|
165
|
+
"courts",
|
|
166
|
+
"natural person",
|
|
167
|
+
"human being",
|
|
168
|
+
"personal representative",
|
|
169
|
+
"personal representatives",
|
|
170
|
+
"prevailing party",
|
|
171
|
+
# software roles and words NER tags as people in code and commit messages
|
|
172
|
+
"user",
|
|
173
|
+
"users",
|
|
174
|
+
"admin",
|
|
175
|
+
"author",
|
|
176
|
+
"maintainer",
|
|
177
|
+
"owner",
|
|
178
|
+
"reviewer",
|
|
179
|
+
"developer",
|
|
180
|
+
"service",
|
|
181
|
+
"server",
|
|
182
|
+
"client id",
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Words that do not make a phrase a name: "New Shareholder", "Each party".
|
|
187
|
+
_STOP_MODIFIERS = frozenset(
|
|
188
|
+
{
|
|
189
|
+
"new",
|
|
190
|
+
"each",
|
|
191
|
+
"every",
|
|
192
|
+
"any",
|
|
193
|
+
"such",
|
|
194
|
+
"said",
|
|
195
|
+
"the",
|
|
196
|
+
"a",
|
|
197
|
+
"an",
|
|
198
|
+
"all",
|
|
199
|
+
"other",
|
|
200
|
+
"proposed",
|
|
201
|
+
"purported",
|
|
202
|
+
"prevailing",
|
|
203
|
+
"transferring",
|
|
204
|
+
"natural",
|
|
205
|
+
"personal",
|
|
206
|
+
"authorized",
|
|
207
|
+
"issuing",
|
|
208
|
+
"federal",
|
|
209
|
+
"state",
|
|
210
|
+
"or",
|
|
211
|
+
"and",
|
|
212
|
+
"of",
|
|
213
|
+
"to",
|
|
214
|
+
"this",
|
|
215
|
+
"that",
|
|
216
|
+
"no",
|
|
217
|
+
"non",
|
|
218
|
+
"former",
|
|
219
|
+
"future",
|
|
220
|
+
"existing",
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
_NOT_NAME_WORDS = GENERIC_TERMS | _STOP_MODIFIERS
|
|
225
|
+
|
|
226
|
+
_NAMEY_LABELS = frozenset(
|
|
227
|
+
{
|
|
228
|
+
"person",
|
|
229
|
+
"organization",
|
|
230
|
+
"company",
|
|
231
|
+
"corporation",
|
|
232
|
+
"government",
|
|
233
|
+
"county",
|
|
234
|
+
"city",
|
|
235
|
+
"municipality",
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
_LABEL_WORDS = {
|
|
240
|
+
"ssn": {"ssn", "social security", "social security number"},
|
|
241
|
+
"tax_id": {"tax id", "tax", "ein"},
|
|
242
|
+
"passport_number": {"passport", "passport number"},
|
|
243
|
+
"drivers_license": {"driver's license", "drivers license", "driver license", "dl"},
|
|
244
|
+
"bank_account": {"bank account", "account", "bank"},
|
|
245
|
+
"routing_number": {"routing", "routing number"},
|
|
246
|
+
"phone_number": {"phone", "telephone", "tel", "mobile", "cell"},
|
|
247
|
+
"email": {"email", "e-mail"},
|
|
248
|
+
"address": {"address"},
|
|
249
|
+
"date_of_birth": {"dob", "date of birth", "birthdate"},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def is_generic_name_phrase(text: str, label: str) -> bool:
|
|
254
|
+
"""True when a person/org hit holds no actual name: only role nouns, stop
|
|
255
|
+
modifiers and lowercase words. 'transferring Shareholder' -> True,
|
|
256
|
+
'Tunnel King Studios Inc.' -> False."""
|
|
257
|
+
if label not in _NAMEY_LABELS:
|
|
258
|
+
return False
|
|
259
|
+
for token in text.split():
|
|
260
|
+
bare = token.strip(".,;:()\"'’").rstrip(".")
|
|
261
|
+
if bare and bare[0].isupper() and bare.lower() not in _NOT_NAME_WORDS:
|
|
262
|
+
return False
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_noise(text: str, label: str) -> bool:
|
|
267
|
+
low = text.lower().strip().strip(".,;:")
|
|
268
|
+
if low in GENERIC_TERMS:
|
|
269
|
+
return True
|
|
270
|
+
for article in ("the ", "a ", "an ", "such ", "said ", "any ", "each ", "every "):
|
|
271
|
+
if low.startswith(article) and low[len(article) :] in GENERIC_TERMS:
|
|
272
|
+
return True
|
|
273
|
+
return is_generic_name_phrase(text, label) or low in _LABEL_WORDS.get(label, ())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class GlinerDetector:
|
|
277
|
+
name = "gliner"
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
model_id: str = DEFAULT_MODEL,
|
|
282
|
+
labels: Optional[Sequence[str]] = None,
|
|
283
|
+
# 0.5: below it the model floods the table with generic phrases scored
|
|
284
|
+
# as names; rare surnames it under-scores are left to the regex rules.
|
|
285
|
+
threshold: float = 0.5,
|
|
286
|
+
chunk_size: int = 1500, # characters; the model truncates near 384 tokens
|
|
287
|
+
overlap: int = 200,
|
|
288
|
+
model=None, # injected for tests
|
|
289
|
+
):
|
|
290
|
+
self.model_id = model_id
|
|
291
|
+
self.labels = list(labels or DEFAULT_LABELS)
|
|
292
|
+
self.threshold = threshold
|
|
293
|
+
self.chunk_size = chunk_size
|
|
294
|
+
self.overlap = overlap
|
|
295
|
+
self._model = model
|
|
296
|
+
|
|
297
|
+
def _load(self):
|
|
298
|
+
if self._model is None:
|
|
299
|
+
try:
|
|
300
|
+
from gliner import GLiNER
|
|
301
|
+
except ImportError as e:
|
|
302
|
+
raise ImportError("GLiNER is not installed: pip install 'pii-eraser[gliner]'") from e
|
|
303
|
+
logger.info("loading GLiNER model %s", self.model_id)
|
|
304
|
+
self._model = GLiNER.from_pretrained(self.model_id)
|
|
305
|
+
return self._model
|
|
306
|
+
|
|
307
|
+
def detect(self, text: str) -> List[Span]:
|
|
308
|
+
model = self._load()
|
|
309
|
+
spans: Dict[tuple, Span] = {}
|
|
310
|
+
start = 0
|
|
311
|
+
while start < len(text):
|
|
312
|
+
end = min(start + self.chunk_size, len(text))
|
|
313
|
+
chunk = text[start:end]
|
|
314
|
+
for entity in model.predict_entities(chunk, self.labels, threshold=self.threshold):
|
|
315
|
+
label, value = entity["label"], entity["text"]
|
|
316
|
+
if is_noise(value, label):
|
|
317
|
+
continue
|
|
318
|
+
local = entity.get("start")
|
|
319
|
+
if local is None or chunk[local : local + len(value)] != value:
|
|
320
|
+
local = chunk.find(value)
|
|
321
|
+
if local == -1:
|
|
322
|
+
continue
|
|
323
|
+
abs_start = start + local
|
|
324
|
+
spans[(abs_start, value)] = Span(
|
|
325
|
+
start=abs_start,
|
|
326
|
+
end=abs_start + len(value),
|
|
327
|
+
text=value,
|
|
328
|
+
kind=LABEL_TO_KIND.get(label, Kind.NAME),
|
|
329
|
+
confidence=float(entity["score"]),
|
|
330
|
+
source=f"gliner:{label}",
|
|
331
|
+
)
|
|
332
|
+
if end >= len(text):
|
|
333
|
+
break
|
|
334
|
+
start = end - self.overlap
|
|
335
|
+
return list(spans.values())
|