anon-lib 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.
- anon_lib-0.1.0/.gitignore +14 -0
- anon_lib-0.1.0/LICENSE +21 -0
- anon_lib-0.1.0/PKG-INFO +197 -0
- anon_lib-0.1.0/README.md +164 -0
- anon_lib-0.1.0/pyproject.toml +49 -0
- anon_lib-0.1.0/pytest.ini +11 -0
- anon_lib-0.1.0/src/anon_lib/__init__.py +10 -0
- anon_lib-0.1.0/src/anon_lib/anonymizer.py +160 -0
- anon_lib-0.1.0/src/anon_lib/patterns/__init__.py +0 -0
- anon_lib-0.1.0/src/anon_lib/patterns/patterns.py +36 -0
- anon_lib-0.1.0/src/anon_lib/schemas/__init__.py +0 -0
- anon_lib-0.1.0/src/anon_lib/schemas/models_schemas.py +21 -0
- anon_lib-0.1.0/tests/__init__.py +0 -0
- anon_lib-0.1.0/tests/test_anonymizer.py +407 -0
anon_lib-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wyctor Fogos da Rocha
|
|
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.
|
anon_lib-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anon-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PII detection and masking for Brazilian Portuguese and English
|
|
5
|
+
Project-URL: Homepage, https://github.com/wyctorfogos/anon-lib
|
|
6
|
+
Project-URL: Issues, https://github.com/wyctorfogos/anon-lib/issues
|
|
7
|
+
Author: Wyctor Fogos da Rocha
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: anonymization,lgpd,ner,pii,portuguese
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
22
|
+
Requires-Python: >=3.8.13
|
|
23
|
+
Requires-Dist: langchain-text-splitters>=0.2
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: ollama>=0.3; extra == 'dev'
|
|
26
|
+
Requires-Dist: pydantic>=2; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Requires-Dist: python-dotenv; extra == 'dev'
|
|
29
|
+
Provides-Extra: llm
|
|
30
|
+
Requires-Dist: ollama>=0.3; extra == 'llm'
|
|
31
|
+
Requires-Dist: pydantic>=2; extra == 'llm'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Sentence-anonimizer
|
|
35
|
+
|
|
36
|
+
Detects and masks personally identifiable information (PII) in free text, replacing
|
|
37
|
+
each value with a stable tag and returning the tag → original value mapping.
|
|
38
|
+
|
|
39
|
+
Supports Brazilian Portuguese (`pt-br`) and English (`en`).
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
Two layers, and the split matters:
|
|
44
|
+
|
|
45
|
+
| Layer | Finds | Guarantees |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| **Regex** (always on) | Formatted data: CPF, CNPJ, e-mail, phone, credit card, CEP… | Deterministic. Same input, same output, no network. |
|
|
48
|
+
| **LLM via Ollama** (opt-in) | Context-dependent data regex cannot express: names, places, organizations | Best effort. Recall varies between runs. |
|
|
49
|
+
|
|
50
|
+
The LLM only **identifies** entities and returns them as structured JSON. The masking
|
|
51
|
+
itself is always done in code, reusing the same tagging machinery as the regex layer.
|
|
52
|
+
The model never rewrites your text, so it cannot silently drop or alter passages.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install anon-lib # regex layer only
|
|
58
|
+
pip install "anon-lib[llm]" # adds the optional LLM layer
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The base install has a single dependency and never touches the network. `ollama` and
|
|
62
|
+
`pydantic` come only with the `llm` extra and are imported lazily, so calling
|
|
63
|
+
`use_llm=True` without them raises an actionable `ImportError` instead of a bare
|
|
64
|
+
`ModuleNotFoundError`.
|
|
65
|
+
|
|
66
|
+
The LLM layer also needs a running [Ollama](https://ollama.com) server with a model
|
|
67
|
+
pulled:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ollama pull qwen3:0.6b
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Point the library at it with the `llm_model_name` argument, or read it from your own
|
|
74
|
+
config:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
Anonymizer(llm_model_name="qwen3:0.6b")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
`anonimize_text` returns a `(masked_text, tags)` tuple. Every tag in the mapping is
|
|
83
|
+
guaranteed to appear in the masked text.
|
|
84
|
+
|
|
85
|
+
### Regex only (default)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from anon_lib import Anonymizer
|
|
89
|
+
|
|
90
|
+
anon = Anonymizer(language="pt-br")
|
|
91
|
+
masked, tags = anon.anonimize_text(
|
|
92
|
+
"Contato: joao.silva@empresa.com.br, CPF 123.456.789-00, "
|
|
93
|
+
"CNPJ 12.345.678/0001-99, cel (11) 98765-4321."
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Contato: [EMAIL_0], CPF [CPF_0], CNPJ [CNPJ_NUMERICO_FORMATADO_0], cel [TELEFONE_BR_0].
|
|
99
|
+
|
|
100
|
+
{'[EMAIL_0]': 'joao.silva@empresa.com.br',
|
|
101
|
+
'[CPF_0]': '123.456.789-00',
|
|
102
|
+
'[CNPJ_NUMERICO_FORMATADO_0]': '12.345.678/0001-99',
|
|
103
|
+
'[TELEFONE_BR_0]': '(11) 98765-4321'}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Regex + LLM
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
masked, tags = anon.anonimize_text(
|
|
110
|
+
"Meu nome é Maria, moro na Rua das Flores, 123 e meu CPF é 111.222.333-44.",
|
|
111
|
+
use_llm=True,
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
Meu nome é [PERSON_NAME_0], moro na [LOCATION_0], 123 e meu CPF é [CPF_0].
|
|
117
|
+
|
|
118
|
+
{'[CPF_0]': '111.222.333-44',
|
|
119
|
+
'[PERSON_NAME_0]': 'Maria',
|
|
120
|
+
'[LOCATION_0]': 'Rua das Flores'}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Note the house number `123` left unmasked: that is real output, and it illustrates why
|
|
124
|
+
the regex layer is the one you rely on for guarantees.
|
|
125
|
+
|
|
126
|
+
Run both layers end to end with `python examples/demo.py`.
|
|
127
|
+
|
|
128
|
+
## Detected patterns
|
|
129
|
+
|
|
130
|
+
**pt-br** — `CPF`, `CNPJ_NUMERICO_FORMATADO`, `CNPJ_ALFANUMERICO`, `CNPJ_NUMERICO`,
|
|
131
|
+
`CNPJ_ALFANUMERICO_SEM_PONTUACAO`, `RG`, `PIS_PASEP`, `TITULO_ELEITOR`, `CNH`, `EMAIL`,
|
|
132
|
+
`TELEFONE_BR`, `CEP`, `DATA`, `PLACA_VEICULO`, `PLACA_VEICULO_MERCOSUL`,
|
|
133
|
+
`CARTAO_CREDITO`, `CARTAO_CREDITO_AMEX`, `CHAVE_PIX_ALEATORIA`, `AGENCIA`,
|
|
134
|
+
`CONTA_BANCARIA`, `IP`
|
|
135
|
+
|
|
136
|
+
**en** — `EMAIL`, `SSN`, `CREDIT_CARD`, `CREDIT_CARD_AMEX`, `US_PHONE`, `US_ZIP`, `IP`
|
|
137
|
+
|
|
138
|
+
**LLM entity types** — `PERSON_NAME`, `LOCATION`, `ORGANIZATION`, `DATE`, `AGE`,
|
|
139
|
+
`TIME`, `MONEY`, `PERCENT`, `FACILITY`, `GPE` (configurable via
|
|
140
|
+
`list_of_undeterministics_entities`; the list is injected into the response schema as an
|
|
141
|
+
enum, so the model cannot return a type outside it).
|
|
142
|
+
|
|
143
|
+
## Design notes
|
|
144
|
+
|
|
145
|
+
**Declaration order is priority.** Some values match several patterns — 11 bare digits
|
|
146
|
+
are both a CPF and a mobile number. The first pattern declared wins, so formatted
|
|
147
|
+
patterns come first and ambiguous bare numerics last.
|
|
148
|
+
|
|
149
|
+
**Longest value is replaced first.** The leading 12 digits of a spaced credit card also
|
|
150
|
+
match a voter ID. Replacing the shorter value first would corrupt the longer one.
|
|
151
|
+
|
|
152
|
+
**Label-anchored patterns include the label.** `CNH`, `AGENCIA` and `CONTA_BANCARIA` mask
|
|
153
|
+
the label too (`CNH: 12345678901` → `[CNH_0]`), because the bare number is
|
|
154
|
+
indistinguishable from a CPF without it.
|
|
155
|
+
|
|
156
|
+
**LLM values absent from the text are discarded.** A paraphrased or invented value would
|
|
157
|
+
produce a no-op substitution while making the output *look* anonymized.
|
|
158
|
+
|
|
159
|
+
## Known limitations
|
|
160
|
+
|
|
161
|
+
- **No check-digit validation.** A well-formed but arithmetically invalid CPF is still
|
|
162
|
+
masked. For anonymization, over-detection is the safe side.
|
|
163
|
+
- **`DATA` matches any `dd/mm/aaaa` date**, with no way to tell a birth date from any
|
|
164
|
+
other. Distinguishing them requires context, which is the LLM layer's job.
|
|
165
|
+
- **`US_ZIP` matches any 5-digit number**, and bare 11-digit runs are inherently
|
|
166
|
+
ambiguous between CPF, CNH and mobile numbers.
|
|
167
|
+
- **`MONEY`, `PERCENT` and `TIME` are not PII** and only add noise. Adjust
|
|
168
|
+
`list_of_undeterministics_entities` for your use case — the list is injected into the
|
|
169
|
+
response schema as an enum, so it is the effective control over what the model may
|
|
170
|
+
return.
|
|
171
|
+
- **Tag numbering may skip an index.** When a short value is also a substring of a longer
|
|
172
|
+
masked one, it stays registered to catch standalone occurrences elsewhere. Dropping it
|
|
173
|
+
would leak PII.
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
pip install -e ".[dev]" # needs Python 3.9+, as declared in pyproject.toml
|
|
179
|
+
pytest # 53 tests, hermetic: no server, no network (~2s)
|
|
180
|
+
pytest -m llm # 3 prompt-regression tests against a real Ollama
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The default suite stubs every LLM response, so it needs no server. The `llm` marker is
|
|
184
|
+
deselected by default and holds the tests that call the model for real — they verify the
|
|
185
|
+
prompt rules still take effect, which a stub cannot do, and skip themselves when Ollama
|
|
186
|
+
or the model is unavailable.
|
|
187
|
+
|
|
188
|
+
## Layout
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
src/anon_lib/__init__.py public API: Anonymizer, __version__
|
|
192
|
+
src/anon_lib/anonymizer.py Anonymizer
|
|
193
|
+
src/anon_lib/patterns/patterns.py regex pattern tables, per language
|
|
194
|
+
src/anon_lib/schemas/models_schemas.py AnonOutput / PIIEntity (LLM response schema)
|
|
195
|
+
examples/demo.py runnable demo of both layers
|
|
196
|
+
tests/test_anonymizer.py test suite
|
|
197
|
+
```
|
anon_lib-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Sentence-anonimizer
|
|
2
|
+
|
|
3
|
+
Detects and masks personally identifiable information (PII) in free text, replacing
|
|
4
|
+
each value with a stable tag and returning the tag → original value mapping.
|
|
5
|
+
|
|
6
|
+
Supports Brazilian Portuguese (`pt-br`) and English (`en`).
|
|
7
|
+
|
|
8
|
+
## How it works
|
|
9
|
+
|
|
10
|
+
Two layers, and the split matters:
|
|
11
|
+
|
|
12
|
+
| Layer | Finds | Guarantees |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| **Regex** (always on) | Formatted data: CPF, CNPJ, e-mail, phone, credit card, CEP… | Deterministic. Same input, same output, no network. |
|
|
15
|
+
| **LLM via Ollama** (opt-in) | Context-dependent data regex cannot express: names, places, organizations | Best effort. Recall varies between runs. |
|
|
16
|
+
|
|
17
|
+
The LLM only **identifies** entities and returns them as structured JSON. The masking
|
|
18
|
+
itself is always done in code, reusing the same tagging machinery as the regex layer.
|
|
19
|
+
The model never rewrites your text, so it cannot silently drop or alter passages.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install anon-lib # regex layer only
|
|
25
|
+
pip install "anon-lib[llm]" # adds the optional LLM layer
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The base install has a single dependency and never touches the network. `ollama` and
|
|
29
|
+
`pydantic` come only with the `llm` extra and are imported lazily, so calling
|
|
30
|
+
`use_llm=True` without them raises an actionable `ImportError` instead of a bare
|
|
31
|
+
`ModuleNotFoundError`.
|
|
32
|
+
|
|
33
|
+
The LLM layer also needs a running [Ollama](https://ollama.com) server with a model
|
|
34
|
+
pulled:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
ollama pull qwen3:0.6b
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Point the library at it with the `llm_model_name` argument, or read it from your own
|
|
41
|
+
config:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
Anonymizer(llm_model_name="qwen3:0.6b")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
`anonimize_text` returns a `(masked_text, tags)` tuple. Every tag in the mapping is
|
|
50
|
+
guaranteed to appear in the masked text.
|
|
51
|
+
|
|
52
|
+
### Regex only (default)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from anon_lib import Anonymizer
|
|
56
|
+
|
|
57
|
+
anon = Anonymizer(language="pt-br")
|
|
58
|
+
masked, tags = anon.anonimize_text(
|
|
59
|
+
"Contato: joao.silva@empresa.com.br, CPF 123.456.789-00, "
|
|
60
|
+
"CNPJ 12.345.678/0001-99, cel (11) 98765-4321."
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Contato: [EMAIL_0], CPF [CPF_0], CNPJ [CNPJ_NUMERICO_FORMATADO_0], cel [TELEFONE_BR_0].
|
|
66
|
+
|
|
67
|
+
{'[EMAIL_0]': 'joao.silva@empresa.com.br',
|
|
68
|
+
'[CPF_0]': '123.456.789-00',
|
|
69
|
+
'[CNPJ_NUMERICO_FORMATADO_0]': '12.345.678/0001-99',
|
|
70
|
+
'[TELEFONE_BR_0]': '(11) 98765-4321'}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Regex + LLM
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
masked, tags = anon.anonimize_text(
|
|
77
|
+
"Meu nome é Maria, moro na Rua das Flores, 123 e meu CPF é 111.222.333-44.",
|
|
78
|
+
use_llm=True,
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Meu nome é [PERSON_NAME_0], moro na [LOCATION_0], 123 e meu CPF é [CPF_0].
|
|
84
|
+
|
|
85
|
+
{'[CPF_0]': '111.222.333-44',
|
|
86
|
+
'[PERSON_NAME_0]': 'Maria',
|
|
87
|
+
'[LOCATION_0]': 'Rua das Flores'}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Note the house number `123` left unmasked: that is real output, and it illustrates why
|
|
91
|
+
the regex layer is the one you rely on for guarantees.
|
|
92
|
+
|
|
93
|
+
Run both layers end to end with `python examples/demo.py`.
|
|
94
|
+
|
|
95
|
+
## Detected patterns
|
|
96
|
+
|
|
97
|
+
**pt-br** — `CPF`, `CNPJ_NUMERICO_FORMATADO`, `CNPJ_ALFANUMERICO`, `CNPJ_NUMERICO`,
|
|
98
|
+
`CNPJ_ALFANUMERICO_SEM_PONTUACAO`, `RG`, `PIS_PASEP`, `TITULO_ELEITOR`, `CNH`, `EMAIL`,
|
|
99
|
+
`TELEFONE_BR`, `CEP`, `DATA`, `PLACA_VEICULO`, `PLACA_VEICULO_MERCOSUL`,
|
|
100
|
+
`CARTAO_CREDITO`, `CARTAO_CREDITO_AMEX`, `CHAVE_PIX_ALEATORIA`, `AGENCIA`,
|
|
101
|
+
`CONTA_BANCARIA`, `IP`
|
|
102
|
+
|
|
103
|
+
**en** — `EMAIL`, `SSN`, `CREDIT_CARD`, `CREDIT_CARD_AMEX`, `US_PHONE`, `US_ZIP`, `IP`
|
|
104
|
+
|
|
105
|
+
**LLM entity types** — `PERSON_NAME`, `LOCATION`, `ORGANIZATION`, `DATE`, `AGE`,
|
|
106
|
+
`TIME`, `MONEY`, `PERCENT`, `FACILITY`, `GPE` (configurable via
|
|
107
|
+
`list_of_undeterministics_entities`; the list is injected into the response schema as an
|
|
108
|
+
enum, so the model cannot return a type outside it).
|
|
109
|
+
|
|
110
|
+
## Design notes
|
|
111
|
+
|
|
112
|
+
**Declaration order is priority.** Some values match several patterns — 11 bare digits
|
|
113
|
+
are both a CPF and a mobile number. The first pattern declared wins, so formatted
|
|
114
|
+
patterns come first and ambiguous bare numerics last.
|
|
115
|
+
|
|
116
|
+
**Longest value is replaced first.** The leading 12 digits of a spaced credit card also
|
|
117
|
+
match a voter ID. Replacing the shorter value first would corrupt the longer one.
|
|
118
|
+
|
|
119
|
+
**Label-anchored patterns include the label.** `CNH`, `AGENCIA` and `CONTA_BANCARIA` mask
|
|
120
|
+
the label too (`CNH: 12345678901` → `[CNH_0]`), because the bare number is
|
|
121
|
+
indistinguishable from a CPF without it.
|
|
122
|
+
|
|
123
|
+
**LLM values absent from the text are discarded.** A paraphrased or invented value would
|
|
124
|
+
produce a no-op substitution while making the output *look* anonymized.
|
|
125
|
+
|
|
126
|
+
## Known limitations
|
|
127
|
+
|
|
128
|
+
- **No check-digit validation.** A well-formed but arithmetically invalid CPF is still
|
|
129
|
+
masked. For anonymization, over-detection is the safe side.
|
|
130
|
+
- **`DATA` matches any `dd/mm/aaaa` date**, with no way to tell a birth date from any
|
|
131
|
+
other. Distinguishing them requires context, which is the LLM layer's job.
|
|
132
|
+
- **`US_ZIP` matches any 5-digit number**, and bare 11-digit runs are inherently
|
|
133
|
+
ambiguous between CPF, CNH and mobile numbers.
|
|
134
|
+
- **`MONEY`, `PERCENT` and `TIME` are not PII** and only add noise. Adjust
|
|
135
|
+
`list_of_undeterministics_entities` for your use case — the list is injected into the
|
|
136
|
+
response schema as an enum, so it is the effective control over what the model may
|
|
137
|
+
return.
|
|
138
|
+
- **Tag numbering may skip an index.** When a short value is also a substring of a longer
|
|
139
|
+
masked one, it stays registered to catch standalone occurrences elsewhere. Dropping it
|
|
140
|
+
would leak PII.
|
|
141
|
+
|
|
142
|
+
## Tests
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pip install -e ".[dev]" # needs Python 3.9+, as declared in pyproject.toml
|
|
146
|
+
pytest # 53 tests, hermetic: no server, no network (~2s)
|
|
147
|
+
pytest -m llm # 3 prompt-regression tests against a real Ollama
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The default suite stubs every LLM response, so it needs no server. The `llm` marker is
|
|
151
|
+
deselected by default and holds the tests that call the model for real — they verify the
|
|
152
|
+
prompt rules still take effect, which a stub cannot do, and skip themselves when Ollama
|
|
153
|
+
or the model is unavailable.
|
|
154
|
+
|
|
155
|
+
## Layout
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
src/anon_lib/__init__.py public API: Anonymizer, __version__
|
|
159
|
+
src/anon_lib/anonymizer.py Anonymizer
|
|
160
|
+
src/anon_lib/patterns/patterns.py regex pattern tables, per language
|
|
161
|
+
src/anon_lib/schemas/models_schemas.py AnonOutput / PIIEntity (LLM response schema)
|
|
162
|
+
examples/demo.py runnable demo of both layers
|
|
163
|
+
tests/test_anonymizer.py test suite
|
|
164
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anon-lib"
|
|
7
|
+
dynamic = ["version"] # fonte unica: __version__ em src/anon_lib/__init__.py
|
|
8
|
+
description = "PII detection and masking for Brazilian Portuguese and English"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8.13"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Wyctor Fogos da Rocha"}]
|
|
14
|
+
keywords = ["pii", "anonymization", "lgpd", "portuguese", "ner"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Natural Language :: Portuguese (Brazilian)",
|
|
19
|
+
"Natural Language :: English",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Text Processing :: Linguistic",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Importado no topo de anonymizer.py, entao e obrigatorio: sem ele o
|
|
30
|
+
# pacote instala e quebra no import.
|
|
31
|
+
dependencies = ["langchain-text-splitters>=0.2"]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# Carregados sob demanda em Anonymizer._require_llm(), so no modo use_llm=True.
|
|
35
|
+
llm = ["ollama>=0.3", "pydantic>=2"]
|
|
36
|
+
dev = ["pytest>=7", "python-dotenv", "anon-lib[llm]"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/wyctorfogos/anon-lib"
|
|
40
|
+
Issues = "https://github.com/wyctorfogos/anon-lib/issues"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
path = "src/anon_lib/__init__.py"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/anon_lib"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["/src/anon_lib", "/tests", "/README.md", "/LICENSE", "/pytest.ini"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[pytest]
|
|
2
|
+
testpaths = tests
|
|
3
|
+
pythonpath = src
|
|
4
|
+
markers =
|
|
5
|
+
llm: exercita o Ollama de verdade (regressao de prompt). Rode com: pytest -m llm
|
|
6
|
+
# Os plugins pytest do faker e do hypothesis instalados neste ambiente quebram
|
|
7
|
+
# na importação sob Python 3.8 (typing não-subscriptável / pydantic._hypothesis_plugin
|
|
8
|
+
# ausente). Nenhum dos dois é usado pelos testes, então desligamos o autoload deles.
|
|
9
|
+
# -m "not llm": a suíte padrão fica hermética e sem rede; veja o bloco de
|
|
10
|
+
# regressão de prompt no fim do arquivo de testes.
|
|
11
|
+
addopts = -p no:faker -p no:hypothesispytest -m "not llm"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Deteccao e mascaramento de PII em texto livre (pt-br e en).
|
|
2
|
+
|
|
3
|
+
Camada regex, deterministica e sem rede, opcionalmente somada a uma camada LLM
|
|
4
|
+
via Ollama para entidades que dependem de contexto (nomes, lugares, idades).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from anon_lib.anonymizer import Anonymizer
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["Anonymizer", "__version__"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
import copy
|
|
4
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
5
|
+
from anon_lib.patterns.patterns import _patterns_by_language
|
|
6
|
+
|
|
7
|
+
class Anonymizer:
|
|
8
|
+
def __init__(self,
|
|
9
|
+
llm_model_name:str="hf.co/empero-ai/Qwen3.8-4B-Distill-GGUF:Q4_K_M",
|
|
10
|
+
llm_ipaddress_service:str="http://localhost:11434",
|
|
11
|
+
language="pt-br"
|
|
12
|
+
):
|
|
13
|
+
self.llm_model_name=llm_model_name
|
|
14
|
+
self.llm_ipaddress_service=llm_ipaddress_service
|
|
15
|
+
self._llm_client=None # criado sob demanda; veja _require_llm()
|
|
16
|
+
self.language=language
|
|
17
|
+
self._patterns_by_language=_patterns_by_language
|
|
18
|
+
self.list_of_undeterministics_entities = [
|
|
19
|
+
"PERSON_NAME", "LOCATION", "ORGANIZATION",
|
|
20
|
+
"DATE", "AGE",
|
|
21
|
+
"TIME", "MONEY", "PERCENT", "FACILITY", "GPE"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
self.prompt = (
|
|
25
|
+
"Você é um detector de dados pessoais (PII). Devolva as entidades "
|
|
26
|
+
"encontradas no texto em JSON, seguindo o schema pedido.\n"
|
|
27
|
+
"Regras:\n"
|
|
28
|
+
"- Copie cada valor exatamente como aparece no texto, sem reescrever.\n"
|
|
29
|
+
"- Não devolva o texto mascarado: quem mascara é o código.\n"
|
|
30
|
+
"- Se não houver nenhuma entidade, devolva uma lista vazia.\n"
|
|
31
|
+
"- Capture TODOS os nomes de pessoas, incluindo terceiros mencionados "
|
|
32
|
+
"por vínculo familiar, profissional ou social "
|
|
33
|
+
"(ex: 'pai de', 'filha de', 'cônjuge de', 'advogado de').\n"
|
|
34
|
+
"- Capture idades expressas como número seguido de 'anos' "
|
|
35
|
+
"(ex: '35 anos', 'com 12 anos') como entidade do tipo AGE."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _require_llm(self):
|
|
39
|
+
"""Carrega as dependencias opcionais do modo LLM e devolve (client, schema).
|
|
40
|
+
|
|
41
|
+
Erro de import vira mensagem acionavel: sem isso o usuario receberia um
|
|
42
|
+
ModuleNotFoundError cru, sem pista de que existe um extra a instalar.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
import ollama
|
|
46
|
+
from anon_lib.schemas.models_schemas import AnonOutput
|
|
47
|
+
except ImportError as e:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"use_llm=True requer as dependencias opcionais do modo LLM: "
|
|
50
|
+
"pip install anon-lib[llm]"
|
|
51
|
+
) from e
|
|
52
|
+
if self._llm_client is None:
|
|
53
|
+
self._llm_client = ollama.Client(host=self.llm_ipaddress_service)
|
|
54
|
+
return self._llm_client, AnonOutput
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def list_of_words_patterns(self):
|
|
58
|
+
try:
|
|
59
|
+
# Obtém a lista de padrões a serem procurados
|
|
60
|
+
lista_por_idioma=self._patterns_by_language.get(self.language)
|
|
61
|
+
if lista_por_idioma is None:
|
|
62
|
+
raise ValueError(f"Idioma '{self.language}' não suportado.")
|
|
63
|
+
return lista_por_idioma
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise ValueError(f"Erro ao obter a lista de padrões: {e}")
|
|
66
|
+
|
|
67
|
+
def split_setences(self, sentence_text:str):
|
|
68
|
+
try:
|
|
69
|
+
text_splitter = RecursiveCharacterTextSplitter(
|
|
70
|
+
separators=["\n\n", "\n", ".", " ", ""],
|
|
71
|
+
chunk_size=1000,
|
|
72
|
+
chunk_overlap=100
|
|
73
|
+
)
|
|
74
|
+
return text_splitter.split_text(sentence_text)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise ValueError(f"Erro ao realizar o split das sentenças textuais: {e}") from e
|
|
77
|
+
|
|
78
|
+
def find_keywords_and_replace(self, chunks_of_sentence_text:str=None):
|
|
79
|
+
if not chunks_of_sentence_text:
|
|
80
|
+
raise ValueError(f"Sem sentença a análisar.")
|
|
81
|
+
|
|
82
|
+
dict_found_keywords={}
|
|
83
|
+
valores_ja_capturados=set()
|
|
84
|
+
for chunk in chunks_of_sentence_text:
|
|
85
|
+
for pii_type, pattern in self.list_of_words_patterns.items():
|
|
86
|
+
for match in re.finditer(pattern, chunk):
|
|
87
|
+
valor=match.group(0)
|
|
88
|
+
if valor in valores_ja_capturados:
|
|
89
|
+
continue
|
|
90
|
+
valores_ja_capturados.add(valor)
|
|
91
|
+
dict_found_keywords.setdefault(pii_type, set()).add(valor)
|
|
92
|
+
return dict_found_keywords
|
|
93
|
+
|
|
94
|
+
def get_llm_response(self, prompt:str, format_schema:dict=None):
|
|
95
|
+
client, AnonOutput = self._require_llm()
|
|
96
|
+
try:
|
|
97
|
+
response = client.chat(
|
|
98
|
+
model=self.llm_model_name,
|
|
99
|
+
messages=[{"role": "user", "content": prompt}],
|
|
100
|
+
format=format_schema or AnonOutput.model_json_schema(),
|
|
101
|
+
options={"temperature": 0}
|
|
102
|
+
)
|
|
103
|
+
return response
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise ValueError(f"Erro ao obter resposta do LLM: {e}") from e
|
|
106
|
+
|
|
107
|
+
def find_undeterministic_entities(self, sentence_text:str):
|
|
108
|
+
prompt = (
|
|
109
|
+
f"{self.prompt}\n\n"
|
|
110
|
+
f"Tipos de entidade a procurar:\n"
|
|
111
|
+
f"{', '.join(self.list_of_undeterministics_entities)}\n\n"
|
|
112
|
+
f"Ignore estes tipos, já tratados por regex:\n"
|
|
113
|
+
f"{', '.join(self.list_of_words_patterns)}\n\n"
|
|
114
|
+
f"Texto:\n{sentence_text.strip()}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
_, AnonOutput = self._require_llm()
|
|
118
|
+
schema = copy.deepcopy(AnonOutput.model_json_schema())
|
|
119
|
+
schema["$defs"]["PIIEntity"]["properties"]["tipo"]["enum"] = list(
|
|
120
|
+
self.list_of_undeterministics_entities
|
|
121
|
+
)
|
|
122
|
+
response = self.get_llm_response(prompt=prompt, format_schema=schema)
|
|
123
|
+
|
|
124
|
+
conteudo = getattr(response.message, "content", None)
|
|
125
|
+
if not conteudo:
|
|
126
|
+
raise ValueError("Resposta do LLM veio vazia.")
|
|
127
|
+
try:
|
|
128
|
+
resultado = AnonOutput.model_validate_json(conteudo)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
raise ValueError(f"Resposta do LLM não bate com o schema: {e}") from e
|
|
131
|
+
|
|
132
|
+
entidades = {}
|
|
133
|
+
for entidade in resultado.anon_model_output:
|
|
134
|
+
if entidade.valor and entidade.valor in sentence_text:
|
|
135
|
+
entidades.setdefault(entidade.tipo, set()).add(entidade.valor)
|
|
136
|
+
return entidades
|
|
137
|
+
|
|
138
|
+
def anonimize_text(self, sentence_text: str, use_llm: bool = False):
|
|
139
|
+
chunks = self.split_setences(sentence_text=sentence_text)
|
|
140
|
+
pii_words = self.find_keywords_and_replace(chunks_of_sentence_text=chunks)
|
|
141
|
+
|
|
142
|
+
if use_llm:
|
|
143
|
+
ja_capturados = {v for valores in pii_words.values() for v in valores}
|
|
144
|
+
for tipo, valores in self.find_undeterministic_entities(sentence_text).items():
|
|
145
|
+
novos = {v for v in valores
|
|
146
|
+
if not any(v in j or j in v for j in ja_capturados)}
|
|
147
|
+
if novos:
|
|
148
|
+
pii_words.setdefault(tipo, set()).update(novos)
|
|
149
|
+
|
|
150
|
+
tags = {
|
|
151
|
+
f"[{tipo}_{i}]": valor
|
|
152
|
+
for tipo, valores in pii_words.items()
|
|
153
|
+
for i, valor in enumerate(sorted(valores))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
masked_text = sentence_text
|
|
157
|
+
for tag, valor in sorted(tags.items(), key=lambda kv: len(kv[1]), reverse=True):
|
|
158
|
+
masked_text = masked_text.replace(valor, tag)
|
|
159
|
+
|
|
160
|
+
return masked_text, {t: v for t, v in tags.items() if t in masked_text}
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
_patterns_by_language={
|
|
2
|
+
"pt-br" : {
|
|
3
|
+
"EMAIL": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
|
|
4
|
+
"CHAVE_PIX_ALEATORIA": r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
|
|
5
|
+
"CNPJ_NUMERICO_FORMATADO": r"\b\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}\b",
|
|
6
|
+
"CNPJ_ALFANUMERICO": r"\b[A-Z0-9]{2}\.[A-Z0-9]{3}\.[A-Z0-9]{3}/[A-Z0-9]{4}-\d{2}\b",
|
|
7
|
+
"RG": r"\b\d{2}\.\d{3}\.\d{3}-[0-9Xx]\b",
|
|
8
|
+
"PIS_PASEP": r"\b\d{3}\.\d{5}\.\d{2}-\d\b",
|
|
9
|
+
"CEP": r"(?<!\d)\d{5}-\d{3}(?!\d)",
|
|
10
|
+
"DATA": r"\b\d{2}/\d{2}/\d{4}\b",
|
|
11
|
+
"PLACA_VEICULO_MERCOSUL": r"\b[A-Z]{3}\d[A-Z]\d{2}\b",
|
|
12
|
+
"PLACA_VEICULO": r"\b[A-Z]{3}[- ]?\d{4}\b",
|
|
13
|
+
# --- Financeiro ---
|
|
14
|
+
"CARTAO_CREDITO_AMEX": r"(?<!\d)3[47]\d{2}[ .-]?\d{6}[ .-]?\d{5}(?!\d)",
|
|
15
|
+
"CARTAO_CREDITO": r"(?<!\d)(?:\d{4}[ .-]?){3}\d{4}(?!\d)",
|
|
16
|
+
"IP": r"(?<!\d)(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?!\d)",
|
|
17
|
+
"CNH": r"(?i)\bCNH\s*n?[º°]?\s*:?\s*\d{11}\b",
|
|
18
|
+
"AGENCIA": r"(?i)\bag(?:[êe]ncia)?\.?\s*n?[º°]?\s*:?\s*\d{4}(?:-\d)?\b",
|
|
19
|
+
"CONTA_BANCARIA": r"(?i)\b(?:c\/c|cc|conta)\.?\s*n?[º°]?\s*:?\s*\d{4,12}-?\d?\b",
|
|
20
|
+
# --- Numéricos crus (mais ambíguos, por último) ---
|
|
21
|
+
"TELEFONE_BR": r"(?<!\d)(?:\+55[-. ]?)?\(?\d{2}\)?[-. ]?9?\d{4}[-. ]?\d{4}(?!\d)",
|
|
22
|
+
"TITULO_ELEITOR": r"(?<!\d)\d{4}[ .]?\d{4}[ .]?\d{4}(?!\d)",
|
|
23
|
+
"CNPJ_NUMERICO": r"\b\d{14}\b",
|
|
24
|
+
"CNPJ_ALFANUMERICO_SEM_PONTUACAO": r"\b[A-Z0-9]{12}\d{2}\b",
|
|
25
|
+
"CPF": r"\b\d{3}\.\d{3}\.\d{3}[.-]\d{2}\b|(?<!\d)\d{11}(?!\d)",
|
|
26
|
+
},
|
|
27
|
+
"en": {
|
|
28
|
+
"EMAIL": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
|
|
29
|
+
"SSN": r"(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)",
|
|
30
|
+
"CREDIT_CARD_AMEX": r"(?<!\d)3[47]\d{2}[ .-]?\d{6}[ .-]?\d{5}(?!\d)",
|
|
31
|
+
"CREDIT_CARD": r"(?<!\d)(?:\d{4}[ .-]?){3}\d{4}(?!\d)",
|
|
32
|
+
"IP": r"(?<!\d)(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?!\d)",
|
|
33
|
+
"US_PHONE": r"(?<!\d)(?:\+?1[-.]?)?\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})(?!\d)",
|
|
34
|
+
"US_ZIP": r"\b\d{5}(?:-\d{4})?\b"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PIIEntity(BaseModel):
|
|
7
|
+
"""Uma entidade PII localizada pelo LLM no texto."""
|
|
8
|
+
|
|
9
|
+
tipo: str = Field(description="Tipo da entidade: PERSON, AGE, ADDRESS, ...")
|
|
10
|
+
valor: str = Field(description="Trecho exato copiado do texto, sem reescrever")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnonOutput(BaseModel):
|
|
14
|
+
"""Saída estruturada esperada do LLM.
|
|
15
|
+
|
|
16
|
+
É uma lista de objetos, e não um dict {tipo: valor}, porque um dict não pode
|
|
17
|
+
repetir a mesma chave: um texto com duas pessoas perderia uma delas em
|
|
18
|
+
silêncio (é o mesmo defeito do exemplo de saída que está no README).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
anon_model_output: List[PIIEntity]
|
|
File without changes
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
# src entra no sys.path via `pythonpath` no pytest.ini; este bloco cobre a
|
|
10
|
+
# execucao direta do arquivo, em que sys.path[0] seria tests/.
|
|
11
|
+
_RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
_SRC = os.path.join(_RAIZ, "src")
|
|
13
|
+
if _SRC not in sys.path:
|
|
14
|
+
sys.path.insert(0, _SRC)
|
|
15
|
+
|
|
16
|
+
from anon_lib import Anonymizer
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="module")
|
|
19
|
+
def anon_ptbr():
|
|
20
|
+
return Anonymizer(language="pt-br")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(scope="module")
|
|
24
|
+
def anon_en():
|
|
25
|
+
return Anonymizer(language="en")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mascarar(anon, texto, **kwargs):
|
|
29
|
+
"""anonimize_text devolve (texto_mascarado, tags); aqui interessa so o texto."""
|
|
30
|
+
return anon.anonimize_text(texto, **kwargs)[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------
|
|
34
|
+
# Um caso por padrão declarado em pt-br
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
CASOS_PTBR = [
|
|
37
|
+
("Contato: joao.silva@empresa.com.br", "Contato: [EMAIL_0]"),
|
|
38
|
+
("Pix: 550e8400-e29b-41d4-a716-446655440000", "Pix: [CHAVE_PIX_ALEATORIA_0]"),
|
|
39
|
+
("CPF 123.345.123-45", "CPF [CPF_0]"),
|
|
40
|
+
("CNPJ 12.345.678/0001-99", "CNPJ [CNPJ_NUMERICO_FORMATADO_0]"),
|
|
41
|
+
("CNPJ 12.ABC.345/01DE-35", "CNPJ [CNPJ_ALFANUMERICO_0]"),
|
|
42
|
+
("RG 12.345.678-9", "RG [RG_0]"),
|
|
43
|
+
("PIS 123.45678.90-1", "PIS [PIS_PASEP_0]"),
|
|
44
|
+
("CEP 01310-100", "CEP [CEP_0]"),
|
|
45
|
+
("Nasceu em 05/09/1990", "Nasceu em [DATA_0]"),
|
|
46
|
+
("Placa ABC1D23", "Placa [PLACA_VEICULO_MERCOSUL_0]"),
|
|
47
|
+
("Placa ABC-1234", "Placa [PLACA_VEICULO_0]"),
|
|
48
|
+
("Cartao 4111 1111 1111 1111", "Cartao [CARTAO_CREDITO_0]"),
|
|
49
|
+
("Amex 3782 822463 10005", "Amex [CARTAO_CREDITO_AMEX_0]"),
|
|
50
|
+
("Servidor 192.168.0.14", "Servidor [IP_0]"),
|
|
51
|
+
("Ligue (11) 98765-4321", "Ligue [TELEFONE_BR_0]"),
|
|
52
|
+
("Ligue +55 11 98765-4321", "Ligue [TELEFONE_BR_0]"),
|
|
53
|
+
("Ligue 1134567890", "Ligue [TELEFONE_BR_0]"),
|
|
54
|
+
("Titulo 1234 5678 9012", "Titulo [TITULO_ELEITOR_0]"),
|
|
55
|
+
("CNPJ 12345678000199", "CNPJ [CNPJ_NUMERICO_0]"),
|
|
56
|
+
("CPF 12345678901", "CPF [CPF_0]"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("texto,esperado", CASOS_PTBR)
|
|
61
|
+
def test_padroes_ptbr(anon_ptbr, texto, esperado):
|
|
62
|
+
assert mascarar(anon_ptbr, texto) == esperado
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
CASOS_EN = [
|
|
66
|
+
("Email me at jane.doe@example.com", "Email me at [EMAIL_0]"),
|
|
67
|
+
("SSN 123-45-6789", "SSN [SSN_0]"),
|
|
68
|
+
("Card 4111-1111-1111-1111", "Card [CREDIT_CARD_0]"),
|
|
69
|
+
("Amex 3782 822463 10005", "Amex [CREDIT_CARD_AMEX_0]"),
|
|
70
|
+
("Server 192.168.0.14", "Server [IP_0]"),
|
|
71
|
+
("ZIP 90210", "ZIP [US_ZIP_0]"),
|
|
72
|
+
("ZIP 90210-1234", "ZIP [US_ZIP_0]"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("texto,esperado", CASOS_EN)
|
|
77
|
+
def test_padroes_en(anon_en, texto, esperado):
|
|
78
|
+
assert mascarar(anon_en, texto) == esperado
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_telefone_en_preserva_pontuacao_de_abertura(anon_en):
|
|
82
|
+
"""O \\b inicial fazia o '(' e o '+' ficarem de fora do match."""
|
|
83
|
+
assert mascarar(anon_en, "Call (415) 555-2671 now") == "Call [US_PHONE_0] now"
|
|
84
|
+
assert mascarar(anon_en, "Call +1-415-555-2671 now") == "Call [US_PHONE_0] now"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------
|
|
88
|
+
# Padrões ancorados por rótulo: o rótulo entra no trecho mascarado
|
|
89
|
+
# --------------------------------------------------------------------------
|
|
90
|
+
CASOS_COM_ROTULO = [
|
|
91
|
+
("CNH: 12345678901", "[CNH_0]"),
|
|
92
|
+
("Agencia 0001-2", "[AGENCIA_0]"),
|
|
93
|
+
("conta 123456-7", "[CONTA_BANCARIA_0]"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.parametrize("texto,esperado", CASOS_COM_ROTULO)
|
|
98
|
+
def test_padroes_ancorados_por_rotulo(anon_ptbr, texto, esperado):
|
|
99
|
+
assert mascarar(anon_ptbr, texto) == esperado
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# Resolução de conflito entre padrões
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
def test_cartao_vence_titulo_de_eleitor(anon_ptbr):
|
|
106
|
+
"""Os 12 primeiros dígitos de um cartão espaçado casam com TITULO_ELEITOR.
|
|
107
|
+
|
|
108
|
+
Se o valor curto fosse substituído primeiro, o cartão seria corrompido.
|
|
109
|
+
"""
|
|
110
|
+
saida = mascarar(anon_ptbr, "Cartao 1234 5678 9012 3456 pago hoje")
|
|
111
|
+
assert saida == "Cartao [CARTAO_CREDITO_0] pago hoje"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_cnpj_numerico_tem_prioridade_sobre_alfanumerico(anon_ptbr):
|
|
115
|
+
"""[A-Z0-9] também casa dígitos: o padrão numérico precisa vir antes."""
|
|
116
|
+
assert "[CNPJ_NUMERICO_FORMATADO_0]" in mascarar(anon_ptbr, "12.345.678/0001-99")
|
|
117
|
+
assert "[CNPJ_NUMERICO_0]" in mascarar(anon_ptbr, "12345678000199")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_sem_chaves_duplicadas_no_dicionario_de_padroes():
|
|
121
|
+
"""Chave repetida num dict literal é sobrescrita em silêncio.
|
|
122
|
+
|
|
123
|
+
Não dá para detectar em runtime: o Python já descartou a duplicata na análise
|
|
124
|
+
sintática. Por isso lemos a AST do fonte. Este bug já apagou padrões duas
|
|
125
|
+
vezes (as 4 chaves "CNPJ", depois um segundo "CPF" que matou o formatado).
|
|
126
|
+
"""
|
|
127
|
+
import ast
|
|
128
|
+
|
|
129
|
+
from anon_lib import anonymizer
|
|
130
|
+
from anon_lib.patterns import patterns
|
|
131
|
+
|
|
132
|
+
# Varre os dois: o dicionário de padrões mora em patterns/patterns.py e a
|
|
133
|
+
# lógica em anonymizer.py. Olhar só um deixaria o outro desprotegido.
|
|
134
|
+
duplicadas = []
|
|
135
|
+
for modulo in (patterns, anonymizer):
|
|
136
|
+
with open(modulo.__file__, encoding="utf-8") as arquivo:
|
|
137
|
+
arvore = ast.parse(arquivo.read())
|
|
138
|
+
for no in ast.walk(arvore):
|
|
139
|
+
if not isinstance(no, ast.Dict):
|
|
140
|
+
continue
|
|
141
|
+
chaves = [
|
|
142
|
+
k.value for k in no.keys
|
|
143
|
+
if isinstance(k, ast.Constant) and isinstance(k.value, str)
|
|
144
|
+
]
|
|
145
|
+
duplicadas += sorted({
|
|
146
|
+
f"{os.path.basename(modulo.__file__)}:{c}"
|
|
147
|
+
for c in chaves if chaves.count(c) > 1
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
assert duplicadas == [], f"chaves duplicadas apagam padroes: {duplicadas}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_cpf_cobre_as_duas_formas_com_a_mesma_tag(anon_ptbr):
|
|
154
|
+
assert mascarar(anon_ptbr, "CPF 123.456.789-00") == "CPF [CPF_0]"
|
|
155
|
+
assert mascarar(anon_ptbr, "CPF 12345678901") == "CPF [CPF_0]"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_telefone_tem_prioridade_sobre_cpf_sem_pontuacao(anon_ptbr):
|
|
159
|
+
"""11 dígitos crus são ambíguos; o padrão de celular é o mais específico."""
|
|
160
|
+
assert mascarar(anon_ptbr, "Ligue 11987654321") == "Ligue [TELEFONE_BR_0]"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_dotted_quad_invalido_como_ip_cai_em_cpf(anon_ptbr):
|
|
164
|
+
"""345 > 255, então não é IP; o CPF com separador tolerante pega."""
|
|
165
|
+
assert mascarar(anon_ptbr, "Doc 123.345.123.45") == "Doc [CPF_0]"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_valor_curto_contido_em_valor_longo_nao_vaza_isolado(anon_ptbr):
|
|
169
|
+
"""O número da CNH rotulada também aparece solto: as duas ocorrências somem."""
|
|
170
|
+
saida = mascarar(anon_ptbr, "CNH: 12345678901 e de novo 12345678901 aqui")
|
|
171
|
+
assert "12345678901" not in saida
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
# Fronteira de chunk (chunk_overlap)
|
|
176
|
+
# --------------------------------------------------------------------------
|
|
177
|
+
@pytest.mark.parametrize("alvo", ["joao.silva@empresa.com.br", "123.345.123-45"])
|
|
178
|
+
def test_pii_na_fronteira_entre_chunks(anon_ptbr, alvo):
|
|
179
|
+
"""Com chunk_overlap=0 o dado partido na fronteira dos 1000 chars vazava."""
|
|
180
|
+
vazamentos = []
|
|
181
|
+
for n in range(940, 1060):
|
|
182
|
+
filler = ("palavra teste do documento. " * 200)[:n]
|
|
183
|
+
texto = f"{filler} {alvo} fim do documento."
|
|
184
|
+
if alvo in mascarar(anon_ptbr, texto):
|
|
185
|
+
vazamentos.append(n)
|
|
186
|
+
assert vazamentos == []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------------------
|
|
190
|
+
# Comportamento geral
|
|
191
|
+
# --------------------------------------------------------------------------
|
|
192
|
+
def test_texto_sem_pii_fica_intacto(anon_ptbr):
|
|
193
|
+
texto = "Reuniao marcada para a sala 3 do predio azul."
|
|
194
|
+
assert mascarar(anon_ptbr, texto) == texto
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_multiplos_valores_do_mesmo_tipo_recebem_tags_distintas(anon_ptbr):
|
|
198
|
+
saida = mascarar(anon_ptbr, "CPF 111.222.333-44 e CPF 555.666.777-88")
|
|
199
|
+
assert saida == "CPF [CPF_0] e CPF [CPF_1]"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_idioma_nao_suportado(anon_ptbr):
|
|
203
|
+
anon = Anonymizer(language="fr")
|
|
204
|
+
with pytest.raises(ValueError, match="não suportado"):
|
|
205
|
+
mascarar(anon, "texto qualquer")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize("entrada", [None, []])
|
|
209
|
+
def test_find_keywords_sem_conteudo(anon_ptbr, entrada):
|
|
210
|
+
with pytest.raises(ValueError, match="Sem sentença"):
|
|
211
|
+
anon_ptbr.find_keywords_and_replace(chunks_of_sentence_text=entrada)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_numeracao_de_tags_estavel_entre_processos():
|
|
215
|
+
"""Iterar um set de strings varia com o PYTHONHASHSEED; sorted() estabiliza."""
|
|
216
|
+
script = (
|
|
217
|
+
"from anon_lib import Anonymizer;"
|
|
218
|
+
"print(Anonymizer(language='pt-br').anonimize_text("
|
|
219
|
+
"'CPF 111.222.333-44, CPF 555.666.777-88, CPF 999.888.777-66'))"
|
|
220
|
+
)
|
|
221
|
+
# Herda o sys.path deste processo em vez de embutir um caminho relativo,
|
|
222
|
+
# para o teste não depender de onde o pytest foi chamado nem do layout.
|
|
223
|
+
saidas = set()
|
|
224
|
+
for seed in ("0", "1", "42", "12345"):
|
|
225
|
+
env = {
|
|
226
|
+
**os.environ,
|
|
227
|
+
"PYTHONHASHSEED": seed,
|
|
228
|
+
"PYTHONPATH": os.pathsep.join(p for p in sys.path if p),
|
|
229
|
+
}
|
|
230
|
+
resultado = subprocess.run(
|
|
231
|
+
[sys.executable, "-c", script],
|
|
232
|
+
env=env, capture_output=True, text=True, check=True,
|
|
233
|
+
)
|
|
234
|
+
saidas.add(resultado.stdout.strip())
|
|
235
|
+
assert len(saidas) == 1, f"numeracao instavel entre processos: {saidas}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------------------
|
|
239
|
+
# Caminho LLM (sem rede: a resposta do ollama é substituída por um stub)
|
|
240
|
+
# --------------------------------------------------------------------------
|
|
241
|
+
def _resposta_falsa(payload):
|
|
242
|
+
"""Imita o ChatResponse do ollama: o conteúdo fica em message.content."""
|
|
243
|
+
return SimpleNamespace(message=SimpleNamespace(content=json.dumps(payload)))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_llm_descarta_valor_que_nao_existe_no_texto(anon_ptbr, monkeypatch):
|
|
247
|
+
"""Valor alucinado/parafraseado viraria um re.sub inócuo, fingindo anonimizar."""
|
|
248
|
+
texto = "Meu nome é Maria e moro em Vitoria."
|
|
249
|
+
monkeypatch.setattr(
|
|
250
|
+
anon_ptbr, "get_llm_response",
|
|
251
|
+
lambda prompt, format_schema=None: _resposta_falsa({"anon_model_output": [
|
|
252
|
+
{"tipo": "PERSON_NAME", "valor": "Maria"},
|
|
253
|
+
{"tipo": "PERSON_NAME", "valor": "Mariazinha"}, # não está no texto
|
|
254
|
+
{"tipo": "LOCATION", "valor": "Vitoria"},
|
|
255
|
+
]}),
|
|
256
|
+
)
|
|
257
|
+
entidades = anon_ptbr.find_undeterministic_entities(texto)
|
|
258
|
+
assert entidades == {"PERSON_NAME": {"Maria"}, "LOCATION": {"Vitoria"}}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_llm_preserva_duas_entidades_do_mesmo_tipo(anon_ptbr, monkeypatch):
|
|
262
|
+
"""O motivo de a saída ser lista, e não dict: dict perderia um dos nomes."""
|
|
263
|
+
texto = "Maria é filha de Joshua Smith."
|
|
264
|
+
monkeypatch.setattr(
|
|
265
|
+
anon_ptbr, "get_llm_response",
|
|
266
|
+
lambda prompt, format_schema=None: _resposta_falsa({"anon_model_output": [
|
|
267
|
+
{"tipo": "PERSON_NAME", "valor": "Maria"},
|
|
268
|
+
{"tipo": "PERSON_NAME", "valor": "Joshua Smith"},
|
|
269
|
+
]}),
|
|
270
|
+
)
|
|
271
|
+
saida = mascarar(anon_ptbr, texto, use_llm=True)
|
|
272
|
+
assert saida == "[PERSON_NAME_1] é filha de [PERSON_NAME_0]."
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_regex_tem_prioridade_sobre_o_llm(anon_ptbr, monkeypatch):
|
|
276
|
+
"""Se os dois acharem o mesmo valor, ele não pode receber duas tags."""
|
|
277
|
+
texto = "O CPF 123.345.123-45 é da Maria."
|
|
278
|
+
monkeypatch.setattr(
|
|
279
|
+
anon_ptbr, "get_llm_response",
|
|
280
|
+
lambda prompt, format_schema=None: _resposta_falsa({"anon_model_output": [
|
|
281
|
+
{"tipo": "PERSON_NAME", "valor": "Maria"},
|
|
282
|
+
{"tipo": "MONEY", "valor": "123.345.123-45"}, # o regex já pegou
|
|
283
|
+
]}),
|
|
284
|
+
)
|
|
285
|
+
saida = mascarar(anon_ptbr, texto, use_llm=True)
|
|
286
|
+
assert saida == "O CPF [CPF_0] é da [PERSON_NAME_0]."
|
|
287
|
+
assert "MONEY" not in saida
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_llm_nao_duplica_tag_de_valor_sobreposto_ao_regex(anon_ptbr, monkeypatch):
|
|
291
|
+
"""O LLM devolve o telefone com o "+"; o regex capturou sem ele.
|
|
292
|
+
|
|
293
|
+
As strings diferem, então a deduplicação por igualdade deixava passar e o
|
|
294
|
+
mesmo telefone acabava com duas tags, uma delas órfã (no dict, fora do texto).
|
|
295
|
+
"""
|
|
296
|
+
texto = "Meu celular é +2799778-5677."
|
|
297
|
+
monkeypatch.setattr(
|
|
298
|
+
anon_ptbr, "get_llm_response",
|
|
299
|
+
lambda prompt, format_schema=None: _resposta_falsa({"anon_model_output": [
|
|
300
|
+
{"tipo": "PERSON_NAME", "valor": "+2799778-5677"},
|
|
301
|
+
]}),
|
|
302
|
+
)
|
|
303
|
+
masked, tags = anon_ptbr.anonimize_text(texto, use_llm=True)
|
|
304
|
+
assert list(tags) == ["[TELEFONE_BR_0]"]
|
|
305
|
+
assert "PERSON_NAME" not in masked
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_tags_devolvidas_existem_todas_no_texto(anon_ptbr, monkeypatch):
|
|
309
|
+
"""Tag órfã no mapa inviabiliza desanonimizar depois."""
|
|
310
|
+
texto = "CNH: 12345678901 e Maria."
|
|
311
|
+
monkeypatch.setattr(
|
|
312
|
+
anon_ptbr, "get_llm_response",
|
|
313
|
+
lambda prompt, format_schema=None: _resposta_falsa({"anon_model_output": [
|
|
314
|
+
{"tipo": "PERSON_NAME", "valor": "Maria"},
|
|
315
|
+
]}),
|
|
316
|
+
)
|
|
317
|
+
masked, tags = anon_ptbr.anonimize_text(texto, use_llm=True)
|
|
318
|
+
assert all(tag in masked for tag in tags), f"tags fora do texto: {tags}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_llm_rejeita_json_fora_do_schema(anon_ptbr, monkeypatch):
|
|
322
|
+
monkeypatch.setattr(
|
|
323
|
+
anon_ptbr, "get_llm_response",
|
|
324
|
+
lambda prompt, format_schema=None: _resposta_falsa({"resposta": "qualquer coisa"}),
|
|
325
|
+
)
|
|
326
|
+
with pytest.raises(ValueError, match="não bate com o schema"):
|
|
327
|
+
anon_ptbr.find_undeterministic_entities("Maria")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_anonimize_text_sem_llm_nao_toca_a_rede(anon_ptbr, monkeypatch):
|
|
331
|
+
"""use_llm=False é o default: a suíte inteira roda sem servidor."""
|
|
332
|
+
def explode(*args, **kwargs):
|
|
333
|
+
raise AssertionError("get_llm_response nao deveria ser chamado")
|
|
334
|
+
|
|
335
|
+
monkeypatch.setattr(anon_ptbr, "get_llm_response", explode)
|
|
336
|
+
assert mascarar(anon_ptbr, "CPF 123.345.123-45") == "CPF [CPF_0]"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --------------------------------------------------------------------------
|
|
340
|
+
# Regressão de PROMPT — chamadas reais ao Ollama.
|
|
341
|
+
#
|
|
342
|
+
# Ficam fora da suíte padrão (pytest.ini desmarca "llm") porque dependem de
|
|
343
|
+
# servidor e de um modelo, e porque a saída de um LLM não é garantida nem com
|
|
344
|
+
# temperature=0. Rode-os de propósito ao mexer no prompt:
|
|
345
|
+
#
|
|
346
|
+
# python3 -m pytest -m llm
|
|
347
|
+
#
|
|
348
|
+
# Stub não serviria aqui: o que se quer verificar é justamente se as regras do
|
|
349
|
+
# prompt continuam surtindo efeito no modelo.
|
|
350
|
+
# --------------------------------------------------------------------------
|
|
351
|
+
@pytest.fixture(scope="module")
|
|
352
|
+
def anon_llm():
|
|
353
|
+
from dotenv import load_dotenv
|
|
354
|
+
|
|
355
|
+
load_dotenv(os.path.join(_RAIZ, "config", ".env"))
|
|
356
|
+
modelo = os.getenv("LLM_MODEL_NAME")
|
|
357
|
+
anon = Anonymizer(**({"llm_model_name": modelo} if modelo else {}))
|
|
358
|
+
try:
|
|
359
|
+
client, _ = anon._require_llm()
|
|
360
|
+
client.show(anon.llm_model_name)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
pytest.skip(f"Ollama indisponivel ou modelo ausente: {e}")
|
|
363
|
+
return anon
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@pytest.mark.llm
|
|
367
|
+
def test_llm_captura_pessoa_citada_por_vinculo_familiar(anon_llm):
|
|
368
|
+
"""Regra do prompt: terceiros mencionados por vínculo ('filha de X').
|
|
369
|
+
|
|
370
|
+
Antes da regra, o modelo devolvia só a pessoa que fala e o Joshua Smith
|
|
371
|
+
passava batido — ou vinha rotulado como ORGANIZATION.
|
|
372
|
+
"""
|
|
373
|
+
texto = "Ele tinha 35 anos, sou filha de Joshua Smith e meu CPF é 111.222.333-44."
|
|
374
|
+
masked, tags = anon_llm.anonimize_text(texto, use_llm=True)
|
|
375
|
+
assert "Joshua Smith" not in masked
|
|
376
|
+
assert "Joshua Smith" in tags.values()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@pytest.mark.llm
|
|
380
|
+
def test_llm_captura_idade_como_age(anon_llm):
|
|
381
|
+
"""Regra do prompt: '<numero> anos' é AGE.
|
|
382
|
+
|
|
383
|
+
Sem AGE na lista de entidades, '35 anos' caía em DATE.
|
|
384
|
+
"""
|
|
385
|
+
masked, tags = anon_llm.anonimize_text("Ele tinha 35 anos.", use_llm=True)
|
|
386
|
+
assert "35 anos" not in masked
|
|
387
|
+
assert any(tag.startswith("[AGE_") for tag in tags), tags
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@pytest.mark.llm
|
|
391
|
+
def test_llm_captura_nome_solto_sem_vinculo(anon_llm):
|
|
392
|
+
"""Contraprova da regra de vínculo: nome sem parentesco declarado.
|
|
393
|
+
|
|
394
|
+
A regra fala em 'pai de', 'filha de'; era preciso confirmar que ela não
|
|
395
|
+
estreitou o modelo a ponto de ignorar um nome solto.
|
|
396
|
+
"""
|
|
397
|
+
texto = "Meu cpf é 123.345.123-45, o Carlos é amigo de todos."
|
|
398
|
+
masked, tags = anon_llm.anonimize_text(texto, use_llm=True)
|
|
399
|
+
assert "Carlos" not in masked
|
|
400
|
+
assert "Carlos" in tags.values()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
# Sem isto, rodar o arquivo direto apenas definiria as funções e sairia sem
|
|
406
|
+
# executar nada: fixtures e parametrize só têm efeito sob o pytest.
|
|
407
|
+
raise SystemExit(pytest.main([__file__, "-v"]))
|