veu 0.4.0__py3-none-any.whl
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.
- veu/__init__.py +78 -0
- veu/__main__.py +3 -0
- veu/_text.py +31 -0
- veu/analyzer.py +121 -0
- veu/anonymizer.py +209 -0
- veu/bench/__init__.py +1 -0
- veu/bench/evaluate.py +140 -0
- veu/bench/generator.py +241 -0
- veu/bench/hard.py +50 -0
- veu/cli.py +429 -0
- veu/config.py +357 -0
- veu/core.py +136 -0
- veu/data/ambiguous.txt +27 -0
- veu/data/first_names.txt +561 -0
- veu/data/organizations.txt +89 -0
- veu/data/places.txt +197 -0
- veu/data/segredos.json +83 -0
- veu/data/stopwords.txt +226 -0
- veu/data/surnames.txt +210 -0
- veu/demo.py +85 -0
- veu/documents.py +292 -0
- veu/entities.py +64 -0
- veu/files.py +180 -0
- veu/gateway/__init__.py +21 -0
- veu/gateway/adapters.py +652 -0
- veu/gateway/pipeline.py +99 -0
- veu/gateway/sse.py +130 -0
- veu/hooks.py +117 -0
- veu/integrations/__init__.py +1 -0
- veu/integrations/_transport.py +160 -0
- veu/integrations/decorator.py +137 -0
- veu/integrations/httpx.py +23 -0
- veu/integrations/httpx2.py +20 -0
- veu/integrations/langchain.py +201 -0
- veu/integrations/litellm.py +204 -0
- veu/integrations/logging.py +44 -0
- veu/integrations/loguru.py +30 -0
- veu/integrations/mcp.py +246 -0
- veu/integrations/pandas.py +58 -0
- veu/integrations/sdk.py +49 -0
- veu/integrations/sentry.py +70 -0
- veu/integrations/structlog.py +52 -0
- veu/recognizers/__init__.py +23 -0
- veu/recognizers/addresses.py +65 -0
- veu/recognizers/base.py +71 -0
- veu/recognizers/optional.py +110 -0
- veu/recognizers/patterns.py +238 -0
- veu/recognizers/persons.py +224 -0
- veu/recognizers/secrets.py +185 -0
- veu/reporting.py +122 -0
- veu/server/__init__.py +5 -0
- veu/server/app.py +482 -0
- veu/server/state.py +316 -0
- veu/server/static/playground.html +218 -0
- veu/sessions.py +77 -0
- veu/sources.py +460 -0
- veu/surrogates.py +319 -0
- veu/validators.py +293 -0
- veu/vault.py +285 -0
- veu-0.4.0.dist-info/METADATA +413 -0
- veu-0.4.0.dist-info/RECORD +64 -0
- veu-0.4.0.dist-info/WHEEL +4 -0
- veu-0.4.0.dist-info/entry_points.txt +2 -0
- veu-0.4.0.dist-info/licenses/LICENSE +21 -0
veu/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Véu: esconde dados pessoais brasileiros antes de enviar textos para modelos de IA.
|
|
2
|
+
|
|
3
|
+
Uso rápido:
|
|
4
|
+
|
|
5
|
+
import veu
|
|
6
|
+
|
|
7
|
+
resultado = veu.anonymize("Sou a Maria Souza, CPF 529.982.247-25")
|
|
8
|
+
resultado.text # "Sou a [PESSOA_1], CPF [CPF_1]"
|
|
9
|
+
veu.restore("Olá, [PESSOA_1]!", resultado.vault) # "Olá, Maria Souza!"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from veu.analyzer import Analyzer
|
|
15
|
+
from veu.anonymizer import OPERATORS, AnonymizationResult, Anonymizer
|
|
16
|
+
from veu.config import Config, ConfigError, load_config
|
|
17
|
+
from veu.core import Veu
|
|
18
|
+
from veu.entities import LABELS, Entity
|
|
19
|
+
from veu.vault import StreamRestorer, Vault
|
|
20
|
+
|
|
21
|
+
__version__ = "0.4.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LABELS",
|
|
25
|
+
"OPERATORS",
|
|
26
|
+
"AnonymizationResult",
|
|
27
|
+
"Analyzer",
|
|
28
|
+
"Anonymizer",
|
|
29
|
+
"Config",
|
|
30
|
+
"ConfigError",
|
|
31
|
+
"Entity",
|
|
32
|
+
"StreamRestorer",
|
|
33
|
+
"Vault",
|
|
34
|
+
"Veu",
|
|
35
|
+
"__version__",
|
|
36
|
+
"analyze",
|
|
37
|
+
"anonymize",
|
|
38
|
+
"load_config",
|
|
39
|
+
"protect",
|
|
40
|
+
"restore",
|
|
41
|
+
"session",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_default: Veu | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _instance() -> Veu:
|
|
48
|
+
global _default
|
|
49
|
+
if _default is None:
|
|
50
|
+
_default = Veu()
|
|
51
|
+
return _default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def analyze(text: str) -> list[Entity]:
|
|
55
|
+
"""Lista os dados pessoais encontrados no texto."""
|
|
56
|
+
return _instance().analyze(text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def anonymize(text: str, vault: Vault | None = None) -> AnonymizationResult:
|
|
60
|
+
"""Troca os dados pessoais por marcadores. O cofre do resultado restaura a resposta do modelo."""
|
|
61
|
+
return _instance().anonymize(text, vault)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def restore(text: str, vault: Vault) -> str:
|
|
65
|
+
"""Devolve os dados reais no lugar dos marcadores."""
|
|
66
|
+
return vault.restore(text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def session(vault: Vault | None = None):
|
|
70
|
+
"""Sessão com um cofre compartilhado: anonymize, anonymize_data, restore, restore_stream."""
|
|
71
|
+
return _instance().session(vault)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def protect(func=None, **options):
|
|
75
|
+
"""Decorador: anonimiza os argumentos e restaura o retorno (veja veu.integrations.decorator)."""
|
|
76
|
+
from veu.integrations.decorator import protect as decorator
|
|
77
|
+
|
|
78
|
+
return decorator(func, **options)
|
veu/__main__.py
ADDED
veu/_text.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Funções de texto compartilhadas pelos detectores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
_SPACES = re.compile(r"\s+")
|
|
9
|
+
_NON_DIGITS = re.compile(r"\D")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def strip_accents(text: str) -> str:
|
|
13
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
14
|
+
return "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fold(text: str) -> str:
|
|
18
|
+
"""Forma de comparação: minúsculas, sem acentos e com espaços simples."""
|
|
19
|
+
return _SPACES.sub(" ", strip_accents(text).casefold()).strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def only_digits(text: str) -> str:
|
|
23
|
+
return _NON_DIGITS.sub("", text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def words_regex(words) -> re.Pattern | None:
|
|
27
|
+
"""Regex que acha qualquer uma das palavras (já normalizadas com fold) como palavra inteira."""
|
|
28
|
+
terms = sorted({fold(word) for word in words if word}, key=len, reverse=True)
|
|
29
|
+
if not terms:
|
|
30
|
+
return None
|
|
31
|
+
return re.compile(r"(?<![\w/])(?:" + "|".join(re.escape(term) for term in terms) + r")(?![\w/])")
|
veu/analyzer.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Junta os detectores, filtra pela pontuação e resolve sobreposições."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from veu._text import fold, only_digits
|
|
10
|
+
from veu.entities import Entity
|
|
11
|
+
from veu.recognizers import (
|
|
12
|
+
AddressRecognizer,
|
|
13
|
+
Candidate,
|
|
14
|
+
PatternRecognizer,
|
|
15
|
+
PersonRecognizer,
|
|
16
|
+
Recognizer,
|
|
17
|
+
SecretRecognizer,
|
|
18
|
+
)
|
|
19
|
+
from veu.validators import normalize_cnpj
|
|
20
|
+
|
|
21
|
+
DEFAULT_THRESHOLD = 0.5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_recognizers() -> list[Recognizer]:
|
|
25
|
+
return [PatternRecognizer(), PersonRecognizer(), AddressRecognizer(), SecretRecognizer()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Analyzer:
|
|
29
|
+
"""Encontra dados pessoais em um texto.
|
|
30
|
+
|
|
31
|
+
threshold: pontuação mínima (0 a 1). O padrão 0,5 privilegia a privacidade: valores com formato
|
|
32
|
+
de documento, mas dígitos verificadores errados, também são escondidos.
|
|
33
|
+
labels: limita a detecção a alguns tipos (por padrão, todos).
|
|
34
|
+
allowlist: valores que nunca são escondidos, como o CNPJ ou o e-mail de contato da própria empresa.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
recognizers: Iterable[Recognizer] | None = None,
|
|
40
|
+
*,
|
|
41
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
42
|
+
labels: Iterable[str] | None = None,
|
|
43
|
+
allowlist: Iterable[str] = (),
|
|
44
|
+
allow_patterns: Iterable[str] = (),
|
|
45
|
+
):
|
|
46
|
+
self.recognizers = list(default_recognizers() if recognizers is None else recognizers)
|
|
47
|
+
self.threshold = threshold
|
|
48
|
+
self.labels = {label.upper() for label in labels} if labels else None
|
|
49
|
+
allowlist = list(allowlist)
|
|
50
|
+
self._allow_text = {fold(value) for value in allowlist}
|
|
51
|
+
self._allow_codes = {only_digits(value) for value in allowlist if len(only_digits(value)) >= 6}
|
|
52
|
+
self._allow_codes |= {normalize_cnpj(value) for value in allowlist if len(normalize_cnpj(value)) == 14}
|
|
53
|
+
self._allow_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in allow_patterns]
|
|
54
|
+
self._has_allowlist = bool(self._allow_text or self._allow_codes or self._allow_patterns)
|
|
55
|
+
|
|
56
|
+
def _allowed(self, value: str) -> bool:
|
|
57
|
+
if fold(value) in self._allow_text:
|
|
58
|
+
return True
|
|
59
|
+
if any(pattern.search(value) for pattern in self._allow_patterns):
|
|
60
|
+
return True
|
|
61
|
+
digits = only_digits(value)
|
|
62
|
+
return (len(digits) >= 6 and digits in self._allow_codes) or normalize_cnpj(value) in self._allow_codes
|
|
63
|
+
|
|
64
|
+
def candidates(self, text: str, threshold: float | None = None) -> list[Candidate]:
|
|
65
|
+
minimum = self.threshold if threshold is None else threshold
|
|
66
|
+
found = []
|
|
67
|
+
for recognizer in self.recognizers:
|
|
68
|
+
for candidate in recognizer.find(text):
|
|
69
|
+
if candidate.score < minimum:
|
|
70
|
+
continue
|
|
71
|
+
if self.labels is not None and candidate.label not in self.labels:
|
|
72
|
+
continue
|
|
73
|
+
if self._has_allowlist and self._allowed(text[candidate.start:candidate.end]):
|
|
74
|
+
continue
|
|
75
|
+
found.append(candidate)
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
def analyze(self, text: str, threshold: float | None = None) -> list[Entity]:
|
|
79
|
+
if not text:
|
|
80
|
+
return []
|
|
81
|
+
chosen = resolve_overlaps(self.candidates(text, threshold))
|
|
82
|
+
return [
|
|
83
|
+
Entity(c.label, c.start, c.end, c.score, text[c.start:c.end], c.recognizer)
|
|
84
|
+
for c in chosen
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_overlaps(candidates: list[Candidate]) -> list[Candidate]:
|
|
89
|
+
"""Escolhe um candidato por trecho.
|
|
90
|
+
|
|
91
|
+
1. Um candidato contido em outro maior é descartado (o endereço "Rua João Pessoa, 50" vence a pessoa
|
|
92
|
+
"João Pessoa"; o telefone com DDD vence o número local dentro dele).
|
|
93
|
+
2. Nas sobreposições parciais e nos trechos idênticos, vence a maior pontuação e, no empate, o mais longo.
|
|
94
|
+
"""
|
|
95
|
+
ordered = sorted(candidates, key=lambda c: (c.start, -c.end, -c.score))
|
|
96
|
+
uncontained: list[Candidate] = []
|
|
97
|
+
container: Candidate | None = None
|
|
98
|
+
for candidate in ordered:
|
|
99
|
+
if (
|
|
100
|
+
container is not None
|
|
101
|
+
and candidate.end <= container.end
|
|
102
|
+
and (candidate.start, candidate.end) != (container.start, container.end)
|
|
103
|
+
):
|
|
104
|
+
continue
|
|
105
|
+
uncontained.append(candidate)
|
|
106
|
+
if container is None or candidate.end > container.end:
|
|
107
|
+
container = candidate
|
|
108
|
+
|
|
109
|
+
starts: list[int] = []
|
|
110
|
+
ends: list[int] = []
|
|
111
|
+
chosen: list[Candidate] = []
|
|
112
|
+
for candidate in sorted(uncontained, key=lambda c: (-c.score, -(c.end - c.start), c.start)):
|
|
113
|
+
position = bisect.bisect_left(starts, candidate.start)
|
|
114
|
+
if position > 0 and ends[position - 1] > candidate.start:
|
|
115
|
+
continue
|
|
116
|
+
if position < len(starts) and starts[position] < candidate.end:
|
|
117
|
+
continue
|
|
118
|
+
starts.insert(position, candidate.start)
|
|
119
|
+
ends.insert(position, candidate.end)
|
|
120
|
+
chosen.append(candidate)
|
|
121
|
+
return sorted(chosen, key=lambda c: c.start)
|
veu/anonymizer.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Troca os dados pessoais encontrados por marcadores, substitutos ou máscaras."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from veu._text import fold
|
|
11
|
+
from veu.analyzer import Analyzer
|
|
12
|
+
from veu.entities import SECRET_LABELS, Entity
|
|
13
|
+
from veu.recognizers.base import folded_set
|
|
14
|
+
from veu.recognizers.persons import CONNECTORS
|
|
15
|
+
from veu.recognizers.secrets import secret_prefix
|
|
16
|
+
from veu.surrogates import make_surrogate
|
|
17
|
+
from veu.vault import Vault
|
|
18
|
+
|
|
19
|
+
OPERATORS = {
|
|
20
|
+
"token": "marcador reversível, como [CPF_1] (padrão)",
|
|
21
|
+
"sintetico": "valor falso do mesmo tipo e formato, reversível (outro CPF válido, outro nome)",
|
|
22
|
+
"mascara": "mascara parte do valor, como ***.982.247-**; não é reversível",
|
|
23
|
+
"remover": "troca pelo tipo, como [CPF]; não é reversível",
|
|
24
|
+
"hash": "marcador estável entre requisições, como [CPF_3fa2c1d9]; reversível com o mesmo cofre",
|
|
25
|
+
}
|
|
26
|
+
OPERATOR_ALIASES = {"marcador": "token", "sintético": "sintetico", "máscara": "mascara", "synthetic": "sintetico",
|
|
27
|
+
"mask": "mascara", "redact": "remover"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_operator(name: str) -> str:
|
|
31
|
+
key = OPERATOR_ALIASES.get(name.strip().lower(), name.strip().lower())
|
|
32
|
+
if key not in OPERATORS:
|
|
33
|
+
raise ValueError(f'Operador desconhecido: "{name}". Use um destes: {", ".join(OPERATORS)}.')
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Replacement:
|
|
39
|
+
entity: Entity
|
|
40
|
+
replacement: str
|
|
41
|
+
operator: str
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def label(self) -> str:
|
|
45
|
+
return self.entity.label
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
data = self.entity.to_dict()
|
|
49
|
+
data.pop("texto")
|
|
50
|
+
data["substituto"] = self.replacement
|
|
51
|
+
data["operador"] = self.operator
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class AnonymizationResult:
|
|
57
|
+
text: str
|
|
58
|
+
replacements: list[Replacement]
|
|
59
|
+
vault: Vault = field(repr=False)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def entities(self) -> list[Entity]:
|
|
63
|
+
return [item.entity for item in self.replacements]
|
|
64
|
+
|
|
65
|
+
def counts(self) -> Counter[str]:
|
|
66
|
+
return Counter(item.label for item in self.replacements)
|
|
67
|
+
|
|
68
|
+
def restore(self, text: str) -> str:
|
|
69
|
+
"""Restaura um texto produzido a partir deste resultado (por exemplo, a resposta do modelo)."""
|
|
70
|
+
return self.vault.restore(text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mask_value(label: str, value: str) -> str:
|
|
74
|
+
if label == "CPF":
|
|
75
|
+
chars = [char for char in value if char.isdigit()]
|
|
76
|
+
if len(chars) == 11:
|
|
77
|
+
visible = set(range(3, 9))
|
|
78
|
+
output, index = [], 0
|
|
79
|
+
for char in value:
|
|
80
|
+
if char.isdigit():
|
|
81
|
+
output.append(char if index in visible else "*")
|
|
82
|
+
index += 1
|
|
83
|
+
else:
|
|
84
|
+
output.append(char)
|
|
85
|
+
return "".join(output)
|
|
86
|
+
if label == "EMAIL" and "@" in value:
|
|
87
|
+
local, domain = value.rsplit("@", 1)
|
|
88
|
+
return f"{local[:1]}***@{domain}"
|
|
89
|
+
if label == "PESSOA":
|
|
90
|
+
return " ".join(word if fold(word) in CONNECTORS else word[:1] + "***" for word in value.split())
|
|
91
|
+
if label in ("SENHA", "CHAVE_PRIVADA"):
|
|
92
|
+
return "********"
|
|
93
|
+
if label == "URL_CONEXAO" and "://" in value:
|
|
94
|
+
return value.split("://", 1)[0] + "://****"
|
|
95
|
+
if label in SECRET_LABELS:
|
|
96
|
+
return secret_prefix(value) + "****"
|
|
97
|
+
positions = [index for index, char in enumerate(value) if char.isalnum()]
|
|
98
|
+
keep = set(positions[-2:]) if len(positions) > 4 else set()
|
|
99
|
+
return "".join(char if (not char.isalnum() or index in keep) else "*" for index, char in enumerate(value))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Anonymizer:
|
|
103
|
+
"""Anonimiza textos usando um Analyzer e um operador por tipo de dado.
|
|
104
|
+
|
|
105
|
+
operator: operador padrão ("token", "sintetico", "mascara", "remover" ou "hash").
|
|
106
|
+
operators: operador por tipo, como {"CARTAO_CREDITO": "remover"}.
|
|
107
|
+
link_names: aplica o marcador de um nome completo também às menções só ao primeiro nome ou ao
|
|
108
|
+
sobrenome ("João da Silva" ... "João" viram o mesmo [PESSOA_1]).
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
analyzer: Analyzer | None = None,
|
|
114
|
+
*,
|
|
115
|
+
operator: str = "token",
|
|
116
|
+
operators: dict[str, str] | None = None,
|
|
117
|
+
link_names: bool = True,
|
|
118
|
+
secret: bytes | None = None,
|
|
119
|
+
):
|
|
120
|
+
self.analyzer = analyzer or Analyzer()
|
|
121
|
+
self.operator = normalize_operator(operator)
|
|
122
|
+
self.operators = {label.upper(): normalize_operator(op) for label, op in (operators or {}).items()}
|
|
123
|
+
self.link_names = link_names
|
|
124
|
+
self.secret = secret
|
|
125
|
+
self._ambiguous = folded_set("ambiguous.txt")
|
|
126
|
+
|
|
127
|
+
def new_vault(self) -> Vault:
|
|
128
|
+
return Vault(secret=self.secret)
|
|
129
|
+
|
|
130
|
+
def anonymize(self, text: str, vault: Vault | None = None) -> AnonymizationResult:
|
|
131
|
+
vault = self.new_vault() if vault is None else vault
|
|
132
|
+
entities = self.analyzer.analyze(text)
|
|
133
|
+
canonical: dict[int, str] = {}
|
|
134
|
+
if self.link_names and entities:
|
|
135
|
+
entities, canonical = self._link_partial_names(text, entities, vault)
|
|
136
|
+
|
|
137
|
+
output: list[str] = []
|
|
138
|
+
replacements: list[Replacement] = []
|
|
139
|
+
position = 0
|
|
140
|
+
for entity in entities:
|
|
141
|
+
operator = self.operators.get(entity.label, self.operator)
|
|
142
|
+
replacement = self._replace(entity, operator, vault, canonical.get(entity.start))
|
|
143
|
+
output.append(text[position:entity.start])
|
|
144
|
+
output.append(replacement)
|
|
145
|
+
replacements.append(Replacement(entity, replacement, operator))
|
|
146
|
+
position = entity.end
|
|
147
|
+
output.append(text[position:])
|
|
148
|
+
return AnonymizationResult("".join(output), replacements, vault)
|
|
149
|
+
|
|
150
|
+
def replace_value(self, label: str, value: str, vault: Vault) -> str:
|
|
151
|
+
"""Substitui um valor inteiro já sabido ser de um tipo (ex.: toda a coluna "cpf" de uma planilha)."""
|
|
152
|
+
label = label.upper()
|
|
153
|
+
operator = self.operators.get(label, self.operator)
|
|
154
|
+
return self._replace(Entity(label, 0, len(value), 1.0, value, "forcado"), operator, vault, None)
|
|
155
|
+
|
|
156
|
+
def _replace(self, entity: Entity, operator: str, vault: Vault, canonical: str | None) -> str:
|
|
157
|
+
value = entity.text
|
|
158
|
+
if canonical is not None and operator in ("token", "hash", "sintetico"):
|
|
159
|
+
# A menção parcial usa o marcador do nome completo, mesmo que ele apareça depois no texto.
|
|
160
|
+
full = Entity(entity.label, entity.start, entity.end, entity.score, canonical, entity.recognizer)
|
|
161
|
+
replacement = self._replace(full, operator, vault, None)
|
|
162
|
+
vault.alias(entity.label, value, replacement)
|
|
163
|
+
return replacement
|
|
164
|
+
if operator == "token":
|
|
165
|
+
return vault.placeholder(entity.label, value)
|
|
166
|
+
if operator == "hash":
|
|
167
|
+
return vault.placeholder(entity.label, value, hashed=True)
|
|
168
|
+
if operator == "sintetico":
|
|
169
|
+
return vault.surrogate(entity.label, value, lambda rng: make_surrogate(entity.label, value, rng))
|
|
170
|
+
if operator == "mascara":
|
|
171
|
+
return mask_value(entity.label, value)
|
|
172
|
+
return f"[{entity.label}]"
|
|
173
|
+
|
|
174
|
+
def _link_partial_names(self, text: str, entities: list[Entity], vault: Vault):
|
|
175
|
+
"""Acha menções soltas ao primeiro nome ou ao sobrenome de uma pessoa já detectada."""
|
|
176
|
+
parts: dict[str, str] = {}
|
|
177
|
+
for entity in entities:
|
|
178
|
+
if entity.label != "PESSOA":
|
|
179
|
+
continue
|
|
180
|
+
words = [word for word in entity.text.split() if fold(word) not in CONNECTORS]
|
|
181
|
+
if len(words) < 2:
|
|
182
|
+
continue
|
|
183
|
+
for word in (words[0], words[-1]):
|
|
184
|
+
if len(word) >= 3 and fold(word) not in self._ambiguous and word[0].isupper():
|
|
185
|
+
parts.setdefault(word, entity.text)
|
|
186
|
+
if not parts:
|
|
187
|
+
return entities, {}
|
|
188
|
+
alternatives = "|".join(re.escape(word) for word in sorted(parts, key=len, reverse=True))
|
|
189
|
+
pattern = re.compile(r"(?<![\w])(?:" + alternatives + r")(?![\w])")
|
|
190
|
+
added: list[Entity] = []
|
|
191
|
+
canonical: dict[int, str] = {}
|
|
192
|
+
# As entidades vêm ordenadas e sem sobreposição: busca binária mantém o custo linear em textos grandes.
|
|
193
|
+
starts = [e.start for e in entities]
|
|
194
|
+
ends = [e.end for e in entities]
|
|
195
|
+
for match in pattern.finditer(text):
|
|
196
|
+
position = bisect.bisect_right(starts, match.start())
|
|
197
|
+
if position > 0 and ends[position - 1] > match.start():
|
|
198
|
+
continue
|
|
199
|
+
if position < len(starts) and starts[position] < match.end():
|
|
200
|
+
continue
|
|
201
|
+
entity = Entity("PESSOA", match.start(), match.end(), 0.7, match.group(0), "vinculo")
|
|
202
|
+
added.append(entity)
|
|
203
|
+
canonical[match.start()] = parts[match.group(0)]
|
|
204
|
+
by_folded = {fold(word): full for word, full in parts.items()}
|
|
205
|
+
for entity in entities:
|
|
206
|
+
if entity.label == "PESSOA" and len(entity.text.split()) == 1 and fold(entity.text) in by_folded:
|
|
207
|
+
canonical[entity.start] = by_folded[fold(entity.text)]
|
|
208
|
+
merged = sorted(entities + added, key=lambda e: e.start)
|
|
209
|
+
return merged, canonical
|
veu/bench/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Benchmark do Véu: gerador de dados sintéticos anotados e avaliação."""
|
veu/bench/evaluate.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Avaliação do Véu: precisão, revocação e taxa de vazamento por tipo de dado."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from veu.bench.generator import Sample
|
|
10
|
+
from veu.core import Veu
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class LabelReport:
|
|
15
|
+
true_positives: int = 0
|
|
16
|
+
false_positives: int = 0
|
|
17
|
+
false_negatives: int = 0
|
|
18
|
+
protected: int = 0
|
|
19
|
+
total: int = 0
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def precision(self) -> float:
|
|
23
|
+
found = self.true_positives + self.false_positives
|
|
24
|
+
return self.true_positives / found if found else 1.0
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def recall(self) -> float:
|
|
28
|
+
expected = self.true_positives + self.false_negatives
|
|
29
|
+
return self.true_positives / expected if expected else 1.0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def f1(self) -> float:
|
|
33
|
+
p, r = self.precision, self.recall
|
|
34
|
+
return 2 * p * r / (p + r) if p + r else 0.0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def leak_rate(self) -> float:
|
|
38
|
+
return 1 - self.protected / self.total if self.total else 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Report:
|
|
43
|
+
labels: dict[str, LabelReport] = field(default_factory=lambda: defaultdict(LabelReport))
|
|
44
|
+
samples: int = 0
|
|
45
|
+
characters: int = 0
|
|
46
|
+
seconds: float = 0.0
|
|
47
|
+
leaks: list[tuple[str, str, str]] = field(default_factory=list)
|
|
48
|
+
false_positives: list[tuple[str, str, str]] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def overall(self) -> LabelReport:
|
|
52
|
+
total = LabelReport()
|
|
53
|
+
for report in self.labels.values():
|
|
54
|
+
total.true_positives += report.true_positives
|
|
55
|
+
total.false_positives += report.false_positives
|
|
56
|
+
total.false_negatives += report.false_negatives
|
|
57
|
+
total.protected += report.protected
|
|
58
|
+
total.total += report.total
|
|
59
|
+
return total
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
def row(report: LabelReport) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"ocorrencias": report.total,
|
|
65
|
+
"precisao": round(report.precision, 4),
|
|
66
|
+
"revocacao": round(report.recall, 4),
|
|
67
|
+
"f1": round(report.f1, 4),
|
|
68
|
+
"vazamento": round(report.leak_rate, 4),
|
|
69
|
+
"falsos_positivos": report.false_positives,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"amostras": self.samples,
|
|
74
|
+
"caracteres_por_segundo": round(self.characters / self.seconds) if self.seconds else None,
|
|
75
|
+
"geral": row(self.overall),
|
|
76
|
+
"por_entidade": {label: row(report) for label, report in sorted(self.labels.items())},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def to_markdown(self) -> str:
|
|
80
|
+
lines = [
|
|
81
|
+
"| Entidade | Ocorrências | Precisão | Revocação | F1 | Vazamento |",
|
|
82
|
+
"|---|---:|---:|---:|---:|---:|",
|
|
83
|
+
]
|
|
84
|
+
for label, report in sorted(self.labels.items(), key=lambda item: -item[1].total):
|
|
85
|
+
if not report.total and not report.false_positives:
|
|
86
|
+
continue
|
|
87
|
+
lines.append(
|
|
88
|
+
f"| {label} | {report.total} | {report.precision:.1%} | {report.recall:.1%} | {report.f1:.1%} | "
|
|
89
|
+
f"{report.leak_rate:.1%} |"
|
|
90
|
+
)
|
|
91
|
+
overall = self.overall
|
|
92
|
+
lines.append(
|
|
93
|
+
f"| **Geral** | **{overall.total}** | **{overall.precision:.1%}** | **{overall.recall:.1%}** | "
|
|
94
|
+
f"**{overall.f1:.1%}** | **{overall.leak_rate:.1%}** |"
|
|
95
|
+
)
|
|
96
|
+
return "\n".join(lines).replace(".", ",").replace("|---,", "|---.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _covered(start: int, end: int, text: str, spans: list[tuple[int, int]]) -> bool:
|
|
100
|
+
"""Todos os caracteres alfanuméricos do trecho ficaram escondidos?"""
|
|
101
|
+
return all(
|
|
102
|
+
not text[index].isalnum() or any(s <= index < e for s, e in spans) for index in range(start, end)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def evaluate(samples: list[Sample], veu: Veu | None = None) -> Report:
|
|
107
|
+
veu = veu or Veu()
|
|
108
|
+
report = Report()
|
|
109
|
+
started = time.perf_counter()
|
|
110
|
+
for sample in samples:
|
|
111
|
+
predictions = veu.anonymize(sample.text).entities
|
|
112
|
+
report.samples += 1
|
|
113
|
+
report.characters += len(sample.text)
|
|
114
|
+
spans = [(entity.start, entity.end) for entity in predictions]
|
|
115
|
+
matched_predictions: set[int] = set()
|
|
116
|
+
for start, end, label in sample.entities:
|
|
117
|
+
stats = report.labels[label]
|
|
118
|
+
stats.total += 1
|
|
119
|
+
hit = next(
|
|
120
|
+
(i for i, e in enumerate(predictions) if e.label == label and e.start < end and start < e.end), None
|
|
121
|
+
)
|
|
122
|
+
if hit is None:
|
|
123
|
+
stats.false_negatives += 1
|
|
124
|
+
else:
|
|
125
|
+
stats.true_positives += 1
|
|
126
|
+
matched_predictions.add(hit)
|
|
127
|
+
if _covered(start, end, sample.text, spans):
|
|
128
|
+
stats.protected += 1
|
|
129
|
+
else:
|
|
130
|
+
report.leaks.append((label, sample.text[start:end], sample.text))
|
|
131
|
+
for index, entity in enumerate(predictions):
|
|
132
|
+
if index in matched_predictions:
|
|
133
|
+
continue
|
|
134
|
+
same = any(entity.label == label and entity.start < end and start < entity.end
|
|
135
|
+
for start, end, label in sample.entities)
|
|
136
|
+
if not same:
|
|
137
|
+
report.labels[entity.label].false_positives += 1
|
|
138
|
+
report.false_positives.append((entity.label, entity.text, sample.text))
|
|
139
|
+
report.seconds = time.perf_counter() - started
|
|
140
|
+
return report
|