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.
Files changed (64) hide show
  1. veu/__init__.py +78 -0
  2. veu/__main__.py +3 -0
  3. veu/_text.py +31 -0
  4. veu/analyzer.py +121 -0
  5. veu/anonymizer.py +209 -0
  6. veu/bench/__init__.py +1 -0
  7. veu/bench/evaluate.py +140 -0
  8. veu/bench/generator.py +241 -0
  9. veu/bench/hard.py +50 -0
  10. veu/cli.py +429 -0
  11. veu/config.py +357 -0
  12. veu/core.py +136 -0
  13. veu/data/ambiguous.txt +27 -0
  14. veu/data/first_names.txt +561 -0
  15. veu/data/organizations.txt +89 -0
  16. veu/data/places.txt +197 -0
  17. veu/data/segredos.json +83 -0
  18. veu/data/stopwords.txt +226 -0
  19. veu/data/surnames.txt +210 -0
  20. veu/demo.py +85 -0
  21. veu/documents.py +292 -0
  22. veu/entities.py +64 -0
  23. veu/files.py +180 -0
  24. veu/gateway/__init__.py +21 -0
  25. veu/gateway/adapters.py +652 -0
  26. veu/gateway/pipeline.py +99 -0
  27. veu/gateway/sse.py +130 -0
  28. veu/hooks.py +117 -0
  29. veu/integrations/__init__.py +1 -0
  30. veu/integrations/_transport.py +160 -0
  31. veu/integrations/decorator.py +137 -0
  32. veu/integrations/httpx.py +23 -0
  33. veu/integrations/httpx2.py +20 -0
  34. veu/integrations/langchain.py +201 -0
  35. veu/integrations/litellm.py +204 -0
  36. veu/integrations/logging.py +44 -0
  37. veu/integrations/loguru.py +30 -0
  38. veu/integrations/mcp.py +246 -0
  39. veu/integrations/pandas.py +58 -0
  40. veu/integrations/sdk.py +49 -0
  41. veu/integrations/sentry.py +70 -0
  42. veu/integrations/structlog.py +52 -0
  43. veu/recognizers/__init__.py +23 -0
  44. veu/recognizers/addresses.py +65 -0
  45. veu/recognizers/base.py +71 -0
  46. veu/recognizers/optional.py +110 -0
  47. veu/recognizers/patterns.py +238 -0
  48. veu/recognizers/persons.py +224 -0
  49. veu/recognizers/secrets.py +185 -0
  50. veu/reporting.py +122 -0
  51. veu/server/__init__.py +5 -0
  52. veu/server/app.py +482 -0
  53. veu/server/state.py +316 -0
  54. veu/server/static/playground.html +218 -0
  55. veu/sessions.py +77 -0
  56. veu/sources.py +460 -0
  57. veu/surrogates.py +319 -0
  58. veu/validators.py +293 -0
  59. veu/vault.py +285 -0
  60. veu-0.4.0.dist-info/METADATA +413 -0
  61. veu-0.4.0.dist-info/RECORD +64 -0
  62. veu-0.4.0.dist-info/WHEEL +4 -0
  63. veu-0.4.0.dist-info/entry_points.txt +2 -0
  64. 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
@@ -0,0 +1,3 @@
1
+ from veu.cli import main
2
+
3
+ raise SystemExit(main())
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