From 8f4dee15fa47ad8e63b672b71676038b1f43516c Mon Sep 17 00:00:00 2001 From: Joberth Firmino Gambati Date: Sun, 4 Oct 2026 12:58:00 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Efici=C3=AAncia=20(300=20DPI,=20OCR=20paral?= =?UTF-8?q?elo=20com=20as=20duas=20passadas,=20pr=C3=A9-carga=20do=20Laya)?= =?UTF-8?q?,=20regi=C3=B5es=20a=20revisar=20na=20p=C3=A1gina,=20documento?= =?UTF-8?q?=20que=20se=20atualiza=20sozinho=20e=20corre=C3=A7=C3=A3o=20do?= =?UTF-8?q?=20log=20de=20acesso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Joberth Firmino Gambati --- .env.example | 2 +- AGENTS.md | 3 +- BACKLOG.md | 5 +- CHANGELOG.md | 17 +++ app_service.py | 1 + docs/MANUAL_DE_USO.md | 7 +- docs/benchmarks.md | 36 +++++- docs/configuration.md | 3 +- main.py | 159 +++++++++++++++++++------- templates/index.html | 79 ++++++++++++- tests/test_auth.py | 18 ++- tests/test_decisions.py | 27 +++++ tests/test_fail_closed_ocr_address.py | 2 +- tests/test_performance.py | 137 ++++++++++++++++++++++ tests/test_review_marks.py | 100 ++++++++++++++++ tests/test_session.py | 15 ++- tests/test_validators_transform.py | 11 +- tests/ui/test_ui_review_marks.py | 88 ++++++++++++++ utils/decisions/engine.py | 21 ++++ utils/ocr_engine.py | 36 +++++- utils/pii.py | 29 ++++- utils/session.py | 6 +- utils/transform_pdf_to_img.py | 12 +- utils/verifier.py | 147 +++++++++++++++++------- 24 files changed, 849 insertions(+), 112 deletions(-) create mode 100644 tests/test_performance.py create mode 100644 tests/test_review_marks.py create mode 100644 tests/ui/test_ui_review_marks.py diff --git a/.env.example b/.env.example index 7bb1727..34b58b7 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,7 @@ # --- PDF -> image conversion (phase 0) / Conversão PDF -> imagem --------------- # Rendering DPI for full-page OCR. Higher = better small text, slower and heavier. # DPI de renderização para o OCR da página inteira. Maior = melhor p/ texto pequeno, porém mais lento. -# BASE_DPI=1000 +# BASE_DPI=300 # --- OCR (Tesseract) ----------------------------------------------------------- # Path to the tesseract binary if it is not on PATH. / Caminho do executável se não estiver no PATH. diff --git a/AGENTS.md b/AGENTS.md index 9d1854a..79f6b8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,8 @@ Navegador ─► gatekeeper.py (:8000, opcional) ─proxy─► app_service.py ( - **README da interface.** O botão *Documentação* do editor mostra o `README.md` da raiz pelo renderizador local (`GET /readme`). Ele não interpreta HTML: as linhas HTML do topo aparecem como texto. Isso é proposital (ver `test_readme_renderer_is_local_and_escapes_html`); não troque por `innerHTML`. -- **`BASE_DPI` padrão é 1000.** Ótimo para texto miúdo, caro em RAM e tempo. Os testes usam valores menores. +- **`BASE_DPI` padrão é 300** (era 1000 até a 5.3.0). Medido: acima de 300 o Tesseract fragmenta os dígitos e a revocação de CPF cai (docs/benchmarks.md). Não suba o padrão sem rodar o benchmark. +- **OCR em paralelo.** As páginas passam pelo OCR em threads (`ocr_workers`). Detecte falha do Tesseract com `OCREngine.thread_failures()` (por thread), nunca com o contador global, senão a falha de uma página é atribuída a outra. As duas passadas (padrão + esparsa) são mantidas de propósito. - **Artefatos com dados pessoais.** `output/`, `WEB_INPUT/`, `documentos_finais/` e `tasks.json` estão no `.gitignore` e nunca devem ser versionados. - **Licenças AGPL.** PyMuPDF e Ultralytics são AGPL-3.0; novas dependências precisam de justificativa e diff --git a/BACKLOG.md b/BACKLOG.md index 2e55402..9108648 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -21,7 +21,7 @@ leia o [AGENTS.md](AGENTS.md): invariantes, arquitetura e como rodar os testes. |---|---|---| | 🔴 P0 | 0 | Proteger o serviço local contra outros sites no mesmo navegador e o token de acesso | | 🟠 P1 | 13 | Falhar fechado em mais situações (OCR, respostas da IA, tarjas manuais, rotação) | -| 🟡 P2 | 11 | Robustez dos processos, limites de recursos, Docker e editor | +| 🟡 P2 | 10 | Robustez dos processos, limites de recursos, Docker e editor | | 🔵 P3 | 5 | Limpeza de código, lint e marca | | 🧠 Roteiro | 9 | Detecção configurável (nomes, telefones, RG...), GLiNER e decisor Laya que aprende (B-71 a B-79) | | 🔭 Visão | 5 | Auditoria, políticas e novos módulos SENTRY | @@ -97,7 +97,6 @@ Arquitetura: `detectar (regras + GLiNER + LLM) → decidir (Laya) → revisão | B-65 | **Expor na interface a remoção das imagens originais** (`POST /purge/{id}`), hoje só pela API | `app_service.py:420-429` | | B-10 | Tarja nativa: remover ou sinalizar metadados, anexos, anotações, campos de formulário e camadas ocultas que sobrevivem à tarja | [docs/limitations.md](docs/limitations.md) (Saída) | | B-11 | Aviso na interface quando `OLLAMA_API_URL` não aponta para `localhost` | [docs/limitations.md](docs/limitations.md) (Plataforma) | -| B-12 | Desempenho em PDFs grandes (centenas de páginas): perfis de DPI, paralelismo do OCR fora do `AI_LOCK` | [docs/limitations.md](docs/limitations.md) (Desempenho) | | B-59 | Publicar a imagem Docker (GHCR) na Release, respeitando a ressalva de licenciamento | [docs/licensing.md](docs/licensing.md), `.github/workflows/release.yml` | ## 🔵 P3 · Refatoração e manutenção @@ -126,6 +125,8 @@ Arquitetura: `detectar (regras + GLiNER + LLM) → decidir (Laya) → revisão | ID | Item | Versão | Teste | |---|---|---|---| +| B-12 | Desempenho: `BASE_DPI` 300 (medido: melhor revocação e mais rápido), OCR das páginas em paralelo nas duas passadas e na verificação, pré-carga do Laya, PNG leve. Documento real de 10 páginas: ~14 min → ~3 min | não publicado | `tests/test_performance.py`, `docs/benchmarks.md` | +| B-67 | Regiões a revisar desenhadas na página (alerta com posição) e documento aberto que se atualiza ao terminar o processamento | não publicado | `tests/test_review_marks.py`, `tests/ui/test_ui_review_marks.py` | | B-41 | Token na URL só abre a sessão (redireciona sem o token); cookie de sessão aleatório (`HttpOnly`, `SameSite=Strict`, `Secure` com HTTPS) no lugar do token cru; `token=` mascarado nos registros (`utils/auth.py`) | 5.3.0 | `tests/test_auth.py`, `tests/test_service_endpoints.py` | | B-42 | Painel do gatekeeper exige `API_TOKEN` quando definido; `kill_port_owner` só encerra um `app_service.py` órfão e não usa shell | 5.3.0 | `tests/test_auth.py`, `tests/test_gatekeeper.py` | | B-52 | Saída do app herdada pelo gatekeeper (o PIPE nunca lido travava o app) | 5.3.0 | `gatekeeper.py` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2e03e..8393069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Todas as mudanças relevantes ficam registradas aqui. Formato baseado no ## [Não publicado] +### Alterado +- **Mais rápido e mais preciso:** um documento real digitalizado de 10 páginas caiu de **~14 min para ~3 min** (OCR de ~510 s para ~19 s). + - **`BASE_DPI` padrão: 1000 → 300.** Medido (`docs/benchmarks.md`): a 300 DPI a revocação de CPF foi de 100% contra ~60% a 1000 DPI (o Tesseract quebra os dígitos em pedaços em imagens grandes), e a verificação achou 16/16 vazamentos contra 11–13/16. No documento real, os CPFs achados a 1000 DPI foram todos achados a 300 DPI. **Atenção:** quem define `BASE_DPI` no `.env` deve revisar o valor (e `YOLO_CROP_PADDING`, que é em pixels). + - **OCR das páginas em paralelo** (nova variável `OCR_WORKERS`; padrão: metade dos núcleos, até 8), nas duas passadas e na verificação pós-tarja. A detecção de falha do Tesseract continua por página (contador por thread). **As duas passadas de OCR (padrão + esparsa) foram mantidas.** + - O decisor Laya carrega o modelo **em paralelo** com a análise de endereços do LLM. + - Imagens das páginas gravadas com compressão PNG leve. + +### Adicionado +- **Onde revisar:** cada alerta com posição conhecida (CPF ainda detectável, CPF sem tarja, dúvida do decisor sobre endereço) marca a **região na página** com um contorno tracejado laranja ("⚠ Revisar aqui"); clicar no alerta da barra leva até a região e a destaca. +- **Documento aberto se atualiza sozinho** quando o processamento termina (sem recarregar a página); com edições não salvas, só avisa, nunca descarta. +- **Texto digital do PDF como passada extra** de CPF, somada ao OCR, quando o PDF tem camada de texto. +- **Tempo por etapa** registrado na tarefa e mostrado no cartão (total; detalhe ao passar o mouse). + +### Corrigido +- O filtro que mascara `token=` nos registros quebrava o formatador de acesso do uvicorn (um *traceback* por requisição no console; as requisições funcionavam). Agora mascara dentro dos argumentos, preservando o formato. +- A tarja nativa sem a largura da imagem de origem supunha 1000 DPI fixo; agora usa `BASE_DPI`. + ## [5.3.0] - 2026-10-04 Decisor local que aprende (experimental, desligado por padrão) e uma rodada completa de segurança: os dois diff --git a/app_service.py b/app_service.py index fbaf587..f657cae 100644 --- a/app_service.py +++ b/app_service.py @@ -374,6 +374,7 @@ def reset_task_state(task, status): task["error"] = False task["needs_review"] = False task["alerts"] = [] + task["review_marks"] = [] task.pop("purged", None) # reprocessar regenera as imagens @app.post("/reprocess/{task_id}") diff --git a/docs/MANUAL_DE_USO.md b/docs/MANUAL_DE_USO.md index b69d7ef..f1726ff 100644 --- a/docs/MANUAL_DE_USO.md +++ b/docs/MANUAL_DE_USO.md @@ -102,7 +102,7 @@ em [threat-model-lgpd.md](threat-model-lgpd.md) e [brand/LGPD_PRODUCT_POSITIONIN | Python | **3.10 a 3.12** | | Tesseract OCR | 5.x com o idioma português (`por`) | | Ollama | Servidor local com um modelo multimodal (visão) já baixado | -| Hardware | RAM/VRAM compatível com o modelo escolhido; o `BASE_DPI` padrão (1000) é alto e consome bastante memória | +| Hardware | RAM/VRAM compatível com o modelo escolhido; o OCR usa vários núcleos da CPU em paralelo (`OCR_WORKERS`) | | Detector de assinaturas | `models/signature_stamp_detector.pt` (já incluído no repositório) | ### 2.2 Windows @@ -237,7 +237,7 @@ Todas as configurações são **variáveis de ambiente**, normalmente no arquivo | `TESSERACT_LANG` | `por` | Idioma(s) do OCR, por exemplo `por+eng` (o *traineddata* precisa estar instalado) | | `OLLAMA_API_URL` | `http://localhost:11434` | Endereço do servidor Ollama | | `OLLAMA_MODEL` / `OLLAMA_VISION_MODEL` | `llama3` / `gemma4:e4b` | Modelos de texto e de visão | -| `BASE_DPI` | `1000` | DPI de renderização das páginas (qualidade × memória × tempo) | +| `BASE_DPI` | `300` | DPI de renderização das páginas. 300 teve a melhor revocação e o menor tempo nas medições | | `VERIFY_OCR` | `1` | Liga a verificação pós-tarja por OCR (recomendado manter `1`) | | `VERIFY_DPI` | `300` | DPI do OCR de verificação | | `APP_HOST` / `APP_PORT` | `127.0.0.1` / `8001` | Endereço e porta da aplicação web | @@ -779,7 +779,8 @@ Sem o token correto, as rotas respondem `401` com "Não autorizado". **Sinais:** status `Erro: Erro na Fase 0: ...`; máquina travando; PDFs longos levando horas. -- Reduza `BASE_DPI` (padrão 1000) para algo entre `300` e `600` ([seção 3.2](#32-perfis-sugeridos-de-desempenho)). +- Confira se `BASE_DPI` está em `300` (o padrão). Valores altos deixam tudo mais lento e, nas medições, pioraram a detecção ([seção 3.2](#32-perfis-sugeridos-de-desempenho)). +- Ajuste `OCR_WORKERS` ao número de núcleos livres. - Mantenha `TESSERACT_SPARSE_PSM` vazio (padrão), pois a segunda passada de OCR custa tempo. - `VERIFY_DPI` alto deixa a verificação mais lenta. - Evite processar muitos PDFs grandes ao mesmo tempo: cada tarefa roda num processo próprio. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index fb271fe..8272f58 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -76,7 +76,7 @@ A verificação novamente encontrou **20/20 vazamentos com 0/10 páginas limpas * Neste corpus sintético, **300 DPI foi a melhor configuração de detecção**; 200 DPI foi quase tão bom e ~40 % mais rápido. A revocação **caiu a 400 e especialmente a 600 DPI**: o Tesseract fragmenta os dígitos em tokens pequenos em tamanhos - de renderização grandes, o que torna mais difícil juntar as sequências de dígitos em um único CPF. O padrão do projeto `BASE_DPI=1000` **não + de renderização grandes, o que torna mais difícil juntar as sequências de dígitos em um único CPF. O padrão antigo do projeto, `BASE_DPI=1000`, **não foi medido** (uma única página A4 leva minutos); com base nessas evidências, ele está fora da faixa em que as digitalizações sintéticas se comportam melhor. Isso pode ser diferente para digitalizações reais, então meça com seus próprios dados (permitidos) antes de alterar o padrão. * `psm` 3, 6 e 11 deram a mesma revocação em texto sintético limpo; diferenças só apareceriam em layouts mais bagunçados. @@ -88,3 +88,37 @@ A verificação novamente encontrou **20/20 vazamentos com 0/10 páginas limpas qualidade da correspondência de endereços (veja `tests/test_address_matching.py` para a caracterização de tarja excessiva/insuficiente), PDFs com camada de texto nativa (esses são verificados por extração exata de texto, não por OCR). * Os tamanhos de amostra são pequenos (30 CPFs): um único erro move a revocação em 3,3 pontos. Use os arquivos JSON para o detalhe por página. + +## Rodada de 2026-10-04: DPI de renderização (300 × 600 × 1000) + +Motivação: o padrão `BASE_DPI=1000` deixava um documento real de 10 páginas em ~14 minutos (61% do tempo no OCR). + +### Sintético (`--pages 8 --dpis 300 600 1000 --psms 3 11 --workers 6 --seed 7 --noise 12`, 24 CPFs) + +| DPI | psm | revocação de CPF | precisão | cobertura de caixas | s/página | +|---:|---:|---:|---:|---:|---:| +| 300 | 3 | 100,0% | 100,0% | 100,0% | 7,1 | +| 300 | 11 | 100,0% | 100,0% | 100,0% | 8,0 | +| 600 | 3 | 62,5% | 88,2% | 33,3% | 22,5 | +| 600 | 11 | 58,3% | 93,3% | 25,0% | 22,0 | +| 1000 | 3 | 58,3% | 93,3% | 29,2% | 21,0 | +| 1000 | 11 | 62,5% | 100,0% | 33,3% | 19,5 | + +Verificação (PDF final 1240 px): vazamentos encontrados 16/16 a 300 DPI, 14/16 a 600, 11 a 13/16 a 1000; nenhuma página +limpa sinalizada em nenhuma configuração. + +### Documento real (10 páginas digitalizadas, só contagens; OCR duplo sequencial) + +| DPI | renderização | OCR duplo | CPFs distintos | +|---:|---:|---:|---:| +| 300 | 16 s | 101 s | 6 | +| 400 | 28 s | 154 s | 6 | +| 600 | 45 s | 245 s | 5 | +| 1000 | 82 s | ~510 s | 5 | + +A 300 DPI, os 5 CPFs achados a 1000 DPI foram todos encontrados. O sexto veio da passada esparsa, montado com 4 pedaços +de duas linhas, sem CPF inteiro numa palavra: provável falso positivo (vira tarja sugerida a mais, que o revisor remove). + +**Decisão:** padrão `BASE_DPI=300`. Somado ao OCR das páginas em paralelo (`OCR_WORKERS`), o OCR deve ficar perto de +10× mais rápido. As duas passadas de OCR (padrão + esparsa) foram mantidas de propósito: cada leitura a mais pode achar +um CPF que a outra perdeu. diff --git a/docs/configuration.md b/docs/configuration.md index 95478ef..f5130c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,7 +23,8 @@ Todas as configurações são variáveis de ambiente, normalmente definidas em u | Variável | Padrão | Descrição | |---|---|---| -| `BASE_DPI` | `1000` | DPI usado para renderizar cada página para o OCR. Valores altos ajudam com texto pequeno, mas exigem muita RAM e tempo; 300-600 é um bom ponto de partida em máquinas modestas. | +| `BASE_DPI` | `300` | DPI usado para renderizar cada página para o OCR. **300 é o recomendado**: nas medições (benchmarks.md) teve a melhor revocação e foi o mais rápido; acima disso o Tesseract quebra os dígitos em pedaços e perde CPFs (a 1000 DPI a revocação caiu para ~60% e cada página vira ~97 Mpx). Suba só para documentos com letra muito pequena, e meça antes. | +| `OCR_WORKERS` | metade dos núcleos (até 8) | Quantas páginas passam pelo OCR ao mesmo tempo, nas duas passadas e na verificação. Com `BASE_DPI` acima de 600 o padrão cai para 2 (memória). `1` = uma página por vez. | | `TESSERACT_PATH` | *(PATH do sistema)* | Caminho completo do executável `tesseract` caso ele não esteja no `PATH` (comum no Windows). | | `TESSERACT_LANG` | `por` | Idioma(s) do Tesseract, por exemplo `por+eng`. O traineddata precisa estar instalado. | | `TESSDATA_PREFIX` | *(padrão do Tesseract)* | Pasta com os `*.traineddata`. Lida pelo próprio Tesseract, não pelo código. No Debian/Ubuntu instale `tesseract-ocr-por`; a cópia de trabalho original mantinha uma pasta `tessdata/` de 16 MB, que não faz parte do repositório público. | diff --git a/main.py b/main.py index 30e07df..78fd8a0 100644 --- a/main.py +++ b/main.py @@ -5,11 +5,12 @@ import sys import time from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed from dotenv import load_dotenv from utils.transform_pdf_to_img import transform_pdf_to_img from utils.session import Session -from utils.ocr_engine import OCREngine +from utils.ocr_engine import OCREngine, ocr_workers from utils.yolo_engine import YOLOEngine from utils.address_redactor import AddressRedactor from utils.lexicon import is_immune @@ -41,7 +42,8 @@ def __init__(self, pdf_path, output_dir=None, final_dir=None): self.address_redactor = AddressRedactor(self.session, self.logger) # 3. Armazenamento de Estado - self.base_dpi = int(os.getenv("BASE_DPI", "1000")) + # 300 DPI: melhor revocação E mais rápido nas medições (docs/benchmarks.md, 2026-10-04) + self.base_dpi = int(os.getenv("BASE_DPI", "300")) self.image_paths = [] self.grounding_maps = [] self.grounding_maps_sparse = [] @@ -55,11 +57,21 @@ def __init__(self, pdf_path, output_dir=None, final_dir=None): self.alerts = [] # FALHAR FECHADO: páginas/itens que exigem revisão humana (page_num -> [motivos]) self.review_pages = defaultdict(list) + self.review_marks = [] # regiões a revisar: {"page", "reason", "box": [x0, y0, x1, y1] relativos} + self.timings = {} # segundos por etapa (para acompanhar o desempenho) + self.grounding_maps_native = [] # palavras do texto digital do PDF (passada extra de CPF), por página - def add_review(self, page_num, reason): - """Marca uma página como 'requer revisão' e registra o alerta (sem duplicar).""" + def add_review(self, page_num, reason, boxes=None): + """Marca uma página como 'requer revisão' e registra o alerta (sem duplicar). + + boxes: regiões [x0, y0, x1, y1] relativas (0..1) na página, para o editor mostrar ONDE revisar. + """ if reason not in self.review_pages[page_num]: self.review_pages[page_num].append(reason) + for box in boxes or []: + mark = {"page": page_num, "reason": mask_text(reason), "box": [round(float(v), 4) for v in box]} + if mark not in self.review_marks: + self.review_marks.append(mark) msg = mask_text(f"Pág {page_num}: {reason}") if msg not in self.alerts: self.alerts.append(msg) @@ -73,6 +85,9 @@ def add_document_review(self, reason): self.alerts.append(msg) def _ocr_failures(self): + """Falhas do Tesseract da thread atual (o OCR das páginas roda em paralelo).""" + if hasattr(self.ocr, "thread_failures"): + return self.ocr.thread_failures() return getattr(self.ocr, "failure_count", 0) @property @@ -88,8 +103,11 @@ def final_state(self): "completed": True, "needs_review": True, "alerts": self.alerts, + "review_marks": self.review_marks, + "timings": self.timings, } - return {"status": "Concluído", "percentage": 100, "completed": True, "needs_review": False, "alerts": self.alerts} + return {"status": "Concluído", "percentage": 100, "completed": True, "needs_review": False, + "alerts": self.alerts, "review_marks": self.review_marks, "timings": self.timings} def run(self, progress_callback=None, manual_redactions=None, native_mode=False): """Executa o pipeline completo com suporte a notificações de progresso.""" @@ -127,7 +145,10 @@ def run(self, progress_callback=None, manual_redactions=None, native_mode=False) for func, status, pct in phases: self.update_progress(status, pct) + started = time.time() func() + self.timings[status] = round(time.time() - started, 1) # chave = nome legível da etapa + self.logger.info(f"[TEMPO] {status}: {self.timings[status]} s") final = self.final_state() self.update_progress(final["status"], 100) @@ -153,6 +174,7 @@ def update_progress(self, status, percentage): "status": status, "percentage": percentage, "alerts": self.alerts, + "review_marks": self.review_marks, "total": len(self.image_paths) if hasattr(self, 'image_paths') else 0, "doc_out_dir": self.session.output_dir if self.session else None }) @@ -168,43 +190,67 @@ def run_phase_0(self): self.logger.error(f"[FATAL] Erro na Fase 0: {e}") raise Exception(f"Erro na Fase 0: {e}") + def _ocr_page(self, img_path): + """As DUAS passadas de OCR de uma página (padrão + esparsa). Roda em paralelo com outras páginas.""" + failures_before = self._ocr_failures() + indexed_text, coordinate_map = self.ocr.get_grounding_map(img_path, psm=os.getenv("TESSERACT_STD_PSM", "3")) + sparse_map = [] + sparse_psm = os.getenv("TESSERACT_SPARSE_PSM", "") + if sparse_psm: + _, sparse_map = self.ocr.get_grounding_map(img_path, psm=sparse_psm) + return indexed_text, coordinate_map, sparse_map, self._ocr_failures() > failures_before + def run_phase_1(self): - """Fase 1: OCR da página e exportação de resultados (Grounding Indexado).""" + """Fase 1: OCR (duas passadas por página, páginas em paralelo) + texto digital do PDF, se houver.""" self.logger.info("--- PHASE 1: OCR SCANNING (01_OCR_RESULTS) ---") - for i, img_path in enumerate(self.image_paths): - page_num = i + 1 - self.logger.info(f"[*] OCR scanning page {page_num}/{len(self.image_paths)}...") - - # Cooperação: Cede tempo para os outros cores e para o loop do FastAPI - time.sleep(0.01) - - failures_before = self._ocr_failures() - # Extrai o mapa e o texto indexado - indexed_text, coordinate_map = self.ocr.get_grounding_map(img_path, psm=os.getenv("TESSERACT_STD_PSM", "3")) - - # Extrai scanner secundário para blocos dispersos (PSM 11) - sparse_psm = os.getenv("TESSERACT_SPARSE_PSM", "") - if sparse_psm: - self.logger.info(f" -> Realizando varredura suplementar de dígitos (TESSERACT_SPARSE_PSM = {sparse_psm})...") - _, sparse_map = self.ocr.get_grounding_map(img_path, psm=sparse_psm) - self.grounding_maps_sparse.append(sparse_map) - else: - self.grounding_maps_sparse.append([]) - - if self._ocr_failures() > failures_before: + total = len(self.image_paths) + workers = ocr_workers(self.base_dpi, total) + sparse_psm = os.getenv("TESSERACT_SPARSE_PSM", "") + self.logger.info(f"[*] OCR de {total} página(s) com {workers} em paralelo" + f"{' + varredura suplementar (PSM ' + sparse_psm + ')' if sparse_psm else ''}...") + results = [None] * total + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = {ex.submit(self._ocr_page, path): i for i, path in enumerate(self.image_paths)} + for done, future in enumerate(as_completed(futures), start=1): + results[futures[future]] = future.result() + if done % max(1, total // 10) == 0 or total < 10: + self.update_progress(f"OCR: Pagina {done}/{total}", int(10 + (done / total * 30))) + + for i, (indexed_text, coordinate_map, sparse_map, failed) in enumerate(results): + if failed: # Resultado vazio por erro do Tesseract não pode virar "página sem CPF" (falha fechado) - self.add_review(page_num, "OCR (Tesseract) falhou nesta página: CPFs podem não ter sido detectados. Revisar manualmente.") - - # Armazena para fases posteriores + self.add_review(i + 1, "OCR (Tesseract) falhou nesta página: CPFs podem não ter sido detectados. Revisar manualmente.") self.grounding_maps.append(coordinate_map) self.indexed_texts.append(indexed_text) - - # Reporta progresso a cada 10% ou a cada página se for PDF pequeno - if page_num % max(1, len(self.image_paths) // 10) == 0 or len(self.image_paths) < 10: - self.update_progress(f"OCR: Pagina {page_num}/{len(self.image_paths)}", int(10 + (page_num/len(self.image_paths)*30))) + self.grounding_maps_sparse.append(sparse_map) + self.grounding_maps_native = self._native_text_maps(total) self.logger.info("[+] Fase 1 concluída: Grounding e Visualizações exportados.") + def _native_text_maps(self, total): + """ + Passada EXTRA: palavras do texto digital do PDF (quando existe), já na escala de pixels das imagens. + Não substitui o OCR; soma a ele (texto digital não tem erro de leitura). + """ + from utils.verifier import _words_to_grounding + maps = [[] for _ in range(total)] + try: + import fitz + scale = self.base_dpi / 72.0 + with fitz.open(self.session.pdf_path) as doc: + for i in range(min(total, len(doc))): + grounding = _words_to_grounding(doc[i].get_text("words")) + for w in grounding: + b = w["box"] + w["box"] = {"x": b["x"] * scale, "y": b["y"] * scale, "w": b["w"] * scale, "h": b["h"] * scale} + maps[i] = grounding + except Exception as e: + self.logger.warning(f"[!] Texto digital do PDF não lido (segue só com o OCR): {e}") + n = sum(1 for m in maps if m) + if n: + self.logger.info(f"[*] Texto digital encontrado em {n} página(s): usado como passada extra de CPF.") + return maps + def run_phase_2(self): """Fase 2: Descoberta de CPFs (OCR Standard + OCR Esparso).""" self.logger.info("--- PHASE 2: CPF DISCOVERY (DUAL-PASS OCR) ---") @@ -224,12 +270,16 @@ def run_phase_2(self): self.cpf_redactions[page_num].extend(boxes) all_found_cpfs.update(page_cpfs) - # Passada 2 (Sparse PSM 11) + # Passada 2 (Sparse PSM 11) e passada 3 (texto digital do PDF, quando existe) + extra_maps = [] if i < len(self.grounding_maps_sparse) and self.grounding_maps_sparse[i]: - sparse_map = self.grounding_maps_sparse[i] - cmd_map_s, cpfs_s = self.ocr.find_cpfs_in_grounding(sparse_map) + extra_maps.append(self.grounding_maps_sparse[i]) + if i < len(self.grounding_maps_native) and self.grounding_maps_native[i]: + extra_maps.append(self.grounding_maps_native[i]) + for extra_map in extra_maps: + cmd_map_s, cpfs_s = self.ocr.find_cpfs_in_grounding(extra_map) if cmd_map_s: - boxes_s = self.session.get_redaction_boxes(sparse_map, cmd_map_s) + boxes_s = self.session.get_redaction_boxes(extra_map, cmd_map_s) self.global_redactions[page_num].extend(boxes_s) self.cpf_redactions[page_num].extend(boxes_s) all_found_cpfs.update(cpfs_s) @@ -286,6 +336,9 @@ def run_phase_4(self): def run_address_discovery(self): """Fase de Endereços: Descoberta via AI Semântica (Visão).""" self.logger.info("--- INICIANDO ELEMENTO: ADDRESS REDACTOR (VISÃO) ---") + service = decisions.get_engine() + if service is not None: + service.preload() # o decisor carrega o modelo enquanto o LLM analisa as páginas self.address_redactor.run_discovery(self.image_paths) for page_num, reason in self.address_redactor.failed_pages.items(): self.add_review(page_num, f"IA não analisou endereços ({reason[:120]}). Revisar manualmente.") @@ -313,17 +366,35 @@ def apply_address_decisions(self): if is_personal and not llm_personal: addr["type"] = "pessoal" addr["decided_by"] = verdict.engine + word_boxes = self._address_word_boxes(page, addr["text"]) if reason: - self.add_review(page, f"Endereços: {reason}. Revisar manualmente.") + self.add_review(page, f"Endereços: {reason}. Revisar manualmente.", + self._relative_boxes(page, word_boxes)) log.append({"page": page, "text": normalize_state(addr["text"]), "llm_type": addr.get("type_original"), "final_type": addr["type"], "p_personal": verdict.p_personal, "mode": service.mode, - "profile": verdict.profile_version, "word_boxes": self._address_word_boxes(page, addr["text"])}) + "profile": verdict.profile_version, "word_boxes": word_boxes}) try: decision_feedback.write_decision_log(self.session.output_dir, log) except OSError as e: self.logger.warning(f"[decisions] não foi possível gravar decisions.json: {e}") self.logger.info(f"[decisions] {len(log)} endereço(s) avaliados pelo decisor local (modo {service.mode}).") + def _relative_boxes(self, page_num, boxes): + """[x, y, w, h] em pixels da imagem da página -> uma região [x0, y0, x1, y1] relativa que envolve todas.""" + if not boxes or page_num > len(self.image_paths): + return [] + try: + from PIL import Image + with Image.open(self.image_paths[page_num - 1]) as im: + width, height = im.size + except Exception: + return [] + x0 = min(b[0] for b in boxes) + y0 = min(b[1] for b in boxes) + x1 = max(b[0] + b[2] for b in boxes) + y1 = max(b[1] + b[3] for b in boxes) + return [[x0 / width, y0 / height, x1 / width, y1 / height]] + def _address_word_boxes(self, page_num, text): """Caixas [x, y, w, h] das palavras do endereço na página (para aprender com a revisão).""" if page_num > len(self.grounding_maps): @@ -649,6 +720,7 @@ def run_verification(self): raise RuntimeError(f"PDF final não encontrado para verificação: {final_pdf}") use_ocr = os.getenv("VERIFY_OCR", "1") == "1" + found_at = {} leftovers, unverified = verify_pdf( final_pdf, ocr_engine=self.ocr, @@ -658,10 +730,11 @@ def run_verification(self): logger=self.logger, progress=lambda done, total: self.update_progress( f"Verificação pós-tarja: {done}/{total}", 98), + locations=found_at, ) for page_num, cpfs in leftovers.items(): self.logger.error(f"[!!!] CPF AINDA VISÍVEL na pág {page_num} do PDF final ({len(cpfs)} ocorrência(s)).") - self.add_review(page_num, f"{len(cpfs)} CPF(s) ainda detectável(is) no PDF final") + self.add_review(page_num, f"{len(cpfs)} CPF(s) ainda detectável(is) no PDF final", found_at.get(page_num)) for page_num in unverified: self.add_review(page_num, "página sem texto e não verificada por OCR") @@ -669,6 +742,7 @@ def run_verification(self): # Cobertura: OCR independente do ORIGINAL (outra resolução) vs. tarjas decididas. # Pega CPFs que a detecção principal leu errado e que o PDF final (baixa resolução) não revela. from utils.verifier import find_uncovered_cpfs + uncovered_at = {} uncovered, failed = find_uncovered_cpfs( self.session.pdf_path, self.global_redactions, self.ocr, base_dpi=self.base_dpi, @@ -677,10 +751,11 @@ def run_verification(self): logger=self.logger, progress=lambda done, total: self.update_progress( f"Verificação de cobertura: {done}/{total}", 98), + locations=uncovered_at, ) for page_num, cpfs in uncovered.items(): self.logger.error(f"[!!!] CPF no ORIGINAL sem tarja correspondente na pág {page_num} ({len(cpfs)} distinto(s)).") - self.add_review(page_num, "CPF detectado no original sem tarja correspondente") + self.add_review(page_num, "CPF detectado no original sem tarja correspondente", uncovered_at.get(page_num)) for page_num in failed: self.add_review(page_num, "verificação de cobertura por OCR falhou") self.logger.info(f"[+] Verificação concluída: {len(leftovers)} página(s) reprovada(s), {len(unverified)} não verificada(s).") diff --git a/templates/index.html b/templates/index.html index 83e0402..ba9012e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -136,7 +136,14 @@ .page-wrapper { position:relative; box-shadow:var(--shadow-md); background:var(--white); min-height: 500px; width: fit-content; border:1px solid var(--border); } .page-img { display:block; max-width:100%; height:auto; user-select:none; -webkit-user-drag:none; background: var(--white); } + .task-time { font-size:12px; color:var(--slate); margin-top:4px; cursor:help; } .redaction-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; } + /* Região que a verificação pediu para revisar (onde está o problema, não uma tarja) */ + .review-mark { position:absolute; border:3px dashed var(--warning-strong); background:rgba(247,144,9,0.12); border-radius:4px; pointer-events:auto; z-index:3; cursor:help; } + .review-mark .review-mark-label { position:absolute; top:-22px; left:-3px; white-space:nowrap; font-size:12px; font-weight:700; line-height:18px; padding:0 6px; border-radius:4px; background:var(--warning-strong); color:var(--white); } + .review-mark.flash { animation: review-flash 0.9s ease-in-out 3; } + @keyframes review-flash { 0%, 100% { box-shadow: 0 0 0 0 rgba(247,144,9,0); } 50% { box-shadow: 0 0 0 10px rgba(247,144,9,0.45); } } + @media (prefers-reduced-motion: reduce) { .review-mark.flash { animation: none; outline: 4px solid var(--warning-strong); } } .redaction-box { position:absolute; background:rgba(11,18,32,0.45); border:2px solid var(--primary); pointer-events:auto; z-index:2; } .redaction-box.type-signature { border-color: var(--teal); background: rgba(20, 184, 166, 0.40); } .redaction-box.type-signature::before { content: attr(data-label); position: absolute; top: -17px; left: 0; font-size: 10px; font-weight:700; background: var(--teal-strong); color: var(--white); padding: 1px 5px; border-radius: 3px; white-space:nowrap; } @@ -385,6 +392,8 @@

Log de auditoria da tarefa

'menu.pii': 'Dados pessoais (PII)', 'menu.sig': 'Assinatura (manual)', 'menu.cancel': 'Cancelar', 'zoom.in': 'Aumentar zoom', 'zoom.out': 'Diminuir zoom', 'nav.prev': 'Página anterior', 'nav.next': 'Próxima página', 'pending.region': 'Páginas pendentes de revisão', 'pending.title': 'Requer revisão', 'pending.only': 'Só páginas pendentes', 'pending.page': 'Pág', + 'review.mark': 'Revisar aqui', 'ok.finished': 'Processamento concluído: resultado carregado.', + 'warn.finished': 'O processamento terminou. Salve suas edições e reabra o documento para ver o resultado.', 'empty.title': 'Nenhum documento ainda', 'empty.sub': 'Adicione um PDF para iniciar a detecção.', 'step.detect.title': 'Detectar', 'step.detect.body': 'OCR, regras de validação e modelos locais sugerem possíveis CPFs, endereços e assinaturas.', 'step.review.title': 'Revisar', 'step.review.body': 'Cada sugestão aparece como uma região editável. Ajuste, mova ou remova antes de continuar.', @@ -430,6 +439,8 @@

Log de auditoria da tarefa

'menu.pii': 'Personal data (PII)', 'menu.sig': 'Signature (manual)', 'menu.cancel': 'Cancel', 'zoom.in': 'Zoom in', 'zoom.out': 'Zoom out', 'nav.prev': 'Previous page', 'nav.next': 'Next page', 'pending.region': 'Pages pending review', 'pending.title': 'Needs review', 'pending.only': 'Pending pages only', 'pending.page': 'Page', + 'review.mark': 'Review here', 'ok.finished': 'Processing finished: result loaded.', + 'warn.finished': 'Processing finished. Save your edits and reopen the document to see the result.', 'empty.title': 'No documents yet', 'empty.sub': 'Add a PDF to begin detection.', 'step.detect.title': 'Detect', 'step.detect.body': 'OCR, validation rules and local models suggest potential CPF numbers, addresses and signatures.', 'step.review.title': 'Review', 'step.review.body': 'Each suggestion appears as an editable region. Adjust, move or remove it before continuing.', @@ -584,7 +595,37 @@

Log de auditoria da tarefa

const el = document.getElementById(`page-wrapper-${n}`); if (!el) return; if (el.style.display === 'none') { pendingOnly = false; applyPendingState(); updatePendingBar(true); } - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const marks = el.querySelectorAll('.review-mark'); + // Com região conhecida, centraliza nela e pisca; senão, vai para o topo da página + (marks[0] || el).scrollIntoView({ behavior: 'smooth', block: marks.length ? 'center' : 'start' }); + marks.forEach(m => { m.classList.remove('flash'); void m.offsetWidth; m.classList.add('flash'); }); + } + + // Regiões a revisar (task.review_marks): caixas relativas 0..1 desenhadas sobre a página + function renderReviewMarks() { + const task = currentTaskId ? tasks[currentTaskId] : null; + const marks = (task && Array.isArray(task.review_marks)) ? task.review_marks : []; + marks.forEach(m => { + const layer = document.getElementById(`layer-${parseInt(m.page, 10)}`); + const b = Array.isArray(m.box) ? m.box.map(Number) : []; + if (!layer || b.length !== 4 || b.some(v => !isFinite(v))) return; + const pad = 0.006; // folga para o contorno não cobrir o texto + const el = document.createElement('div'); + el.className = 'review-mark'; + el.setAttribute('data-testid', 'review-mark'); + el.setAttribute('role', 'note'); + el.title = String(m.reason || ''); + el.setAttribute('aria-label', `${t('review.mark')}: ${String(m.reason || '')}`); + el.style.left = (Math.max(0, b[0] - pad) * 100) + '%'; + el.style.top = (Math.max(0, b[1] - pad) * 100) + '%'; + el.style.width = (Math.min(1, b[2] + pad) - Math.max(0, b[0] - pad)) * 100 + '%'; + el.style.height = (Math.min(1, b[3] + pad) - Math.max(0, b[1] - pad)) * 100 + '%'; + const label = document.createElement('span'); + label.className = 'review-mark-label'; + label.textContent = `⚠ ${t('review.mark')}`; + el.appendChild(label); + layer.appendChild(el); + }); } function togglePendingOnly(v) { pendingOnly = !!v; applyPendingState(); } @@ -668,6 +709,17 @@

Log de auditoria da tarefa

} const dateStr = tk.created_at ? new Date(tk.created_at).toLocaleDateString(lang, {hour: '2-digit', minute:'2-digit'}) : ''; + // Tempo por etapa (desempenho): total no cartão, detalhe ao passar o mouse + let timeHtml = ''; + if (isDone && tk.timings && typeof tk.timings === 'object') { + const entries = Object.entries(tk.timings).filter(([, v]) => isFinite(Number(v))); + const total = entries.reduce((acc, [, v]) => acc + Number(v), 0); + if (entries.length) { + const detail = entries.map(([k, v]) => `${k}: ${Number(v).toFixed(1)} s`).join('\n'); + const label = total >= 60 ? `${Math.floor(total / 60)} min ${Math.round(total % 60)} s` : `${total.toFixed(0)} s`; + timeHtml = `
⏱ ${esc(label)}
`; + } + } let alertsHtml = ''; if (tk.alerts && tk.alerts.length > 0) { @@ -691,6 +743,7 @@

Log de auditoria da tarefa

${esc(tk.status)} ${!isDone ? '('+esc(tk.percentage)+'%)' : ''}${dateStr}
${badgeHtml} ${completeHtml} + ${timeHtml} ${downloadHtml} `; // Só re-renderiza quando algo mudou (evita fechar o menu aberto a cada polling) @@ -702,9 +755,29 @@

Log de auditoria da tarefa

}); tasks = data; updatePendingBar(); + await refreshOpenDocumentIfFinished(); } catch(e) { console.error('Erro ao atualizar tarefas:', e); } } + // Documento aberto enquanto processava: ao terminar, carrega o resultado sem precisar recarregar a página. + // Com edições não salvas, só avisa (nunca descarta trabalho do revisor). + let openTaskWasDone = null; + let loadedRedactionsSig = '[]'; + const taskDone = (tk) => !!(tk && (tk.completed || tk.error)); + async function refreshOpenDocumentIfFinished() { + if (!currentTaskId) { openTaskWasDone = null; return; } + const tk = tasks[currentTaskId]; + const done = taskDone(tk); + const finishedNow = openTaskWasDone === false && done; + openTaskWasDone = done; + if (!finishedNow) return; + if (JSON.stringify(redactions) !== loadedRedactionsSig) { toast(t('warn.finished')); return; } + await loadMetadata(); + initDocumentView(tk.total || 1); + updatePendingBar(true); + toast(t('ok.finished'), true); + } + async function selectTask(id, name) { currentTaskId = id; currentPage = 1; zoom = 0.8; redactions = []; // Limpa tarjas do documento anterior @@ -719,6 +792,7 @@

Log de auditoria da tarefa

await fetchTasks(); await loadMetadata(); + openTaskWasDone = taskDone(tasks[currentTaskId]); initDocumentView((tasks[currentTaskId] && tasks[currentTaskId].total) || 1); updatePendingBar(true); } @@ -811,6 +885,7 @@

Log de auditoria da tarefa

const r = await fetch(`/metadata/${currentTaskId}`); const data = await r.json(); redactions = data.redactions || []; + loadedRedactionsSig = JSON.stringify(redactions); } catch(e) { console.error("Erro ao carregar metadados:", e); } } @@ -915,6 +990,7 @@

Log de auditoria da tarefa

}; layer.appendChild(box); }); + renderReviewMarks(); } let activePageNum = null; @@ -1038,6 +1114,7 @@

Log de auditoria da tarefa

loader.style.display = 'none'; if(res.ok) { + loadedRedactionsSig = JSON.stringify(redactions); // salvo: sem edições pendentes const btn = document.getElementById('btn-save'); const originalHTML = btn.innerHTML; btn.innerHTML = ''; diff --git a/tests/test_auth.py b/tests/test_auth.py index d6dbbb6..3b780b2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -12,7 +12,7 @@ from fastapi.testclient import TestClient # noqa: E402 from utils.auth import TokenAuth # noqa: E402 -from utils.pii import CpfMaskingFilter, install_access_log_masking, mask_secrets # noqa: E402 +from utils.pii import ArgsMaskingFilter, CpfMaskingFilter, install_access_log_masking, mask_secrets # noqa: E402 def _app(token="segredo"): @@ -81,12 +81,24 @@ def test_mask_secrets_and_access_log_filter(): assert mask_secrets("GET /tasks?token=abc&x=1") == "GET /tasks?token=***&x=1" assert mask_secrets("?API_TOKEN=z") == "?API_TOKEN=***" install_access_log_masking() - assert any(isinstance(f, CpfMaskingFilter) for f in logging.getLogger("uvicorn.access").filters) + filters = logging.getLogger("uvicorn.access").filters + assert any(isinstance(f, ArgsMaskingFilter) for f in filters) + assert not any(isinstance(f, CpfMaskingFilter) for f in filters) record = logging.LogRecord("uvicorn.access", logging.INFO, __file__, 1, '%s "GET %s"', ("127.0.0.1", "/?token=s3"), None) - CpfMaskingFilter().filter(record) + ArgsMaskingFilter().filter(record) assert "s3" not in record.getMessage() and "token=***" in record.getMessage() +def test_uvicorn_access_formatter_still_works_with_masking(): + # Regressão da 5.3.0: o filtro achatava record.args e o AccessFormatter do uvicorn quebrava a cada requisição + from uvicorn.logging import AccessFormatter + record = logging.LogRecord("uvicorn.access", logging.INFO, __file__, 1, '%s - "%s %s HTTP/%s" %d', + ("127.0.0.1:5000", "GET", "/tasks?token=s3cr3t", "1.1", 200), None) + ArgsMaskingFilter().filter(record) + line = AccessFormatter('%(client_addr)s - "%(request_line)s" %(status_code)s', use_colors=False).format(record) + assert "s3cr3t" not in line and "token=***" in line and "200" in line + + def test_gatekeeper_panel_requires_token_when_set(monkeypatch, tmp_path): monkeypatch.setenv("GATEKEEPER_STATE_FILE", str(tmp_path / "state")) monkeypatch.setenv("API_TOKEN", "segredo") diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 3e43892..0a6107a 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -265,3 +265,30 @@ def test_pipeline_shadow_changes_nothing_but_logs(app, monkeypatch): def test_pipeline_without_engine_is_a_noop(app): app.apply_address_decisions() assert not os.path.exists(os.path.join(app.session.output_dir, "decisions.json")) + + +def test_preload_loads_model_once_even_when_racing_with_use(monkeypatch): + import sys + import threading + import time + import types + from utils.decisions.engine import LayaEngine + + loads = [] + + def slow_load(*a, **k): + loads.append(threading.current_thread().name) + time.sleep(0.2) + return types.SimpleNamespace(predict_batch=lambda states, q: [ + {"answers": {qid: {"noul": 0.9} for qid in q}} for _ in states]) + + monkeypatch.setitem(sys.modules, "laya", types.SimpleNamespace(load=slow_load)) + engine = LayaEngine(model_id="convaiinnovations/laya-multilingual") + thread = DecisionService(engine).preload() # começa a carregar em segundo plano + feats = engine.features(["Rua A, casa 1"]) # uso concorrente: espera a mesma carga + thread.join() + assert len(loads) == 1 and len(feats[0]) == len(FEATURE_IDS) + + +def test_preload_is_optional_for_engines_without_it(): + assert DecisionService(KeywordEngine()).preload() is None diff --git a/tests/test_fail_closed_ocr_address.py b/tests/test_fail_closed_ocr_address.py index 0ebf108..2e75c27 100644 --- a/tests/test_fail_closed_ocr_address.py +++ b/tests/test_fail_closed_ocr_address.py @@ -65,7 +65,7 @@ def __init__(self, fail_when): def get_grounding_map(self, img_path, psm=3): if self.fail_when(os.path.basename(img_path)): - self.failure_count += 1 + self._record_failure() return "", [] return "[0] CPF: [1] " + CPF_A_FMT, [word(0, "CPF:", 0), word(1, CPF_A_FMT, 200, w=280)] diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..605accd --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Eficiência sem perder segurança: OCR das páginas em paralelo (com falha atribuída à página certa), +texto digital do PDF como passada extra, tempos por etapa e número de workers.""" +import os +import threading +import time + +import fitz +import pytest + +from main import SentryApp +from sentry_testkit import CPF_A, CPF_A_FMT, CPF_B_FMT, make_text_pdf, word +from utils.ocr_engine import OCREngine, ocr_workers +from utils.verifier import verify_pdf + + +class SlowOCR(OCREngine): + """OCR falso: demora um pouco, conta a concorrência e falha nas páginas pedidas.""" + + def __init__(self, fail_pages=(), delay=0.05): + super().__init__(tesseract_path=None) + self.fail_pages, self.delay = set(fail_pages), delay + self.active = self.peak = 0 + self.calls = [] + self._guard = threading.Lock() + + def get_grounding_map(self, img_path, psm=3): + with self._guard: + self.active += 1 + self.peak = max(self.peak, self.active) + self.calls.append((os.path.basename(img_path), str(psm))) + time.sleep(self.delay) + with self._guard: + self.active -= 1 + page = int(os.path.splitext(os.path.basename(img_path))[0].rsplit("_", 1)[-1]) + if page in self.fail_pages: + self._record_failure() + return "", [] + return f"[0] {CPF_A_FMT}", [word(0, CPF_A_FMT, 100)] + + +@pytest.fixture() +def app(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + monkeypatch.setenv("OCR_WORKERS", "4") + monkeypatch.setenv("TESSERACT_SPARSE_PSM", "11") + a = SentryApp(make_text_pdf(tmp_path / "doc.pdf", text="pagina", n_pages=6)) + yield a + a.session.close() + + +def test_phase1_runs_pages_in_parallel_and_keeps_both_passes(app): + app.run_phase_0() + app.ocr = SlowOCR() + app.run_phase_1() + assert app.ocr.peak > 1 # páginas em paralelo + assert sorted(psm for _, psm in app.ocr.calls) == ["11"] * 6 + ["3"] * 6 # as DUAS passadas em todas + assert len(app.grounding_maps) == len(app.grounding_maps_sparse) == 6 # ordem e tamanho preservados + + +def test_parallel_ocr_failure_is_attributed_to_the_right_page(app): + app.run_phase_0() + app.ocr = SlowOCR(fail_pages={2, 5}) + app.run_phase_1() + assert sorted(app.review_pages) == [2, 5] # nem mais (outras páginas) nem menos + + +def test_native_text_is_an_extra_cpf_pass(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + monkeypatch.delenv("TESSERACT_SPARSE_PSM", raising=False) + pdf = str(tmp_path / "digital.pdf") + with fitz.open() as doc: + doc.new_page().insert_text((72, 100), f"Interessado CPF {CPF_B_FMT}") + doc.save(pdf) + app = SentryApp(pdf) + app.run_phase_0() + + class BlindOCR(OCREngine): # o OCR "não lê" nada: só o texto digital pode achar o CPF + def get_grounding_map(self, img_path, psm=3): + return "", [] + + app.ocr = BlindOCR(tesseract_path=None) + app.run_phase_1() + app.run_phase_2() + assert app.cpf_redactions[1], "o CPF do texto digital deveria ter virado tarja" + box = app.cpf_redactions[1][0] + assert 0 < box["x"] < app.base_dpi / 72.0 * 600 # caixa na escala de pixels da imagem + app.session.close() + + +def test_timings_are_recorded_per_phase(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + app = SentryApp(make_text_pdf(tmp_path / "t.pdf")) + for name in ("run_phase_0", "run_phase_1", "run_phase_2", "run_phase_3", "run_phase_4", "run_address_discovery", + "run_phase_6", "run_signature_audit", "run_phase_5", "run_verification"): + monkeypatch.setattr(app, name, lambda: time.sleep(0.01)) + app.run() + timings = app.final_state()["timings"] + assert len(timings) == 10 and all(v >= 0 for v in timings.values()) + app.session.close() + + +@pytest.mark.parametrize("env, dpi, jobs, expected", [ + ("3", 300, 10, 3), ("3", 300, 2, 2), ("", 1000, 10, None), ("x", 300, 10, None), +]) +def test_ocr_workers(monkeypatch, env, dpi, jobs, expected): + monkeypatch.setenv("OCR_WORKERS", env) + value = ocr_workers(dpi, jobs) + if expected is not None: + assert value == expected + else: + assert 1 <= value <= (2 if dpi > 600 else 8) + + +def test_parallel_verification_matches_sequential(tmp_path): + from PIL import Image + pdf = str(tmp_path / "scan.pdf") + img = tmp_path / "p.png" + Image.new("RGB", (100, 140), "white").save(img) + with fitz.open() as doc: + for _ in range(4): + doc.new_page().insert_image(fitz.Rect(0, 0, 595, 842), filename=str(img)) + doc.save(pdf) + + class PageOCR(OCREngine): # CPF só nas páginas pares; página 3 falha + def get_grounding_map(self, img_path, psm=3): + page = int(os.path.splitext(os.path.basename(img_path))[0].rsplit("_", 1)[-1]) + if page == 3: + self._record_failure() + return "", [] + return (f"[0] {CPF_A_FMT}", [word(0, CPF_A_FMT, 10)]) if page % 2 == 0 else ("", []) + + seq = verify_pdf(pdf, ocr_engine=PageOCR(tesseract_path=None), dpi=36, workers=1) + par = verify_pdf(pdf, ocr_engine=PageOCR(tesseract_path=None), dpi=36, workers=4) + assert seq == par + leftovers, unverified = par + assert set(leftovers) == {2, 4} and unverified == [3] and leftovers[2] == {CPF_A} diff --git a/tests/test_review_marks.py b/tests/test_review_marks.py new file mode 100644 index 0000000..d5ee3ae --- /dev/null +++ b/tests/test_review_marks.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Regiões a revisar: a verificação diz ONDE está o problema (caixas relativas 0..1), não só a página.""" +import shutil + +import fitz +import pytest +from PIL import Image + +from main import SentryApp +from sentry_testkit import CPF_A_FMT, make_text_pdf, word +from utils.ocr_engine import OCREngine +from utils.verifier import find_uncovered_cpfs, verify_pdf + + +class FixedOCR(OCREngine): + """OCR falso: sempre 'lê' um CPF numa posição conhecida da imagem recebida (em pixels).""" + + def __init__(self, rel_box): + super().__init__(tesseract_path=None) + self.rel_box = rel_box + + def get_grounding_map(self, img_path, psm=3): + w, h = Image.open(img_path).size + x0, y0, x1, y1 = self.rel_box + return "[0] " + CPF_A_FMT, [word(0, CPF_A_FMT, x0 * w, y=y0 * h, w=(x1 - x0) * w, h=(y1 - y0) * h)] + + +def _scanned_pdf(tmp_path): + img = tmp_path / "scan.png" + Image.new("RGB", (200, 280), "white").save(img) + pdf = str(tmp_path / "scan.pdf") + with fitz.open() as doc: + doc.new_page().insert_image(fitz.Rect(0, 0, 595, 842), filename=str(img)) + doc.save(pdf) + return pdf + + +def test_verify_pdf_reports_relative_location_of_leftover_cpf(tmp_path): + locations = {} + leftovers, unverified = verify_pdf(_scanned_pdf(tmp_path), ocr_engine=FixedOCR([0.5, 0.25, 0.75, 0.3]), + dpi=72, locations=locations) + assert leftovers and not unverified + (box,) = locations[1] + assert box == pytest.approx([0.5, 0.25, 0.75, 0.3], abs=0.01) + + +def test_verify_pdf_native_text_location(tmp_path): + pdf = str(tmp_path / "texto.pdf") + with fitz.open() as doc: + doc.new_page(width=600, height=800).insert_text((300, 200), f"CPF {CPF_A_FMT}") + doc.save(pdf) + locations = {} + leftovers, _ = verify_pdf(pdf, use_ocr=False, locations=locations) + assert leftovers[1] and 0.5 <= locations[1][0][0] <= 0.65 and 0.22 < locations[1][0][1] < 0.26 + + +def test_uncovered_cpf_location(tmp_path): + locations = {} + uncovered, failed = find_uncovered_cpfs(_scanned_pdf(tmp_path), {}, FixedOCR([0.1, 0.8, 0.3, 0.83]), dpi=72, + locations=locations) + assert uncovered and not failed + assert locations[1][0] == pytest.approx([0.1, 0.8, 0.3, 0.83], abs=0.01) + + +@pytest.fixture() +def app(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "50") + a = SentryApp(make_text_pdf(tmp_path / "doc.pdf", n_pages=2)) + yield a + a.session.close() + + +def test_review_marks_travel_with_the_final_state(app): + app.add_review(2, f"CPF {CPF_A_FMT} ainda visível", [[0.1, 0.2, 0.3, 0.25]]) + app.add_review(2, f"CPF {CPF_A_FMT} ainda visível", [[0.1, 0.2, 0.3, 0.25]]) # sem duplicar + app.add_review(1, "página sem posição") # alerta só de página + state = app.final_state() + assert state["status"] == "Requer revisão" + assert state["review_marks"] == [{"page": 2, "reason": app.review_marks[0]["reason"], "box": [0.1, 0.2, 0.3, 0.25]}] + assert CPF_A_FMT not in app.review_marks[0]["reason"] # motivo mascarado + + +def test_verification_attaches_locations_to_alerts(app, monkeypatch, tmp_path): + app.run_phase_0() + app.ocr = FixedOCR([0.5, 0.25, 0.75, 0.3]) + monkeypatch.setenv("VERIFY_DPI", "40") + final = f"{app.session.doc_finais_dir}/{app.session.filename}_TARJADO_FINAL.pdf" + shutil.copy(_scanned_pdf(tmp_path), final) # "PDF final" digitalizado com um CPF ainda legível + app.run_verification() + pages = {m["page"] for m in app.review_marks} + assert 1 in pages and all(len(m["box"]) == 4 for m in app.review_marks) + + +def test_reprocess_clears_review_marks(monkeypatch): + import importlib + import app_service + importlib.reload(app_service) + task = {"review_marks": [{"page": 1}], "alerts": ["x"]} + app_service.reset_task_state(task, "Reiniciando...") + assert task["review_marks"] == [] and task["alerts"] == [] diff --git a/tests/test_session.py b/tests/test_session.py index a497e7a..4d035fa 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -260,9 +260,10 @@ def test_native_redaction_uses_image_width_and_default_scale(session, tmp_path): assert "pagina 2" in d[1].get_text() -def test_native_redaction_default_assumption_is_1000_dpi(session): - """Sem source/image_width: assume imagem a 1000 DPI -> caixa gigante em pixels vira pequena em pontos.""" - box = {"x": 1000, "y": 1250, "w": 800, "h": 300} # ~72..130pt x 90..112pt +def test_native_redaction_default_assumption_is_base_dpi(session, monkeypatch): + """Sem source/image_width: assume a resolução de renderização (BASE_DPI) para converter pixels em pontos.""" + monkeypatch.setenv("BASE_DPI", "1000") + box = {"x": 1000, "y": 1250, "w": 800, "h": 300} # a 1000 DPI: ~72..130pt x 90..112pt out = session.apply_native_pdf_redactions({1: [box]}) with fitz.open(out) as d: assert "pagina 1" not in d[0].get_text() @@ -284,3 +285,11 @@ def test_no_cpf_text_artifacts_after_basic_usage(session, tmp_path): session.save_ai_interaction("f", "p", CPF_A_FMT, CPF_A, {"m": CPF_A}) for path in text_files_under(session.output_dir): assert not FULL_CPF_RE.search(open(path, encoding="utf-8").read()), path + + +def test_native_redaction_default_assumption_follows_new_300_dpi_default(session, monkeypatch): + monkeypatch.delenv("BASE_DPI", raising=False) + box = {"x": 300, "y": 375, "w": 240, "h": 90} # a 300 DPI: os mesmos ~72..130pt x 90..112pt + out = session.apply_native_pdf_redactions({1: [box]}) + with fitz.open(out) as d: + assert "pagina 1" not in d[0].get_text() diff --git a/tests/test_validators_transform.py b/tests/test_validators_transform.py index 58a8d73..f78499b 100644 --- a/tests/test_validators_transform.py +++ b/tests/test_validators_transform.py @@ -6,6 +6,7 @@ from PIL import Image from sentry_testkit import CPF_A, CPF_A_FMT, CPF_B, make_text_pdf +import utils.transform_pdf_to_img as transform_module from utils.transform_pdf_to_img import transform_pdf_to_img from utils.validators import is_valid_cnpj, is_valid_cpf @@ -82,15 +83,15 @@ def test_transform_corrupt_pdf_raises(tmp_path): def test_transform_retries_locked_output_then_succeeds(tmp_path, monkeypatch): pdf = make_text_pdf(tmp_path / "a.pdf") calls = {"n": 0} - real_save = fitz.Pixmap.save + real_save = transform_module._save_png - def flaky(self, filename, *a, **k): + def flaky(pix, filename): calls["n"] += 1 if calls["n"] < 3: raise OSError("arquivo travado") - return real_save(self, filename, *a, **k) + return real_save(pix, filename) - monkeypatch.setattr(fitz.Pixmap, "save", flaky) + monkeypatch.setattr(transform_module, "_save_png", flaky) monkeypatch.setattr("time.sleep", lambda s: None) paths = transform_pdf_to_img(pdf, str(tmp_path / "o"), dpi=36) assert calls["n"] == 3 and os.path.exists(paths[0]) @@ -98,7 +99,7 @@ def flaky(self, filename, *a, **k): def test_transform_gives_up_after_three_failed_saves(tmp_path, monkeypatch): pdf = make_text_pdf(tmp_path / "a.pdf") - monkeypatch.setattr(fitz.Pixmap, "save", lambda *a, **k: (_ for _ in ()).throw(OSError("travado"))) + monkeypatch.setattr(transform_module, "_save_png", lambda *a, **k: (_ for _ in ()).throw(OSError("travado"))) monkeypatch.setattr("time.sleep", lambda s: None) with pytest.raises(OSError): transform_pdf_to_img(pdf, str(tmp_path / "o"), dpi=36) diff --git a/tests/ui/test_ui_review_marks.py b/tests/ui/test_ui_review_marks.py new file mode 100644 index 0000000..7a9a682 --- /dev/null +++ b/tests/ui/test_ui_review_marks.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""UI: regiões a revisar desenhadas na página e atualização automática do documento aberto.""" +import json + +from playwright.sync_api import expect + +from ui_support import box + +MARK = {"page": 2, "reason": "1 CPF(s) ainda detectável(is) no PDF final", "box": [0.5, 0.25, 0.75, 0.3]} + + +def test_review_mark_is_drawn_where_the_problem_is(page, live_app, open_task): + tid = live_app.seed_task("marcas.pdf", pages=3, status="Requer revisão", needs_review=True, + alerts=["Pág 2: 1 CPF(s) ainda detectável(is) no PDF final"], redactions=[], + extra={"review_marks": [MARK]}) + open_task(tid) + mark = page.locator("#layer-2 [data-testid=review-mark]") + page.locator("#page-wrapper-2").scroll_into_view_if_needed() + page.wait_for_function("() => { const i = document.querySelector('#page-wrapper-2 img'); return i && i.naturalWidth > 0; }") + expect(mark).to_have_count(1) + expect(mark).to_have_attribute("title", MARK["reason"]) + expect(mark).to_contain_text("Revisar aqui") + # posição relativa: começa a ~50% da largura e ~25% da altura da página + pos = page.evaluate("""() => { + const m = document.querySelector('#layer-2 [data-testid=review-mark]').getBoundingClientRect(); + const p = document.querySelector('#page-wrapper-2 img').getBoundingClientRect(); + return [(m.left - p.left) / p.width, (m.top - p.top) / p.height, m.width / p.width]; + }""") + assert 0.47 < pos[0] < 0.51 and 0.22 < pos[1] < 0.26 and 0.24 < pos[2] < 0.28 + assert page.locator("#layer-1 [data-testid=review-mark], #layer-3 [data-testid=review-mark]").count() == 0 + + +def test_pending_chip_takes_you_to_the_region_and_highlights_it(page, live_app, open_task): + tid = live_app.seed_task("ir.pdf", pages=3, status="Requer revisão", needs_review=True, + alerts=["Pág 2: 1 CPF(s) ainda detectável(is) no PDF final"], redactions=[], + extra={"review_marks": [MARK]}) + open_task(tid) + page.locator("#page-wrapper-2").scroll_into_view_if_needed() + page.wait_for_function("() => { const i = document.querySelector('#page-wrapper-2 img'); return i && i.naturalWidth > 0; }") + page.locator("#page-wrapper-1").scroll_into_view_if_needed() + page.locator(".pending-chip[data-page='2']").click() + expect(page.locator("#layer-2 [data-testid=review-mark]")).to_have_class("review-mark flash") + expect(page.locator("#layer-2 [data-testid=review-mark]")).to_be_in_viewport() + + +def test_review_mark_reason_is_text_not_html(page, live_app, open_task): + evil = {"page": 1, "reason": "", "box": [0.1, 0.1, 0.2, 0.2]} + tid = live_app.seed_task("xss.pdf", pages=1, status="Requer revisão", needs_review=True, + alerts=["Pág 1: teste"], redactions=[], extra={"review_marks": [evil, {"page": 1, "box": "x"}]}) + open_task(tid) + expect(page.locator("#layer-1 [data-testid=review-mark]")).to_have_count(1) # caixa inválida ignorada + assert page.locator("#layer-1 img").count() == 0 and page.evaluate("window.__xss") is None + + +def test_open_document_reloads_by_itself_when_processing_finishes(page, live_app, open_task): + tid = live_app.seed_task("processando.pdf", pages=2, status="OCR: Pagina 1/2", percentage=30, completed=False, + redactions=[]) + open_task(tid) + expect(page.locator(".redaction-box")).to_have_count(0) + # o "worker" termina: grava as sugestões e marca a tarefa como concluída com uma região a revisar + meta = live_app.output_dir / live_app.base(tid) / "redactions_metadata.json" + meta.write_text(json.dumps([box(1), box(2)]), encoding="utf-8") + live_app.update_task(tid, status="Requer revisão", percentage=100, completed=True, needs_review=True, + alerts=["Pág 2: 1 CPF(s) ainda detectável(is) no PDF final"], review_marks=[MARK]) + expect(page.locator("#toast")).to_contain_text("Processamento concluído", timeout=10000) + expect(page.locator(".redaction-box")).to_have_count(2) + page.locator("#page-wrapper-2").scroll_into_view_if_needed() + expect(page.locator("#layer-2 [data-testid=review-mark]")).to_have_count(1) + + +def test_unsaved_edits_are_never_discarded_when_processing_finishes(page, live_app, open_task): + tid = live_app.seed_task("editando.pdf", pages=1, status="Processando", percentage=50, completed=False, + redactions=[box(1)]) + open_task(tid) + page.evaluate("() => { redactions.push({page: 1, coords: [10, 10, 60, 40], type: 'pii', source: 'Manual', " + "image_width: 700, image_height: 1000}); renderRedactions(); }") + expect(page.locator(".redaction-box")).to_have_count(2) + live_app.update_task(tid, status="Concluído", percentage=100, completed=True) + expect(page.locator("#toast")).to_contain_text("Salve suas edições", timeout=10000) + expect(page.locator(".redaction-box")).to_have_count(2) # a edição do revisor continua lá + + +def test_task_card_shows_processing_time(page, live_app, open_app): + live_app.seed_task("tempo.pdf", pages=1, extra={"timings": {"OCR Scanning": 61.4, "Renderizando PDF": 4.2}}) + open_app() + t = page.locator("[data-testid=task-time]") + expect(t).to_have_text("⏱ 1 min 6 s") + expect(t).to_have_attribute("title", "OCR Scanning: 61.4 s\nRenderizando PDF: 4.2 s") diff --git a/utils/decisions/engine.py b/utils/decisions/engine.py index 9c7ba0b..a0cf92c 100644 --- a/utils/decisions/engine.py +++ b/utils/decisions/engine.py @@ -16,6 +16,7 @@ import logging import math import os +import threading from dataclasses import dataclass, field from typing import List, Optional, Sequence @@ -57,8 +58,24 @@ def __init__(self, model_id=None, device=None): self.model_id = model_id or os.getenv("LAYA_MODEL") or DEFAULT_LAYA_MODEL self.device = device or os.getenv("LAYA_DEVICE") or "cpu" self._agent = None + self._load_lock = threading.Lock() # pré-carga em segundo plano + uso: carrega uma vez só def _load(self): + with self._load_lock: + return self._load_locked() + + def preload(self): + """Carrega o modelo em segundo plano (ex.: enquanto o LLM analisa as páginas). Erros ficam para o uso.""" + def run(): + try: + self._load() + except Exception as e: # o erro reaparece em features() e a política trata (falha fechado) + logger.warning(f"[decisions] pré-carga do modelo falhou: {e}") + thread = threading.Thread(target=run, name="laya-preload", daemon=True) + thread.start() + return thread + + def _load_locked(self): if self._agent is None: try: # Redes com inspeção TLS (proxy corporativo): usa o repositório de certificados do sistema. @@ -150,6 +167,10 @@ def __init__(self, engine, profile: Optional[Profile] = None, mode="shadow"): def thresholds(self): return self.profile.thresholds + def preload(self): + """Começa a carregar o modelo agora, em paralelo, se o motor souber fazer isso.""" + return self.engine.preload() if hasattr(self.engine, "preload") else None + def decide_many(self, texts: Sequence[str]) -> List[Decision]: """Nunca levanta exceção: falha do motor vira p_personal=None (a política trata).""" try: diff --git a/utils/ocr_engine.py b/utils/ocr_engine.py index c79a035..b42f579 100644 --- a/utils/ocr_engine.py +++ b/utils/ocr_engine.py @@ -1,9 +1,32 @@ # SPDX-License-Identifier: AGPL-3.0-or-later +import os import re +import threading import pytesseract from PIL import Image from utils.validators import is_valid_cpf, is_valid_cnpj +def ocr_workers(base_dpi=None, jobs=None): + """ + Quantas páginas rodar no OCR ao mesmo tempo (OCR_WORKERS; padrão: metade dos núcleos, até 8). + Imagens muito grandes (BASE_DPI > 600) usam no máximo 2, para não estourar a memória. + """ + try: + value = int(os.getenv("OCR_WORKERS", "0")) + except ValueError: + value = 0 + if value <= 0: + value = max(1, min(8, (os.cpu_count() or 2) // 2)) + if base_dpi and base_dpi > 600: + value = min(value, 2) + if jobs is not None: + value = min(value, max(1, jobs)) + if value > 1: + # Vários Tesseracts ao mesmo tempo: cada um com 1 thread (evita disputa de núcleos; recomendação do Tesseract) + os.environ.setdefault("OMP_THREAD_LIMIT", "1") + return value + + class OCREngine: def __init__(self, tesseract_path, lang="por+eng", logger=None): self.tesseract_path = tesseract_path @@ -12,6 +35,8 @@ def __init__(self, tesseract_path, lang="por+eng", logger=None): # Quantas chamadas ao Tesseract falharam de vez (nas duas escalas). Quem chama compara o valor # antes/depois de uma página para falhar fechado: resultado vazio por erro != página sem CPF. self.failure_count = 0 + self._failure_lock = threading.Lock() + self._local = threading.local() # falhas da thread atual: com OCR em paralelo, cada página conta as suas if self.tesseract_path: pytesseract.pytesseract.tesseract_cmd = self.tesseract_path @@ -44,11 +69,20 @@ def attempt_scan(img_input, current_scale): img_safe = img.resize((int(w_orig * scale), int(h_orig * scale)), resample=Image.Resampling.LANCZOS) return attempt_scan(img_safe, scale) except Exception as e2: - self.failure_count += 1 + self._record_failure() if self.logger: self.logger.error(f"[!!] TESSERACT CRITICAL FAIL: {e2}") return {"text": []} if method == "data" else "" + def _record_failure(self): + with self._failure_lock: + self.failure_count += 1 + self._local.failures = getattr(self._local, "failures", 0) + 1 + + def thread_failures(self): + """Falhas do Tesseract registradas pela thread atual (compare antes/depois de uma página).""" + return getattr(self._local, "failures", 0) + def image_to_data(self, img, psm=3): return self._run_tesseract_with_fallback(img, method="data", psm=psm) diff --git a/utils/pii.py b/utils/pii.py index f9c892f..a0cacc8 100644 --- a/utils/pii.py +++ b/utils/pii.py @@ -63,15 +63,38 @@ def filter(self, record): return True +class ArgsMaskingFilter(logging.Filter): + """Mascara CPF e segredos DENTRO dos argumentos do registro, preservando a estrutura. + + O formatador de acesso do uvicorn lê record.args como tupla (cliente, método, caminho, versão, status): + achatar a mensagem (como faz o CpfMaskingFilter) quebraria a formatação de cada requisição. + """ + + @staticmethod + def _clean(value): + return mask_secrets(mask_text(value)) if isinstance(value, str) else value + + def filter(self, record): + if isinstance(record.msg, str): + record.msg = self._clean(record.msg) + if isinstance(record.args, tuple): + record.args = tuple(self._clean(a) for a in record.args) + elif isinstance(record.args, dict): + record.args = {k: self._clean(v) for k, v in record.args.items()} + return True + + def install_access_log_masking(): - """Mascara CPF e segredos nos registros do uvicorn (acesso e erro), que têm handlers próprios. + """Mascara CPF e segredos nos registros do uvicorn (acesso e erro), que têm handlers e formatadores próprios. Filtro de LOGGER: vale para tudo o que esses loggers registram, mesmo com handlers criados depois. """ - flt = CpfMaskingFilter() + flt = ArgsMaskingFilter() for name in ("uvicorn.access", "uvicorn.error"): lg = logging.getLogger(name) - if not any(isinstance(f, CpfMaskingFilter) for f in lg.filters): + for old in [f for f in lg.filters if isinstance(f, CpfMaskingFilter)]: + lg.removeFilter(old) + if not any(isinstance(f, ArgsMaskingFilter) for f in lg.filters): lg.addFilter(flt) return flt diff --git a/utils/session.py b/utils/session.py index 1406c01..4f31179 100644 --- a/utils/session.py +++ b/utils/session.py @@ -296,7 +296,7 @@ def apply_final_redactions(self, page_num, img_path, box_list, dir_key="10_final # Mesmo se não houver tarjas para aplicar (box_list vazio), # PROCESSAMOS a imagem (redimensionamento + conversão JPEG) para o PDF final - # para garantir que o arquivo final não contenha PNGs gigantes de 1000 DPI. + # para garantir que o arquivo final não contenha PNGs gigantes (renderização em alta resolução). if box_list: for b in box_list: @@ -441,8 +441,8 @@ def apply_native_pdf_redactions(self, global_redactions, output_suffix="_NATIVO_ for b in boxes: src_w = b.get("source_width") or b.get("image_width") if not src_w or src_w <= 0: - # Assumimos que foi extraído nativamente na proporção do Tesseract (1000 DPI aprox.) - src_w = pdf_w * (1000 / 72.0) + # Sem a largura da imagem de origem: assume a resolução de renderização (BASE_DPI) + src_w = pdf_w * (_env_int("BASE_DPI", 300, 72, 2400) / 72.0) scale = pdf_w / float(src_w) diff --git a/utils/transform_pdf_to_img.py b/utils/transform_pdf_to_img.py index 0bda825..ee4730b 100644 --- a/utils/transform_pdf_to_img.py +++ b/utils/transform_pdf_to_img.py @@ -2,7 +2,15 @@ import fitz # PyMuPDF import os -def transform_pdf_to_img(pdf_path, output_folder, dpi=1000): +def _save_png(pix, output_path): + """PNG com compressão leve: com páginas de dezenas de megapixels, a compressão padrão gasta muito tempo.""" + try: + pix.pil_save(output_path, format="PNG", compress_level=1) + except (AttributeError, ImportError, TypeError): # PyMuPDF/Pillow sem pil_save: gravação padrão + pix.save(output_path) + + +def transform_pdf_to_img(pdf_path, output_folder, dpi=300): """ Transforms each page of a PDF into an image. @@ -47,7 +55,7 @@ def transform_pdf_to_img(pdf_path, output_folder, dpi=1000): max_retries = 3 for attempt in range(max_retries): try: - pix.save(output_path) + _save_png(pix, output_path) # Liberação explícita de memória para evitar picos de RAM pix = None break diff --git a/utils/verifier.py b/utils/verifier.py index 9c2dede..3eedded 100644 --- a/utils/verifier.py +++ b/utils/verifier.py @@ -6,11 +6,12 @@ A lógica de CPF (validação do dígito, exclusão de CNPJ/data) é a mesma de OCREngine. """ import os +from concurrent.futures import ThreadPoolExecutor import tempfile import fitz # PyMuPDF -from utils.ocr_engine import OCREngine +from utils.ocr_engine import OCREngine, ocr_workers def _words_to_grounding(words): @@ -29,69 +30,129 @@ def _words_to_grounding(words): return grounding -def find_cpfs_in_words(words, ocr_engine=None): - """CPFs válidos encontrados numa lista de palavras do PyMuPDF.""" +def _relative_boxes(grounding, commands, width, height): + """Caixas [x0, y0, x1, y1] relativas (0..1) das palavras marcadas: para mostrar ONDE revisar.""" + boxes = [] + for word_id in commands or []: + try: + b = grounding[word_id]["box"] + except (IndexError, KeyError, TypeError): + continue + boxes.append([round(b["x"] / width, 4), round(b["y"] / height, 4), + round((b["x"] + b["w"]) / width, 4), round((b["y"] + b["h"]) / height, 4)]) + return boxes + + +def _thread_failures(engine): + return engine.thread_failures() if hasattr(engine, "thread_failures") else getattr(engine, "failure_count", 0) + + +def _ocr_images(engine, jobs, psm, workers): + """ + OCR de várias imagens em paralelo. jobs: {chave: caminho}. Devolve {chave: (grounding, erro_ou_None)}. + Falha do Tesseract (resultado vazio por erro) vira erro, nunca "página sem CPF". + """ + def one(path): + before = _thread_failures(engine) + try: + _, grounding = engine.get_grounding_map(path, psm=psm) + except Exception as e: + return None, e + if _thread_failures(engine) > before: + return None, RuntimeError("Tesseract falhou (resultado vazio por erro)") + return grounding, None + + keys = list(jobs) + if workers <= 1 or len(keys) <= 1: + return {k: one(jobs[k]) for k in keys} + with ThreadPoolExecutor(max_workers=workers) as ex: + return dict(zip(keys, ex.map(one, [jobs[k] for k in keys]))) + + +def find_cpfs_in_words(words, ocr_engine=None, page_size=None, boxes_out=None): + """CPFs válidos encontrados numa lista de palavras do PyMuPDF. + + page_size=(largura, altura) + boxes_out=lista: acrescenta em boxes_out as caixas relativas encontradas. + """ engine = ocr_engine or OCREngine(tesseract_path=None) - _, found = engine.find_cpfs_in_grounding(_words_to_grounding(words)) + grounding = _words_to_grounding(words) + commands, found = engine.find_cpfs_in_grounding(grounding) + if boxes_out is not None and page_size and found: + boxes_out.extend(_relative_boxes(grounding, commands, *page_size)) return found -def verify_pdf(pdf_path, ocr_engine=None, use_ocr=True, dpi=300, psm="6", logger=None, progress=None): +def verify_pdf(pdf_path, ocr_engine=None, use_ocr=True, dpi=300, psm="6", logger=None, progress=None, + locations=None, workers=None): """ Retorna {page_num: set(cpfs)} com os CPFs ainda detectáveis no PDF. Páginas sem texto nativo só são checadas por OCR se use_ocr=True e ocr_engine tiver Tesseract. Também retorna a lista de páginas que não puderam ser verificadas. + locations (dict opcional): recebe {page_num: [[x0, y0, x1, y1], ...]} relativos (0..1) do que foi achado. """ leftovers = {} unverified = [] engine = ocr_engine or OCREngine(tesseract_path=None) - with fitz.open(pdf_path) as doc: + with fitz.open(pdf_path) as doc, tempfile.TemporaryDirectory() as tmp: total = len(doc) + pages, jobs = [], {} + # 1) leitura do texto nativo e renderização (sequencial: o documento do PyMuPDF não é thread-safe) for idx in range(total): page_num = idx + 1 page = doc[idx] words = page.get_text("words") has_images = bool(page.get_images()) - found_here = set() - + found_here, boxes_here = set(), [] if words: - found_here |= find_cpfs_in_words(words, engine) - + found_here |= find_cpfs_in_words(words, engine, (page.rect.width, page.rect.height), boxes_here) # Página escaneada: pode ter algumas palavras de texto nativo (carimbo, cabeçalho) e o # conteúdo real em imagem. Se há imagens (ou nenhum texto), só o OCR verifica o resto. needs_ocr = has_images or not words + size = (page.rect.width * dpi / 72.0, page.rect.height * dpi / 72.0) if needs_ocr and use_ocr: try: pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) - with tempfile.TemporaryDirectory() as tmp: - img_path = os.path.join(tmp, f"verify_{page_num}.png") - pix.save(img_path) - pix = None - failures_before = getattr(engine, "failure_count", 0) - _, grounding = engine.get_grounding_map(img_path, psm=psm) - if getattr(engine, "failure_count", 0) > failures_before: - raise RuntimeError("Tesseract falhou (resultado vazio por erro)") - _, found_ocr = engine.find_cpfs_in_grounding(grounding) - found_here |= found_ocr + img_path = os.path.join(tmp, f"verify_{page_num}.png") + pix.save(img_path) + pix = None + jobs[page_num] = img_path except Exception as e: if logger: logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {e}") unverified.append(page_num) elif needs_ocr and not words: unverified.append(page_num) + pages.append((page_num, found_here, boxes_here, size)) + + # 2) OCR das páginas em paralelo + results = _ocr_images(engine, jobs, psm, workers or ocr_workers(dpi, len(jobs))) + # 3) resultado em ordem de página + for page_num, found_here, boxes_here, size in pages: + if page_num in results: + grounding, error = results[page_num] + if error is not None: + if logger: + logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {error}") + unverified.append(page_num) + else: + commands_ocr, found_ocr = engine.find_cpfs_in_grounding(grounding) + found_here |= found_ocr + if found_ocr: + boxes_here.extend(_relative_boxes(grounding, commands_ocr, *size)) if found_here: leftovers[page_num] = found_here - + if locations is not None and boxes_here: + locations[page_num] = boxes_here if progress and (page_num % 10 == 0 or page_num == total): progress(page_num, total) - return leftovers, unverified + return leftovers, sorted(set(unverified)) -def find_uncovered_cpfs(pdf_path, boxes_by_page, ocr_engine, base_dpi=1000, dpi=300, psm="6", - tolerance_pt=2.0, logger=None, progress=None): +def find_uncovered_cpfs(pdf_path, boxes_by_page, ocr_engine, base_dpi=300, dpi=300, psm="6", + tolerance_pt=2.0, logger=None, progress=None, locations=None, workers=None): """ Verificação de COBERTURA, independente da detecção original: faz OCR do PDF ORIGINAL em outra resolução/configuração e confere se cada CPF achado está dentro de alguma tarja decidida. @@ -104,29 +165,35 @@ def find_uncovered_cpfs(pdf_path, boxes_by_page, ocr_engine, base_dpi=1000, dpi= """ uncovered = {} failed = [] - with fitz.open(pdf_path) as doc: + with fitz.open(pdf_path) as doc, tempfile.TemporaryDirectory() as tmp: total = len(doc) - for idx in range(total): + widths, jobs = {}, {} + for idx in range(total): # renderização sequencial page_num = idx + 1 page = doc[idx] - page_w = page.rect.width + widths[page_num] = (page.rect.width, page.rect.height) try: pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) - with tempfile.TemporaryDirectory() as tmp: - img_path = os.path.join(tmp, f"cover_{page_num}.png") - pix.save(img_path) - pix = None - failures_before = getattr(ocr_engine, "failure_count", 0) - _, grounding = ocr_engine.get_grounding_map(img_path, psm=psm) - if getattr(ocr_engine, "failure_count", 0) > failures_before: - raise RuntimeError("Tesseract falhou (resultado vazio por erro)") - commands, found = ocr_engine.find_cpfs_in_grounding(grounding) + img_path = os.path.join(tmp, f"cover_{page_num}.png") + pix.save(img_path) + pix = None + jobs[page_num] = img_path except Exception as e: if logger: logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {e}") failed.append(page_num) - continue + results = _ocr_images(ocr_engine, jobs, psm, workers or ocr_workers(dpi, len(jobs))) # OCR em paralelo + + for page_num in sorted(results): + grounding, error = results[page_num] + if error is not None: + if logger: + logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {error}") + failed.append(page_num) + continue + page_w, page_h = widths[page_num] + commands, found = ocr_engine.find_cpfs_in_grounding(grounding) if commands: rects = [] for b in boxes_by_page.get(page_num, []): @@ -142,9 +209,11 @@ def find_uncovered_cpfs(pdf_path, boxes_by_page, ocr_engine, base_dpi=1000, dpi= cy = (box["y"] + box["h"] / 2.0) * pt_per_px if not any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in rects): uncovered.setdefault(page_num, set()).update(found) + if locations is not None: + scale = dpi / 72.0 + locations.setdefault(page_num, []).extend(_relative_boxes( + grounding, [word_id], page_w * scale, page_h * scale)) break - - # progresso para TODA pagina (antes so avancava nas paginas com CPF detectado) if progress and (page_num % 10 == 0 or page_num == total): progress(page_num, total) From 6e54778dfc87a6c1043a2ce56b11083041d3b526 Mon Sep 17 00:00:00 2001 From: Joberth Firmino Gambati Date: Sun, 4 Oct 2026 14:35:44 -0400 Subject: [PATCH 2/3] =?UTF-8?q?SENTRY=20Detect:=20cat=C3=A1logo=20de=20PII?= =?UTF-8?q?=20com=20enquadramento=20legal=20(LGPD,=20GDPR,=20ISO=2029100,?= =?UTF-8?q?=20NIST,=20HIPAA),=20perfis=20de=20pol=C3=ADtica=20e=20novos=20?= =?UTF-8?q?detectores=20brasileiros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Joberth Firmino Gambati --- .env.example | 4 + AGENTS.md | 1 + BACKLOG.md | 4 +- CHANGELOG.md | 8 ++ README.en.md | 6 +- README.md | 27 +++- app_service.py | 12 ++ docs/MANUAL_DE_USO.md | 12 +- docs/README.md | 1 + docs/catalogo-pii.md | 58 +++++++++ docs/configuration.md | 8 ++ docs/limitations.md | 3 +- docs/threat-model-lgpd.md | 2 +- main.py | 86 +++++++++++- templates/index.html | 19 ++- tests/test_detect.py | 239 ++++++++++++++++++++++++++++++++++ tests/test_performance.py | 5 +- tests/ui/test_ui_brand.py | 2 +- tests/ui/test_ui_pii_types.py | 34 +++++ utils/detect/__init__.py | 16 +++ utils/detect/__main__.py | 31 +++++ utils/detect/catalog.py | 165 +++++++++++++++++++++++ utils/detect/profiles.py | 96 ++++++++++++++ utils/detect/rules.py | 159 ++++++++++++++++++++++ utils/detect/validators.py | 70 ++++++++++ 25 files changed, 1042 insertions(+), 26 deletions(-) create mode 100644 docs/catalogo-pii.md create mode 100644 tests/test_detect.py create mode 100644 tests/ui/test_ui_pii_types.py create mode 100644 utils/detect/__init__.py create mode 100644 utils/detect/__main__.py create mode 100644 utils/detect/catalog.py create mode 100644 utils/detect/profiles.py create mode 100644 utils/detect/rules.py create mode 100644 utils/detect/validators.py diff --git a/.env.example b/.env.example index 34b58b7..62dea0c 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ # CSRF). Loopback e APP_HOST sempre valem. "*" desliga a checagem (não recomendado). Ex.: sentry.intranet # ALLOWED_HOSTS= +# --- PII policy / Política de dados pessoais (docs/catalogo-pii.md) -------------------------------- +# cpf_endereco (default/padrão) | lgpd_publicacao | lgpd_interno | gdpr | saude_hipaa +# POLICY_PROFILE=cpf_endereco + # --- Local decision model / Decisor local (optional, docs/decisions.md) ------------------------ # Requires / Exige: pip install -e ".[laya]" # DECISION_ENGINE=off # laya | off diff --git a/AGENTS.md b/AGENTS.md index 79f6b8e..fe81742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ Navegador ─► gatekeeper.py (:8000, opcional) ─proxy─► app_service.py ( | `utils/session.py` | Pastas da tarefa, logs, exportação, reconstrução do PDF e tarja nativa (`apply_native_pdf_redactions`) | | `utils/verifier.py` | Verificação pós-tarja: relê o PDF final e confronta o original (`find_uncovered_cpfs`) | | `utils/pii.py` | Mascaramento de CPF em logs | +| `utils/detect/` | SENTRY Detect: catálogo de PII com enquadramento legal (`catalog.py`), perfis de política (`profiles.py`, `POLICY_PROFILE`), detectores por regra (`rules.py`) e dígitos verificadores (`validators.py`). Pacote isolado, com contrato JSON (API `/policy/*`), pronto para virar serviço. `docs/catalogo-pii.md` é gerado: `python -m utils.detect --markdown` | | `utils/decisions/` | Decisor local (Laya) e automelhoramento: perguntas, motor, regra de combinação, treino com portão de qualidade e versões. Guia: [docs/decisions.md](docs/decisions.md) | | `utils/auth.py` | Autenticação por `API_TOKEN` com sessão aleatória (app e gatekeeper): `?token=` só abre a sessão e sai da URL | | `utils/net_guard.py` | Checagem de `Host`/`Origin` (anti *DNS rebinding* e CSRF) usada pelo app e pelo gatekeeper; `ALLOWED_HOSTS` | diff --git a/BACKLOG.md b/BACKLOG.md index 9108648..13ca0b7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -52,8 +52,8 @@ Arquitetura: `detectar (regras + GLiNER + LLM) → decidir (Laya) → revisão | ID | Prioridade | Item | Depende de | |---|---|---|---| -| B-71 | P1 | **Política configurável**: o usuário escolhe os tipos (CPF, endereço, nome, telefone, e-mail, RG...) na interface e na CLI; arquivo de política versionado; registrar qual política foi aplicada em cada tarefa | — | -| B-72 | P1 | **Novos tipos por regras com validação**: RG, CNH, PIS/NIS, título de eleitor, telefone, e-mail, CEP, placa, cartão (dígito verificador quando houver), com testes e casos falsos positivos conhecidos | B-71 | +| B-71 | P1 | **Política configurável — parte 1 concluída (não publicado):** catálogo com enquadramento legal, 5 perfis via `POLICY_PROFILE`, rótulo do tipo em cada tarja, resumo por documento, API `/policy/*`. **Falta:** escolher o perfil na interface e por tarefa, e registrar a política aplicada num relatório exportável | — | +| B-72 | P1 | **Concluído (não publicado):** RG, CNH, título de eleitor, PIS/NIS, CNS, passaporte, CTPS, telefone, e-mail, IP, cartão (Luhn), conta bancária, chave Pix, data de nascimento e placa, com dígito verificador, formato ou contexto (`tests/test_detect.py`). **Falta:** medir revocação por tipo em documentos fictícios realistas e estender a verificação pós-tarja a esses tipos | B-71 | | B-73 | P1 | **Nomes e rótulos livres com GLiNER** (modo leve na CPU): o usuário escreve o rótulo ("nome de pessoa", "número de processo"); trechos mapeados para as caixas do OCR; confiança baixa → revisão | B-71 | | B-74 | P2 | **Exceções decididas pelo Laya**: "nome a proteger" × "servidor/signatário público" (LAI), com perguntas próprias, treino e portão | B-73, B-70 | | B-75 | P2 | **Perfis de política e roteamento**: perfis prontos (Transparência/LAI, Saúde, Jurídico, Só CPF) e o Laya sugerindo o perfil pelo tipo de documento | B-71, B-70 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8393069..e169564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Todas as mudanças relevantes ficam registradas aqui. Formato baseado no ## [Não publicado] +### Adicionado (critérios de dados pessoais) +- **Catálogo de PII** (`utils/detect/catalog.py`, documentação gerada em `docs/catalogo-pii.md`): 30 tipos com enquadramento em **LGPD** (art. 5º, I e II), **GDPR** (art. 4, 9 e 10), **ISO/IEC 29100**, **NIST SP 800-122** e **HIPAA Safe Harbor**, nível (identificador direto, dado pessoal, sensível, indireto) e forma de detecção. Tipos sem detector ficam como *planejados* (nomes, rostos, dados sensíveis). +- **Perfis de política** (`POLICY_PROFILE`): `cpf_endereco` (padrão, comportamento original), `lgpd_publicacao` (LAI), `lgpd_interno`, `gdpr` e `saude_hipaa`; cada tipo é tarjado (sugestão para revisão) ou só alertado (região a revisar). O endereço residencial também obedece ao perfil. +- **Novos detectores por regra** sobre as três leituras de cada página (OCR padrão, OCR esparso, texto digital): RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP. Cada regra exige dígito verificador oficial, formato distintivo ou palavra de contexto; a palavra de contexto nunca é tarjada. +- O editor mostra o **tipo** de cada tarja sugerida (ex.: "Possível Telefone"), e o cartão da tarefa mostra o **resumo dos tipos encontrados** (quantidades, nunca os valores). +- API só de leitura `GET /policy/catalog` e `GET /policy/profiles` (contrato de um futuro serviço SENTRY Detect). +- No documento real de teste, o perfil `lgpd_publicacao` passou a encontrar RG e telefone, antes não tarjados. + ### Alterado - **Mais rápido e mais preciso:** um documento real digitalizado de 10 páginas caiu de **~14 min para ~3 min** (OCR de ~510 s para ~19 s). - **`BASE_DPI` padrão: 1000 → 300.** Medido (`docs/benchmarks.md`): a 300 DPI a revocação de CPF foi de 100% contra ~60% a 1000 DPI (o Tesseract quebra os dígitos em pedaços em imagens grandes), e a verificação achou 16/16 vazamentos contra 11–13/16. No documento real, os CPFs achados a 1000 DPI foram todos achados a 300 DPI. **Atenção:** quem define `BASE_DPI` no `.env` deve revisar o valor (e `YOLO_CROP_PADDING`, que é em pixels). diff --git a/README.en.md b/README.en.md index 3e40b2f..5daad8f 100644 --- a/README.en.md +++ b/README.en.md @@ -38,14 +38,14 @@ PRIVIO SENTRY is a local-first project for detecting and protecting personal, se * Every output **must be reviewed by a person** before publication or sharing. * "Concluído" means *no pending alert was detected by the automatic checks*, not that the document is guaranteed clean. -* Only **CPF** and **personal addresses** are targeted. Names, phone numbers, e-mails, RG, bank data, photos, QR codes, metadata, etc. are **not** redacted. See [docs/limitations.md](docs/limitations.md) and [docs/threat-model-lgpd.md](docs/threat-model-lgpd.md). +* What gets redacted depends on the **policy profile** (`POLICY_PROFILE`). The default covers only **CPF** and **residential addresses**; the LGPD, GDPR and health profiles add RG, CNH, voter ID, PIS/NIS, SUS health card, passport, CTPS, phone, e-mail, bank data, Pix key, date of birth, plate and IP ([PII catalog](docs/catalogo-pii.md), in Portuguese). Names, parents' names, faces, sensitive data (health, religion...), QR codes and metadata are **not yet** detected. See [docs/limitations.md](docs/limitations.md) and [docs/threat-model-lgpd.md](docs/threat-model-lgpd.md). * Never use real documents to report bugs or in tests; see [SECURITY.md](SECURITY.md). ## LGPD positioning PRIVIO SENTRY is a **technical control that can support privacy and security practices**, including those related to Brazil's data-protection law (**LGPD**, Lei 13.709/2018). It is **not** an "LGPD compliance" engine, it is not legal advice, and using it does not by itself make any document or process compliant: that depends on purpose, legal basis, necessity, governance, data lifecycle and roles. Local processing is an architectural choice, not a legal conclusion; actual guarantees depend on your deployment (network configuration, logging, temporary files, model behavior). The UI label **LOCAL PROCESSING** describes where processing happens; it does not claim the machine is offline. -The LGPD separates *personal data* from *sensitive personal data* (health, biometric, genetic...). Today only CPF numbers and residential addresses (personal data) are targeted. See [docs/brand/LGPD_PRODUCT_POSITIONING.md](docs/brand/LGPD_PRODUCT_POSITIONING.md) and the official sources: [LGPD](https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm), [ANPD](https://www.gov.br/anpd/pt-br). +The LGPD separates *personal data* from *sensitive personal data* (health, biometric, genetic...). Sensitive categories are mapped in the PII catalog but not yet detected. See [docs/brand/LGPD_PRODUCT_POSITIONING.md](docs/brand/LGPD_PRODUCT_POSITIONING.md) and the official sources: [LGPD](https://www.planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm), [ANPD](https://www.gov.br/anpd/pt-br). ## What SENTRY Redact does today @@ -163,7 +163,7 @@ Brand, design and UX guidelines live in [docs/brand/](docs/brand/) (including th ## Limitations -* Only CPF and personal addresses; no names, phones, e-mails, faces, etc. +* Names, parents' names, faces and sensitive data are not yet detected; other types depend on the policy profile. * Recall of the detection and of the verification was only measured on synthetic data (see [docs/benchmarks.md](docs/benchmarks.md) when available); real-world scans can be worse. * Handwriting and low-quality scans are the weakest point. The signature detector has modest accuracy (see model card). * Address redaction depends on an LLM classification and on fuzzy matching: it can over- or under-redact. diff --git a/README.md b/README.md index 4e7c355..bb1fc1d 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,10 @@ visíveis; também pode tarjar mais do que o necessário. - Toda saída **deve ser revisada por uma pessoa** antes de ser publicada ou compartilhada. - **"Concluído"** significa *nenhum alerta pendente nas verificações automáticas*, e não que o documento esteja garantidamente limpo. -- Só **CPF** e **endereços pessoais** são alvo. Nomes, telefones, e-mails, RG, dados bancários, fotos, - QR codes, metadados etc. **não** são tarjados ([limitações](docs/limitations.md), +- O que é tarjado depende do **perfil de política** (`POLICY_PROFILE`). O padrão cobre só **CPF** e + **endereço residencial**; os perfis LGPD, GDPR e saúde acrescentam RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP + ([catálogo](docs/catalogo-pii.md)). **Ainda não** são detectados: nomes, filiação, rostos, dados sensíveis + (saúde, religião...), QR codes e metadados ([limitações](docs/limitations.md), [modelo de ameaças](docs/threat-model-lgpd.md)). - Nunca use documentos reais para relatar bugs ou em testes ([SECURITY.md](SECURITY.md)). @@ -146,6 +148,24 @@ flowchart LR --- +## 🧾 Critérios de dados pessoais + +O [**catálogo de PII**](docs/catalogo-pii.md) lista cada tipo de dado pessoal com o enquadramento em **LGPD** +(art. 5º, I e II), **GDPR** (art. 4, 9 e 10), **ISO/IEC 29100**, **NIST SP 800-122** e **HIPAA Safe Harbor**, o +nível (identificador direto, dado pessoal, dado sensível, identificador indireto) e como é detectado. Os +**perfis de política** dizem o que tarjar ou só alertar: + +| Perfil (`POLICY_PROFILE`) | Para quê | +|---|---| +| `cpf_endereco` (padrão) | Comportamento original: CPF e endereço residencial | +| `lgpd_publicacao` | Publicar documentos (LAI art. 31): documentos, contato, financeiro e nascimento tarjados; indiretos e sensíveis alertados | +| `lgpd_interno` | Circular internamente: documentos e financeiro tarjados; contato e sensíveis alertados | +| `gdpr` | Identificadores diretos e online (inclui IP) tarjados; categorias especiais alertadas | +| `saude_hipaa` | Identificadores da lista Safe Harbor que o projeto detecta, tarjados | + +Detectar e tarjar esses tipos **apoia** práticas alinhadas a essas referências; não torna um documento +"conforme". A API só de leitura `GET /policy/catalog` e `GET /policy/profiles` expõe catálogo e perfis. + ## 💻 Requisitos | Item | Versão | @@ -300,7 +320,8 @@ O trabalho pendente e as prioridades estão no [BACKLOG.md](BACKLOG.md). Diretri ## 🚧 Limitações -- Só CPF e endereços pessoais; sem nomes, telefones, e-mails, rostos etc. +- Nomes, filiação, rostos e dados sensíveis (saúde, religião...) ainda não são detectados; os demais tipos + dependem do perfil de política ([catálogo](docs/catalogo-pii.md)). - Revocação da detecção e da verificação medida **só em dados sintéticos**; digitalizações reais podem ser piores. - Manuscritos e digitalizações de baixa qualidade são o ponto mais fraco; o detector de assinaturas tem diff --git a/app_service.py b/app_service.py index f657cae..86dcb18 100644 --- a/app_service.py +++ b/app_service.py @@ -306,6 +306,18 @@ async def internal_update(task_id: str, request: Request, data: dict = Body(...) async def index(request: Request): return templates.TemplateResponse(request=request, name="index.html") +@app.get("/policy/catalog") +async def policy_catalog(): + """Catálogo de tipos de PII e enquadramento legal (sem nenhum dado pessoal).""" + from utils.detect import catalog + return {"tipos": [t.to_dict() for t in catalog.CATALOG]} + +@app.get("/policy/profiles") +async def policy_profiles(): + """Perfis de política e o perfil ativo (POLICY_PROFILE).""" + from utils.detect import profiles + return {"ativo": profiles.active_profile_id(), "perfis": profiles.describe_all()} + @app.get("/readme") async def readme(): path = os.path.join(BASE_DIR, "README.md") diff --git a/docs/MANUAL_DE_USO.md b/docs/MANUAL_DE_USO.md index f1726ff..5f9e365 100644 --- a/docs/MANUAL_DE_USO.md +++ b/docs/MANUAL_DE_USO.md @@ -64,9 +64,10 @@ processamento ocorre (arquitetura); ele não afirma que a máquina esteja descon ### 1.2 O que não é -- **Não** é uma ferramenta de anonimização completa. Só **CPF** e **endereços pessoais** são alvo. Nomes, RG, - telefones, e-mails, dados bancários, placas, fotos/rostos, QR codes, as próprias assinaturas, datas de - nascimento e metadados/anexos/anotações do PDF **não** são tarjados. +- **Não** é uma ferramenta de anonimização completa. O que é tarjado depende do perfil de política + (`POLICY_PROFILE`): o padrão cobre só **CPF** e **endereço residencial**; os perfis LGPD, GDPR e saúde + acrescentam RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP ([catálogo](catalogo-pii.md)). Nomes, filiação, fotos/rostos, dados sensíveis, QR + codes, as próprias assinaturas e metadados/anexos/anotações do PDF **ainda não** são tarjados. - **Não** é aconselhamento jurídico nem um motor de conformidade. - **Não** tem contas de usuário, TLS embutido nem trilha de auditoria (o único registro é o log da tarefa). - Está em **estágio inicial**: a acurácia foi medida apenas em dados sintéticos @@ -649,7 +650,7 @@ sem página valem para o documento inteiro. |---|---| | `WEB_INPUT/` (PDFs enviados) | Sim, os originais completos | | `output/` (pastas de trabalho) | Sim: imagens sem tarja, OCR, recortes, prompts/respostas da IA | -| `documentos_finais/` | Pode conter: só CPF e endereços pessoais são alvo; nomes e outros dados permanecem | +| `documentos_finais/` | Pode conter os tipos que o perfil não cobre (nomes, dados sensíveis, etc.) | | `tasks.json` | Nomes de arquivos e alertas (com CPFs mascarados) | | `process_log.log` | Registros de processamento (com CPFs mascarados) | @@ -871,7 +872,8 @@ Não. "Concluído" significa apenas que as verificações automáticas não deix humana faz parte do fluxo. **Quais dados são tarjados?** -Apenas números de CPF e endereços pessoais (residenciais). Nomes de pessoas são deliberadamente mantidos. +Depende do perfil (`POLICY_PROFILE`). O padrão: CPF e endereço residencial. Os perfis LGPD, GDPR e saúde +acrescentam RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP. Nomes ainda não são detectados (planejado). Veja o [catálogo](catalogo-pii.md). **Endereços profissionais são tarjados?** Não. O LLM classifica cada endereço como pessoal, profissional ou secundário, e só os pessoais recebem tarja diff --git a/docs/README.md b/docs/README.md index 8cb2685..f76edce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Ponto de partida para usar, operar e desenvolver o **SENTRY Redact**. Comece pel | Documento | Conteúdo | |---|---| | [Manual de uso](MANUAL_DE_USO.md) | Instalação passo a passo, editor web, linha de comando, resultados, solução de problemas e perguntas frequentes | +| [Catálogo de dados pessoais](catalogo-pii.md) | Tipos de PII, enquadramento (LGPD, GDPR, ISO/IEC 29100, NIST, HIPAA) e como cada um é detectado | | [Configuração](configuration.md) | Todas as variáveis de ambiente (`.env`), com valores padrão | | [Limitações](limitations.md) | O que a ferramenta **não** faz: leia antes de confiar nos resultados | diff --git a/docs/catalogo-pii.md b/docs/catalogo-pii.md new file mode 100644 index 0000000..229f2f7 --- /dev/null +++ b/docs/catalogo-pii.md @@ -0,0 +1,58 @@ +# Catálogo de dados pessoais (PII) + +> Gerado por `python -m utils.detect --markdown` a partir de `utils/detect/catalog.py`. Não edite à mão. +> +> O enquadramento indica a que categoria de cada referência o tipo corresponde. Detectar e tarjar esses +> tipos **apoia** práticas alinhadas a essas referências; **não** torna um documento "conforme". +> Itens *planejados* ainda não têm detector. + +## Identificador direto + +| Tipo | Estado | Detecção | LGPD | GDPR | ISO/IEC 29100 | NIST SP 800-122 | HIPAA Safe Harbor | +|---|---|---|---|---|---|---|---| +| **CPF** (`cpf`) | ativo | regra + dígito verificador (OCR, passada esparsa e texto digital) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) e art. 87 (número de identificação nacional) | PII | identificador pessoal (ex.: SSN) | outro número de identificação único | +| **RG (carteira de identidade)** (`rg`)
*Sem algoritmo nacional de dígito verificador: exige contexto.* | ativo | formato + palavra de contexto (RG, identidade) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) e art. 87 | PII | identificador pessoal | outro número de identificação único | +| **CNH (registro da habilitação)** (`cnh`) | ativo | formato + palavra de contexto (CNH, habilitação) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | identificador pessoal (carteira de motorista) | número de certificado/licença | +| **Título de eleitor** (`titulo_eleitor`) | ativo | regra + dígito verificador + contexto | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | identificador pessoal | outro número de identificação único | +| **PIS/PASEP/NIS/NIT** (`pis_nis`) | ativo | regra + dígito verificador + contexto | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | identificador pessoal | outro número de identificação único | +| **Cartão Nacional de Saúde (CNS/SUS)** (`cns`) | ativo | regra + dígito verificador | LGPD art. 5º, I (dado pessoal); associado a dado de saúde pode compor dado sensível | GDPR art. 4(1) | PII | identificador pessoal | número de beneficiário de plano de saúde | +| **Passaporte** (`passaporte`) | ativo | formato + palavra de contexto (passaporte) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | identificador pessoal (passaporte) | número de certificado/licença | +| **Carteira de Trabalho (CTPS)** (`ctps`) | ativo | formato + palavra de contexto (CTPS) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | identificador pessoal | outro número de identificação único | +| **Cartão de pagamento** (`cartao_pagamento`) | ativo | Luhn + formatação em grupos ou contexto | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | conta financeira / cartão de crédito | número de conta | +| **Agência e conta bancária** (`conta_bancaria`) | ativo | palavra de contexto (agência, conta, c/c) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | conta financeira | número de conta | +| **Chave Pix aleatória** (`chave_pix`)
*Chaves Pix de CPF, e-mail ou telefone já são cobertas pelos respectivos tipos.* | ativo | formato (UUID) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | conta financeira | número de conta | +| **Nome de pessoa** (`nome_pessoa`)
*Na LAI, nome de servidor no exercício da função costuma ser público: exceção decidida por perfil/Laya (B-74).* | planejado | GLiNER (reconhecimento de nomes) — backlog B-73 | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | nome | nomes | +| **Filiação (nome da mãe/pai)** (`filiacao`) | planejado | GLiNER + contexto — backlog B-73 | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | nome / informação vinculável | nomes (inclui parentes) | +| **Assinatura** (`assinatura`) | planejado | detector YOLO (hoje só localiza para auditar CPFs próximos) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | característica pessoal | — | +| **Foto de rosto** (`foto_rosto`) | planejado | detecção de rosto (planejado) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1); art. 9 se usado para identificação biométrica | PII | característica pessoal (foto) | foto de rosto inteiro e imagens comparáveis | + +## Dado pessoal + +| Tipo | Estado | Detecção | LGPD | GDPR | ISO/IEC 29100 | NIST SP 800-122 | HIPAA Safe Harbor | +|---|---|---|---|---|---|---|---| +| **Endereço residencial** (`endereco_residencial`) | ativo | LLM de visão + casamento com o OCR (+ decisor Laya) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) (dado de localização) | PII | endereço | subdivisão geográfica menor que o estado | +| **Telefone** (`telefone`)
*Telefones institucionais também são marcados: o revisor decide.* | ativo | formato brasileiro (DDD) ou palavra de contexto | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | número de telefone | número de telefone/fax | +| **E-mail** (`email`)
*E-mails institucionais também são marcados: o revisor decide.* | ativo | formato | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) (identificador online) | PII | endereço de e-mail | endereço de e-mail | + +## Dado pessoal sensível + +| Tipo | Estado | Detecção | LGPD | GDPR | ISO/IEC 29100 | NIST SP 800-122 | HIPAA Safe Harbor | +|---|---|---|---|---|---|---|---| +| **Dado de saúde (diagnóstico, CID, tratamento)** (`saude`) | planejado | LLM/Laya sobre o contexto, sempre com revisão humana | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 (dados de saúde) | PII sensível | informação médica | número de prontuário e informação de saúde associada | +| **Dado biométrico ou genético** (`biometrico_genetico`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | característica pessoal (biometria) | identificador biométrico | +| **Convicção religiosa** (`religiao`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | informação vinculável | — | +| **Opinião política / filiação partidária** (`opiniao_politica`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | informação vinculável | — | +| **Filiação a sindicato** (`filiacao_sindical`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | informação vinculável | — | +| **Origem racial ou étnica** (`origem_etnica`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | informação vinculável | — | +| **Vida sexual / orientação sexual** (`vida_sexual`) | planejado | LLM/Laya + revisão | LGPD art. 5º, II (dado pessoal sensível) | GDPR art. 9 | PII sensível | informação vinculável | — | +| **Condenações e infrações penais** (`antecedentes_criminais`) | planejado | LLM/Laya + revisão | LGPD: não listado no art. 5º, II; o tratamento para persecução penal fica fora da LGPD e segue lei específica (art. 4º, III) | GDPR art. 10 | PII sensível | informação vinculável | — | + +## Identificador indireto + +| Tipo | Estado | Detecção | LGPD | GDPR | ISO/IEC 29100 | NIST SP 800-122 | HIPAA Safe Harbor | +|---|---|---|---|---|---|---|---| +| **Endereço IP** (`ip`) | ativo | formato IPv4 válido | LGPD art. 5º, I (dado pessoal) (quando vinculado a pessoa) | GDPR art. 4(1) (identificador online) | PII | informação de ativo (IP) | endereço IP | +| **Coordenadas geográficas** (`geolocalizacao`) | planejado | formato lat/long (planejado) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) (dado de localização) | PII | informação vinculável | subdivisão geográfica | +| **Data de nascimento** (`data_nascimento`) | ativo | data + palavra de contexto (nascido, nascimento) | LGPD art. 5º, I (dado pessoal) | GDPR art. 4(1) | PII | informação vinculável (data de nascimento) | datas relacionadas à pessoa (exceto ano) | +| **Placa de veículo** (`placa_veiculo`) | ativo | formato antigo ou Mercosul | LGPD art. 5º, I (dado pessoal) (quando vinculada a pessoa) | GDPR art. 4(1) | PII | bem de propriedade pessoal (registro de veículo) | identificador de veículo (inclui placa) | + diff --git a/docs/configuration.md b/docs/configuration.md index f5130c5..800c819 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,6 +69,14 @@ Ambas são lidas em `utils/session.py` e `app_service.py`. | `FINAL_JPEG_QUALITY` | `75` | Qualidade JPEG (1-95; valores fora do intervalo voltam ao padrão) dessas páginas. | | `RETENTION_DAYS` | `0` (desativado) | Na inicialização, exclui tarefas (e todos os seus artefatos) com mais de N dias; `0`/não definido mantém tudo. `POST /purge/{id}` remove sob demanda as imagens originais das páginas de uma tarefa. Sempre exclua `output/` quando terminar: ele contém imagens das páginas e texto de OCR com dados pessoais. | +## Política de dados pessoais + +Detalhes no [catálogo de PII](catalogo-pii.md). + +| Variável | Padrão | Lida em | Descrição | +|---|---|---|---| +| `POLICY_PROFILE` | `cpf_endereco` | `utils/detect/profiles.py` | Perfil de política: `cpf_endereco` (original), `lgpd_publicacao`, `lgpd_interno`, `gdpr`, `saude_hipaa`. Define o que é tarjado e o que só é alertado. Valor desconhecido volta ao padrão. | + ## Decisor local e automelhoramento (opcional) Detalhes em [decisions.md](decisions.md). Exige `pip install -e ".[laya]"`. diff --git a/docs/limitations.md b/docs/limitations.md index e43f866..316c07c 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -3,7 +3,8 @@ Seja explícito sobre o que esta ferramenta não consegue fazer. Sempre combine o uso com revisão humana. O PRIVIO SENTRY / SENTRY Redact está em **estágio inicial**: a IA **sugere**, uma pessoa **confirma**. ## Escopo da tarja -* **Apenas números de CPF e endereços pessoais (residenciais)** são alvo. Não são tarjados: nomes, RG e outros documentos de identificação, telefones, e-mails, dados bancários/financeiros, placas de veículos, fotografias/rostos, QR codes/códigos de barras, as próprias assinaturas (apenas CPFs próximos a elas), datas de nascimento, metadados/anexos/anotações/marcadores do PDF (inspecione esses itens separadamente). +* **O que é tarjado depende do perfil de política** (`POLICY_PROFILE`, ver [catálogo](catalogo-pii.md)). O padrão (`cpf_endereco`) cobre só CPF e endereço residencial; os perfis LGPD, GDPR e saúde acrescentam RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP, detectados por regras (dígito verificador, formato e palavra de contexto). **Não são detectados em nenhum perfil:** nomes, filiação, fotografias/rostos, dados sensíveis (saúde, religião, opinião política...), QR codes/códigos de barras, as próprias assinaturas (apenas CPFs próximos a elas) e metadados/anexos/anotações/marcadores do PDF (inspecione esses itens separadamente). +* Os detectores por regra só foram testados com valores gerados (sem medida de revocação em documentos reais). A verificação pós-tarja confere só CPF: os outros tipos dependem da revisão humana. * Nomes de pessoas deliberadamente **não** são tarjados (uma regra de negócio do caso de uso original), o que pode tornar um indivíduo identificável mesmo sem CPF/endereço. ## Qualidade da detecção diff --git a/docs/threat-model-lgpd.md b/docs/threat-model-lgpd.md index fcfc66e..827ff8d 100644 --- a/docs/threat-model-lgpd.md +++ b/docs/threat-model-lgpd.md @@ -37,6 +37,6 @@ A ferramenta ajuda a remover **dados pessoais (CPF, endereços residenciais)** d ## Considerações sobre a LGPD (não exaustivas) * A anonimização sob a LGPD (art. 12) exige que os dados **não possam ser reidentificados por meios razoáveis**; uma tarja visual com nomes, contexto ou metadados remanescentes ainda pode permitir a reidentificação. Avalie caso a caso. -* A LGPD distingue *dados pessoais* de *dados pessoais sensíveis* (saúde, biométricos, genéticos etc.). Esta ferramenta atualmente tem como alvo apenas números de CPF e endereços residenciais (dados pessoais); ela não detecta categorias sensíveis. +* A LGPD distingue *dados pessoais* de *dados pessoais sensíveis* (saúde, biométricos, genéticos etc.). As categorias sensíveis estão mapeadas no [catálogo](catalogo-pii.md), mas ainda **não** são detectadas; os dados pessoais cobertos dependem do perfil de política. * Mantenha uma **política de retenção** para os artefatos (apague `output/`; veja `RETENTION_DAYS` em [configuration.md](configuration.md)). * O operador continua sendo o **controlador/operador** dos dados e é responsável pela base legal, pelos registros das operações de tratamento e pelo tratamento de incidentes. Este projeto não oferece nenhuma garantia (veja LICENSE, seção 7). diff --git a/main.py b/main.py index 78fd8a0..03f6f56 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from utils.lexicon import is_immune from utils.pii import mask_text from utils import decisions +from utils import detect as pii_detect from utils.decisions import feedback as decision_feedback from utils.decisions.questions import normalize_state @@ -60,6 +61,9 @@ def __init__(self, pdf_path, output_dir=None, final_dir=None): self.review_marks = [] # regiões a revisar: {"page", "reason", "box": [x0, y0, x1, y1] relativos} self.timings = {} # segundos por etapa (para acompanhar o desempenho) self.grounding_maps_native = [] # palavras do texto digital do PDF (passada extra de CPF), por página + self.policy_profile = pii_detect.active_profile_id() # perfil de política (utils/detect/profiles.py) + self.pii_summary = {} # tipo do catálogo -> quantidade de valores distintos achados (sem os valores) + self.box_labels = defaultdict(dict) # página -> {assinatura da caixa: rótulo do tipo} (mostrado no editor) def add_review(self, page_num, reason, boxes=None): """Marca uma página como 'requer revisão' e registra o alerta (sem duplicar). @@ -105,9 +109,13 @@ def final_state(self): "alerts": self.alerts, "review_marks": self.review_marks, "timings": self.timings, + "policy_profile": self.policy_profile, + "pii_summary": self.pii_summary, + "pii_found": self.pii_found(), } return {"status": "Concluído", "percentage": 100, "completed": True, "needs_review": False, - "alerts": self.alerts, "review_marks": self.review_marks, "timings": self.timings} + "alerts": self.alerts, "review_marks": self.review_marks, "timings": self.timings, + "policy_profile": self.policy_profile, "pii_summary": self.pii_summary, "pii_found": self.pii_found()} def run(self, progress_callback=None, manual_redactions=None, native_mode=False): """Executa o pipeline completo com suporte a notificações de progresso.""" @@ -134,6 +142,7 @@ def run(self, progress_callback=None, manual_redactions=None, native_mode=False) (self.run_phase_0, "Renderizando PDF", 10), (self.run_phase_1, "OCR Scanning", 20), (self.run_phase_2, "Busca de CPFs", 30), + (self.run_policy_phase, "Outros dados pessoais (perfil)", 35), (self.run_phase_3, "Busca Visual (YOLO)", 40), (self.run_phase_4, "Micro-Auditoria", 50), (self.run_address_discovery, "Análise Semântica (IA)", 65), @@ -286,8 +295,54 @@ def run_phase_2(self): self.session.save_detected_cpfs(all_found_cpfs) self.known_cpfs = list(all_found_cpfs) + if all_found_cpfs: + self.pii_summary["cpf"] = len(all_found_cpfs) self.logger.info(f"[+] Fase 2 concluída: {len(all_found_cpfs)} CPFs identificados.") + def pii_found(self): + """Resumo legível por tipo do catálogo: nome, nível e quantidade (nunca os valores).""" + out = [] + for type_id, count in self.pii_summary.items(): + t = pii_detect.catalog.BY_ID.get(type_id) + if t: + out.append({"id": type_id, "nome": t.nome, "nivel": t.nivel, "quantidade": count}) + return out + + def run_policy_phase(self): + """ + Demais tipos de PII do perfil ativo (SENTRY Detect): regras sobre as três leituras de cada página + (OCR padrão, OCR esparso e texto digital). "tarjar" vira tarja sugerida; "alertar" marca a região. + CPF e endereço residencial têm fases próprias. + """ + actions = {t: a for t, a in pii_detect.runnable_actions(self.policy_profile).items() + if t not in ("cpf", "endereco_residencial")} + self.logger.info(f"--- POLÍTICA: perfil '{self.policy_profile}' ({len(actions)} tipo(s) com detector) ---") + if not actions: + return + found_values = defaultdict(set) + for i in range(len(self.grounding_maps)): + page_num = i + 1 + maps = [m for m in (self.grounding_maps[i], + self.grounding_maps_sparse[i] if i < len(self.grounding_maps_sparse) else [], + self.grounding_maps_native[i] if i < len(self.grounding_maps_native) else []) if m] + for gmap in maps: + for type_id, (commands, values) in pii_detect.find_in_grounding(gmap, list(actions)).items(): + found_values[type_id] |= {f"{page_num}:{v}" for v in values} + boxes = self.session.get_redaction_boxes(gmap, commands) + label = pii_detect.catalog.get(type_id).nome + if actions[type_id] == pii_detect.TARJAR: + for b in boxes: + self.global_redactions[page_num].append(b) + self.box_labels[page_num][f"{b['x']}_{b['y']}_{b['w']}_{b['h']}"] = label + else: + self.add_review(page_num, f"{label}: possível dado pessoal (perfil manda alertar). Revisar.", + self._relative_boxes(page_num, [[b["x"], b["y"], b["w"], b["h"]] for b in boxes])) + for type_id, vals in found_values.items(): + self.pii_summary[type_id] = len(vals) + if found_values: + resumo = ", ".join(f"{pii_detect.catalog.get(t).nome}: {n}" for t, n in self.pii_summary.items() if t in actions) + self.logger.info(f"[+] Política: {resumo}.") + def run_phase_3(self): """Fase 3: Mapeador Visual YOLO (02_DISCOVERY & CROPPING)""" self.logger.info("--- PHASE 3: YOLO DISCOVERY ---") @@ -470,8 +525,28 @@ def run_phase_6(self): self.address_redactions[page_num].append(box) except: continue + self._apply_address_policy() self.logger.info("[+] Fase 6 concluída.") + def _apply_address_policy(self): + """Endereço residencial segue o perfil: tarjar (padrão), alertar (vira região a revisar) ou ignorar.""" + action = pii_detect.runnable_actions(self.policy_profile).get("endereco_residencial") + found = {p: list(b) for p, b in self.address_redactions.items() if b} + # Conta ENDEREÇOS (não as palavras tarjadas) das páginas onde algum foi localizado + n_addresses = sum(1 for page, addrs in self.address_redactor.results.items() if page in found + for a in addrs if a.get("type") == "pessoal") + if found: + self.pii_summary["endereco_residencial"] = n_addresses or len(found) + if action == pii_detect.TARJAR: + return + for page_num, boxes in found.items(): + ids = {id(b) for b in boxes} + self.global_redactions[page_num] = [b for b in self.global_redactions[page_num] if id(b) not in ids] + if action == pii_detect.ALERTAR: + self.add_review(page_num, "Endereço residencial: possível dado pessoal (perfil manda alertar). Revisar.", + self._relative_boxes(page_num, [[b["x"], b["y"], b["w"], b["h"]] for b in boxes])) + self.address_redactions = defaultdict(list) + def run_signature_audit(self): """Fase 7: Auditoria de Assinaturas (IA Visual) para verificar CPFs não tarjados.""" self.logger.info("--- PHASE 7: SIGNATURE AUDIT (VISION AI) ---") @@ -662,15 +737,16 @@ def run_phase_5(self): box_sig = f"{bx}_{by}_{bw}_{bh}" if box_sig in addr_boxes: - rtype = 'pii' + rtype, label = 'pii', "Endereço residencial" elif box_sig in cpf_boxes: - rtype = 'signature' + rtype, label = 'signature', "CPF" else: - rtype = 'pii' - + rtype, label = 'pii', self.box_labels.get(page_num, {}).get(box_sig) + frontend_redactions.append({ "page": page_num, "type": rtype, + "label": label, "coords": [bx, by, bx + bw, by + bh], "source": "AI_Engine", "image_width": orig_w, diff --git a/templates/index.html b/templates/index.html index ba9012e..f43f2a1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -146,6 +146,8 @@ @media (prefers-reduced-motion: reduce) { .review-mark.flash { animation: none; outline: 4px solid var(--warning-strong); } } .redaction-box { position:absolute; background:rgba(11,18,32,0.45); border:2px solid var(--primary); pointer-events:auto; z-index:2; } .redaction-box.type-signature { border-color: var(--teal); background: rgba(20, 184, 166, 0.40); } + .redaction-box.type-labelled::before { content: attr(data-label); position: absolute; top: -17px; left: 0; font-size: 10px; font-weight:700; background: var(--primary); color: var(--white); padding: 1px 5px; border-radius: 3px; white-space:nowrap; } + .task-pii { font-size:12px; color:var(--slate); margin-top:2px; cursor:help; } .redaction-box.type-signature::before { content: attr(data-label); position: absolute; top: -17px; left: 0; font-size: 10px; font-weight:700; background: var(--teal-strong); color: var(--white); padding: 1px 5px; border-radius: 3px; white-space:nowrap; } .redaction-box.selected { border:3px solid var(--white); box-shadow:0 0 0 2px var(--primary), 0 0 14px rgba(37,99,235,0.6); z-index:10; } @@ -404,7 +406,7 @@

Log de auditoria da tarefa

'task.actions': 'Ações de {file}', 'task.reprocess': 'Reprocessar', 'task.logs': 'Ver logs', 'task.delete': 'Excluir', 'task.view': 'Ver', 'task.download': 'Baixar PDF', 'task.review': 'Requer revisão', 'task.complete': 'Proteção concluída · Processado localmente', - 'region.possible': 'Possível CPF ou endereço — verifique antes de proteger', + 'region.possible': 'Possível dado pessoal — verifique antes de proteger', 'region.possibleType': 'Possível {type} — verifique antes de proteger', 'pii.found': 'Dados encontrados', 'region.signature': 'Possível assinatura — verifique antes de proteger', 'region.manual': 'Região adicionada manualmente', 'region.signatureLabel': 'POSSÍVEL ASSINATURA', 'region.manualSignatureLabel': 'ASSINATURA', @@ -451,7 +453,7 @@

Log de auditoria da tarefa

'task.actions': 'Actions for {file}', 'task.reprocess': 'Reprocess', 'task.logs': 'View logs', 'task.delete': 'Delete', 'task.view': 'View', 'task.download': 'Download PDF', 'task.review': 'Needs review', 'task.complete': 'Protection complete · Processed locally', - 'region.possible': 'Potential CPF or address — verify before protecting', + 'region.possible': 'Potential personal data — verify before protecting', 'region.possibleType': 'Potential {type} — verify before protecting', 'pii.found': 'Data found', 'region.signature': 'Potential signature — verify before protecting', 'region.manual': 'Manually added region', 'region.signatureLabel': 'POTENTIAL SIGNATURE', 'region.manualSignatureLabel': 'SIGNATURE', @@ -709,6 +711,13 @@

Log de auditoria da tarefa

} const dateStr = tk.created_at ? new Date(tk.created_at).toLocaleDateString(lang, {hour: '2-digit', minute:'2-digit'}) : ''; + // Tipos de dado pessoal encontrados (catálogo), sem os valores + let piiHtml = ''; + if (isDone && Array.isArray(tk.pii_found) && tk.pii_found.length) { + const items = tk.pii_found.map(p => `${String(p.nome)} ${Number(p.quantidade) || 0}`); + const detail = tk.pii_found.map(p => `${String(p.nome)} (${String(p.nivel)}): ${Number(p.quantidade) || 0}`).join('\n'); + piiHtml = `
🔎 ${esc(items.join(' · '))}
`; + } // Tempo por etapa (desempenho): total no cartão, detalhe ao passar o mouse let timeHtml = ''; if (isDone && tk.timings && typeof tk.timings === 'object') { @@ -743,6 +752,7 @@

Log de auditoria da tarefa

${esc(tk.status)} ${!isDone ? '('+esc(tk.percentage)+'%)' : ''}${dateStr}
${badgeHtml} ${completeHtml} + ${piiHtml} ${timeHtml} ${downloadHtml} `; @@ -966,10 +976,13 @@

Log de auditoria da tarefa

box.dataset.testid = 'redaction-box'; box.setAttribute('data-testid', 'redaction-box'); // A IA SUGERE: rótulo de possível detecção (nunca só por cor) - const label = isSignature ? (isManual ? t('region.signature') : t('region.signature')) : (isManual ? t('region.manual') : t('region.possible')); + const typed = !isSignature && !isManual && r.label; // tipo do catálogo (ex.: Telefone, RG) + const label = isSignature ? t('region.signature') : (isManual ? t('region.manual') + : (typed ? t('region.possibleType', {type: String(r.label)}) : t('region.possible'))); box.title = label; box.setAttribute('aria-label', label); if (isSignature) box.dataset.label = isManual ? t('region.manualSignatureLabel') : t('region.signatureLabel'); + if (typed) { box.classList.add('type-labelled'); box.dataset.label = String(r.label); } box.style.left = (r.coords[0] * sX) + 'px'; box.style.top = (r.coords[1] * sY) + 'px'; box.style.width = ((r.coords[2] - r.coords[0]) * sX) + 'px'; box.style.height = ((r.coords[3] - r.coords[1]) * sY) + 'px'; diff --git a/tests/test_detect.py b/tests/test_detect.py new file mode 100644 index 0000000..624dbd3 --- /dev/null +++ b/tests/test_detect.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""SENTRY Detect: catálogo, perfis, validadores e detectores por regra (números de teste GERADOS aqui).""" +import os +import random + +import pytest + +from sentry_testkit import word +from utils.detect import catalog, profiles, rules +from utils.detect import validators as v + +RNG = random.Random(2026) + + +# --- geradores de números válidos (só para teste) ------------------------------------------------------ +def make_pis(): + base = [RNG.randint(0, 9) for _ in range(10)] + dv = 11 - sum(a * w for a, w in zip(base, (3, 2, 9, 8, 7, 6, 5, 4, 3, 2))) % 11 + return "".join(map(str, base)) + str(0 if dv >= 10 else dv) + + +def make_cns(): + while True: + d = [1] + [RNG.randint(0, 9) for _ in range(14)] + if sum(a * w for a, w in zip(d, range(15, 0, -1))) % 11 == 0: + return "".join(map(str, d)) + + +def make_titulo(uf="11"): + seq = [RNG.randint(0, 9) for _ in range(8)] + dv1 = sum(a * w for a, w in zip(seq, range(2, 10))) % 11 + dv1 = 0 if dv1 == 10 else dv1 + dv2 = (int(uf[0]) * 7 + int(uf[1]) * 8 + dv1 * 9) % 11 + dv2 = 0 if dv2 == 10 else dv2 + return "".join(map(str, seq)) + uf + f"{dv1}{dv2}" + + +def make_card(): + d = [4] + [RNG.randint(0, 9) for _ in range(14)] + for check in range(10): + n = "".join(map(str, d)) + str(check) + if v.is_valid_luhn(n): + return n + + +def mutate(number): + last = (int(number[-1]) + 1) % 10 + return number[:-1] + str(last) + + +# --- catálogo e perfis ------------------------------------------------------------------------------------ +def test_catalog_is_consistent(): + ids = [t.id for t in catalog.CATALOG] + assert len(ids) == len(set(ids)) + for t in catalog.CATALOG: + assert t.nivel in catalog.LEVELS and t.estado in (catalog.ATIVO, catalog.PLANEJADO) + assert t.lgpd and t.gdpr and t.nist and t.hipaa and t.iso29100 + if t.nivel == catalog.SENSIVEL: + assert "art. 5º, II" in t.lgpd or "art. 4º" in t.lgpd + assert "art. 9" in t.gdpr or "art. 10" in t.gdpr + + +def test_every_active_rule_type_has_a_detector_and_vice_versa(): + with_own_phase = {"cpf", "endereco_residencial"} + active = set(catalog.active_ids()) - with_own_phase + assert active == set(rules.RULES_BY_TYPE) + + +def test_profiles_reference_only_catalog_types_and_always_protect_cpf(): + for pid, p in profiles.PROFILES.items(): + assert set(p["acoes"]) <= set(catalog.BY_ID), pid + assert p["acoes"].get("cpf") == profiles.TARJAR, pid + assert set(p["acoes"].values()) <= {profiles.TARJAR, profiles.ALERTAR} + + +def test_default_profile_keeps_original_behavior(monkeypatch): + monkeypatch.delenv("POLICY_PROFILE", raising=False) + assert profiles.active_profile_id() == "cpf_endereco" + assert profiles.runnable_actions() == {"cpf": "tarjar", "endereco_residencial": "tarjar"} + monkeypatch.setenv("POLICY_PROFILE", "inexistente") + assert profiles.active_profile_id() == "cpf_endereco" + + +def test_planned_types_are_not_run_but_stay_in_profile(monkeypatch): + monkeypatch.setenv("POLICY_PROFILE", "lgpd_publicacao") + run = profiles.runnable_actions() + assert "nome_pessoa" not in run and "telefone" in run + assert profiles.describe_all()["lgpd_publicacao"]["acoes"]["nome_pessoa"]["estado"] == "planejado" + + +def test_generated_catalog_doc_is_in_sync(): + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "catalogo-pii.md") + assert open(path, encoding="utf-8").read().strip() == catalog.to_markdown().strip(), \ + "rode: python -m utils.detect --markdown" + + +# --- validadores ------------------------------------------------------------------------------------------ +@pytest.mark.parametrize("make, check", [(make_pis, v.is_valid_pis), (make_cns, v.is_valid_cns), + (make_titulo, v.is_valid_titulo_eleitor), (make_card, v.is_valid_luhn)]) +def test_validators_accept_generated_and_reject_mutated(make, check): + for _ in range(20): + n = make() + assert check(n), n + assert not check(mutate(n)), mutate(n) + + +def test_validators_reject_degenerate_numbers(): + assert not v.is_valid_pis("00000000000") + assert not v.is_valid_cns("000000000000000") + assert not v.is_valid_titulo_eleitor("123456782901") # UF 29 não existe + assert not v.is_valid_luhn("1111111111111111") + assert v.is_valid_ipv4("200.17.10.4") and not v.is_valid_ipv4("999.1.1.1") and not v.is_valid_ipv4("127.0.0.1") + + +# --- detectores ---------------------------------------------------------------------------------------------- +def grounding(*texts): + return [word(i, t, 100 * i) for i, t in enumerate(texts)] + + +def found(gmap, type_id): + res = rules.find_in_grounding(gmap, [type_id]) + return res.get(type_id) + + +def redacted_words(gmap, type_id): + res = found(gmap, type_id) + return sorted(res[0]) if res else [] + + +def test_context_word_is_never_redacted_only_the_value(): + pis = make_pis() + gmap = grounding("PIS:", pis[:3] + "." + pis[3:8] + "." + pis[8:10] + "-" + pis[10]) + assert redacted_words(gmap, "pis_nis") == [1] + + +@pytest.mark.parametrize("type_id, texts, expected_words", [ + ("rg", ("RG", "12.345.678-9", "SSP/MT"), [1]), + ("rg", ("protocolo", "12.345.678-9"), []), # sem contexto: não é RG + ("cnh", ("CNH", "nº", "98765432199"), [2]), # número inventado e que NÃO é CPF válido + ("passaporte", ("Passaporte", "FT123456"), [1]), + ("ctps", ("CTPS", "1234567", "série", "0012"), [1, 2, 3]), + ("telefone", ("Tel.:", "(65)", "99999-1234"), [1, 2]), + ("telefone", ("protocolo", "6599991234"), []), # sem formato nem contexto + ("email", ("contato:", "fulano.teste@example.org"), [1]), + ("chave_pix", ("chave", "123e4567-e89b-42d3-a456-426614174000"), [1]), + ("data_nascimento", ("nascido", "em", "01/02/1980"), [2]), + ("data_nascimento", ("assinado", "em", "01/02/1980"), []), # data qualquer não é nascimento + ("placa_veiculo", ("veículo", "placa", "ABC1D23"), [2]), + ("placa_veiculo", ("norma", "ISO", "9001"), []), + ("conta_bancaria", ("agência", "1234", "conta", "56789-0"), [1, 3]), + ("conta_bancaria", ("prestação", "de", "contas", "2024"), []), + ("ip", ("acesso", "do", "IP", "200.17.10.4"), [3]), +]) +def test_rules(type_id, texts, expected_words): + assert redacted_words(grounding(*texts), type_id) == expected_words + + +def test_checksum_rules_with_generated_numbers(): + cns = make_cns() + spaced = f"{cns[:3]} {cns[3:7]} {cns[7:11]} {cns[11:]}" + assert redacted_words(grounding("Cartão", "SUS", *spaced.split()), "cns") == [2, 3, 4, 5] + assert redacted_words(grounding("Cartão", "SUS", mutate(cns)), "cns") == [] + titulo = make_titulo() + assert redacted_words(grounding("Título", "de", "eleitor", titulo), "titulo_eleitor") == [3] + card = make_card() + grouped = [card[i:i + 4] for i in range(0, 16, 4)] + assert redacted_words(grounding("cartão", *grouped), "cartao_pagamento") == [1, 2, 3, 4] + assert redacted_words(grounding("cartão", mutate(card)), "cartao_pagamento") == [] + + +def test_value_split_across_words_maps_to_char_indices(): + gmap = grounding("Tel", "(65)", "99999-1234", "fim") + commands, values = found(gmap, "telefone") + assert commands[1] == set(range(4)) and commands[2] == set(range(10)) + assert values == {"(65)99999-1234"} + + +# --- integração com o pipeline e com a API ----------------------------------------------------------------- +@pytest.fixture() +def app(tmp_path, monkeypatch): + from main import SentryApp + from sentry_testkit import make_text_pdf + monkeypatch.setenv("BASE_DPI", "40") + a = SentryApp(make_text_pdf(tmp_path / "doc.pdf")) + a.run_phase_0() + a.grounding_maps = [grounding("Tel.:", "(65)", "99999-1234", "placa", "ABC1D23")] + a.grounding_maps_sparse = [[]] + a.grounding_maps_native = [[]] + yield a + a.session.close() + + +def test_policy_phase_redacts_and_labels_by_profile(app, monkeypatch): + monkeypatch.setenv("POLICY_PROFILE", "lgpd_publicacao") + app.policy_profile = "lgpd_publicacao" + app.run_policy_phase() + assert len(app.global_redactions[1]) == 2 # telefone (2 palavras) tarjado + assert set(app.box_labels[1].values()) == {"Telefone"} + assert app.pii_summary == {"telefone": 1, "placa_veiculo": 1} + assert app.needs_review and any(m["reason"].startswith("Placa de veículo") for m in app.review_marks) # alertar + + +def test_default_profile_runs_no_extra_types(app): + app.run_policy_phase() + assert not app.global_redactions[1] and app.pii_summary == {} + + +def test_address_follows_profile(app): + box = {"x": 1, "y": 2, "w": 3, "h": 4} + app.address_redactions[1].append(box) + app.global_redactions[1].append(box) + app.address_redactor.results = {1: [{"text": "Rua A, casa 1", "type": "pessoal"}, {"text": "Av B", "type": "profissional"}]} + app.policy_profile = "lgpd_interno" # endereço: alertar + app._apply_address_policy() + assert box not in app.global_redactions[1] and app.needs_review + assert app.pii_summary["endereco_residencial"] == 1 + + +def test_labels_reach_the_editor_metadata(app, monkeypatch): + import json + import os as _os + app.policy_profile = "lgpd_publicacao" + app.run_policy_phase() + app.run_phase_5() + meta = json.load(open(_os.path.join(app.session.output_dir, "redactions_metadata.json"), encoding="utf-8")) + assert {m.get("label") for m in meta} == {"Telefone"} + + +def test_policy_api_is_read_only_and_has_no_personal_data(monkeypatch): + import importlib + from fastapi.testclient import TestClient + import app_service + importlib.reload(app_service) + c = TestClient(app_service.app) + cat = c.get("/policy/catalog").json() + assert {t["id"] for t in cat["tipos"]} == set(catalog.BY_ID) + prof = c.get("/policy/profiles").json() + assert prof["ativo"] == "cpf_endereco" and "lgpd_publicacao" in prof["perfis"] + assert c.post("/policy/catalog").status_code == 405 diff --git a/tests/test_performance.py b/tests/test_performance.py index 605accd..80c75d6 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -91,12 +91,13 @@ def get_grounding_map(self, img_path, psm=3): def test_timings_are_recorded_per_phase(tmp_path, monkeypatch): monkeypatch.setenv("BASE_DPI", "40") app = SentryApp(make_text_pdf(tmp_path / "t.pdf")) - for name in ("run_phase_0", "run_phase_1", "run_phase_2", "run_phase_3", "run_phase_4", "run_address_discovery", + for name in ("run_phase_0", "run_phase_1", "run_phase_2", "run_policy_phase", "run_phase_3", "run_phase_4", + "run_address_discovery", "run_phase_6", "run_signature_audit", "run_phase_5", "run_verification"): monkeypatch.setattr(app, name, lambda: time.sleep(0.01)) app.run() timings = app.final_state()["timings"] - assert len(timings) == 10 and all(v >= 0 for v in timings.values()) + assert len(timings) == 11 and all(v >= 0 for v in timings.values()) app.session.close() diff --git a/tests/ui/test_ui_brand.py b/tests/ui/test_ui_brand.py index d8aa51c..afe4fce 100644 --- a/tests/ui/test_ui_brand.py +++ b/tests/ui/test_ui_brand.py @@ -103,7 +103,7 @@ def test_detections_are_labelled_as_suggestions(page, live_app, open_task): open_task(tid) titles = page.eval_on_selector_all("[data-testid=redaction-box]", "els => els.map(e => e.title)") assert len(titles) == 3 - assert any("Possível CPF ou endereço — verifique antes de proteger" == x for x in titles) + assert any("Possível dado pessoal — verifique antes de proteger" == x for x in titles) assert any("Possível assinatura — verifique antes de proteger" == x for x in titles) assert any("manualmente" in x for x in titles) # assinatura tem rótulo textual (não depende só de cor) diff --git a/tests/ui/test_ui_pii_types.py b/tests/ui/test_ui_pii_types.py new file mode 100644 index 0000000..b502b93 --- /dev/null +++ b/tests/ui/test_ui_pii_types.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""UI: tipo do dado em cada tarja sugerida e resumo dos tipos encontrados no cartão da tarefa.""" +from playwright.sync_api import expect + +from ui_support import box + + +def test_typed_suggestion_shows_its_type(page, live_app, open_task): + typed = dict(box(1), label="Telefone") + tid = live_app.seed_task("tipos.pdf", redactions=[typed, box(1, (300, 300, 400, 340))]) + open_task(tid) + labelled = page.locator(".redaction-box.type-labelled") + expect(labelled).to_have_count(1) + expect(labelled).to_have_attribute("data-label", "Telefone") + expect(labelled).to_have_attribute("title", "Possível Telefone — verifique antes de proteger") + titles = page.eval_on_selector_all("[data-testid=redaction-box]", "els => els.map(e => e.title)") + assert "Possível dado pessoal — verifique antes de proteger" in titles + + +def test_type_label_is_text_not_html(page, live_app, open_task): + evil = dict(box(1), label="") + tid = live_app.seed_task("xss2.pdf", redactions=[evil]) + open_task(tid) + assert page.locator("#layer-1 img").count() == 0 and page.evaluate("window.__xss") is None + + +def test_task_card_summarizes_pii_found(page, live_app, open_app): + live_app.seed_task("resumo.pdf", extra={"pii_found": [ + {"id": "cpf", "nome": "CPF", "nivel": "identificador direto", "quantidade": 3}, + {"id": "telefone", "nome": "Telefone", "nivel": "dado pessoal", "quantidade": 1}]}) + open_app() + summary = page.locator("[data-testid=task-pii]") + expect(summary).to_have_text("🔎 CPF 3 · Telefone 1") + expect(summary).to_have_attribute("title", "CPF (identificador direto): 3\nTelefone (dado pessoal): 1") diff --git a/utils/detect/__init__.py b/utils/detect/__init__.py new file mode 100644 index 0000000..b0619a2 --- /dev/null +++ b/utils/detect/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""SENTRY Detect: catálogo de PII, enquadramento legal, perfis de política e detectores por regra. + +Pacote isolado, sem dependência do pipeline, com entrada e saída simples (mapas de palavras e dicionários +JSON). Hoje roda dentro do processo de cada tarefa; o mesmo contrato pode virar um serviço separado no +futuro (visão SENTRY Gateway) sem mudar quem o usa. A API só de leitura está em /policy/catalog e +/policy/profiles (app_service.py). + + catalog tipos de PII e enquadramento (LGPD, GDPR, ISO/IEC 29100, NIST SP 800-122, HIPAA Safe Harbor) + profiles o que tarjar/alertar em cada perfil (POLICY_PROFILE) + rules detectores por regra para os tipos de formato conhecido + validators dígitos verificadores oficiais (PIS, CNS, título de eleitor, Luhn...) +""" +from . import catalog, profiles, rules # noqa: F401 +from .profiles import ALERTAR, TARJAR, active_profile_id, get_profile, runnable_actions # noqa: F401 +from .rules import find_in_grounding # noqa: F401 diff --git a/utils/detect/__main__.py b/utils/detect/__main__.py new file mode 100644 index 0000000..27a2b5a --- /dev/null +++ b/utils/detect/__main__.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""python -m utils.detect --markdown escreve docs/catalogo-pii.md a partir do catálogo +python -m utils.detect --json imprime catálogo e perfis em JSON (o mesmo da API /policy/*)""" +import argparse +import json +import os +import sys + +from . import catalog, profiles + +DOC_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "docs", "catalogo-pii.md") + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="python -m utils.detect") + ap.add_argument("--markdown", action="store_true", help=f"regenera {DOC_PATH}") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + if args.markdown: + with open(DOC_PATH, "w", encoding="utf-8", newline="\n") as f: + f.write(catalog.to_markdown() + "\n") + print(f"escrito: {DOC_PATH}") + if args.json or not args.markdown: + print(json.dumps({"catalogo": [t.to_dict() for t in catalog.CATALOG], "perfis": profiles.describe_all()}, + ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/detect/catalog.py b/utils/detect/catalog.py new file mode 100644 index 0000000..c8d83df --- /dev/null +++ b/utils/detect/catalog.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Catálogo de tipos de dado pessoal (PII) e seu enquadramento em referências nacionais e internacionais. + +É a "fonte da verdade" de SENTRY Detect: perfis de política (profiles.py), detectores (rules.py), relatório +por documento e a documentação (docs/catalogo-pii.md, gerada por `python -m utils.detect --markdown`). + +Atenção à redação: o enquadramento indica a QUE categoria das referências o tipo corresponde. Detectar e +tarjar esses tipos APOIA práticas alinhadas a essas referências; não torna um documento "conforme". + +Para acrescentar um tipo: inclua-o aqui (estado "planejado" se ainda não houver detector), acrescente-o aos +perfis que fizer sentido, escreva o detector em rules.py com testes e regenere a documentação. +""" +from dataclasses import asdict, dataclass + +# Níveis +DIRETO = "identificador direto" # identifica sozinho (documento, nome completo...) +PESSOAL = "dado pessoal" # identifica ou contata a pessoa (telefone, e-mail, endereço...) +SENSIVEL = "dado pessoal sensível" # LGPD art. 5º, II / GDPR art. 9 e 10 +INDIRETO = "identificador indireto" # identifica combinado com outros dados (placa, data de nascimento, IP...) + +ATIVO = "ativo" +PLANEJADO = "planejado" + +# Referências citadas (mantidas curtas; o texto legal completo está nas fontes oficiais) +REF_LGPD_I = "LGPD art. 5º, I (dado pessoal)" +REF_LGPD_II = "LGPD art. 5º, II (dado pessoal sensível)" +REF_GDPR_4 = "GDPR art. 4(1)" + + +@dataclass(frozen=True) +class PIIType: + id: str + nome: str + nivel: str + estado: str + deteccao: str + lgpd: str + gdpr: str + iso29100: str + nist: str + hipaa: str + nota: str = "" + + def to_dict(self): + return asdict(self) + + +def _t(id, nome, nivel, estado, deteccao, lgpd, gdpr, nist, hipaa, nota="", iso="PII"): + return PIIType(id, nome, nivel, estado, deteccao, lgpd, gdpr, iso, nist, hipaa, nota) + + +CATALOG = [ + # --- documentos e números de identificação (Brasil) ------------------------------------------------- + _t("cpf", "CPF", DIRETO, ATIVO, "regra + dígito verificador (OCR, passada esparsa e texto digital)", + REF_LGPD_I, REF_GDPR_4 + " e art. 87 (número de identificação nacional)", "identificador pessoal (ex.: SSN)", + "outro número de identificação único"), + _t("rg", "RG (carteira de identidade)", DIRETO, ATIVO, "formato + palavra de contexto (RG, identidade)", + REF_LGPD_I, REF_GDPR_4 + " e art. 87", "identificador pessoal", "outro número de identificação único", + "Sem algoritmo nacional de dígito verificador: exige contexto."), + _t("cnh", "CNH (registro da habilitação)", DIRETO, ATIVO, "formato + palavra de contexto (CNH, habilitação)", + REF_LGPD_I, REF_GDPR_4, "identificador pessoal (carteira de motorista)", "número de certificado/licença"), + _t("titulo_eleitor", "Título de eleitor", DIRETO, ATIVO, "regra + dígito verificador + contexto", + REF_LGPD_I, REF_GDPR_4, "identificador pessoal", "outro número de identificação único"), + _t("pis_nis", "PIS/PASEP/NIS/NIT", DIRETO, ATIVO, "regra + dígito verificador + contexto", + REF_LGPD_I, REF_GDPR_4, "identificador pessoal", "outro número de identificação único"), + _t("cns", "Cartão Nacional de Saúde (CNS/SUS)", DIRETO, ATIVO, "regra + dígito verificador", + REF_LGPD_I + "; associado a dado de saúde pode compor dado sensível", REF_GDPR_4, + "identificador pessoal", "número de beneficiário de plano de saúde"), + _t("passaporte", "Passaporte", DIRETO, ATIVO, "formato + palavra de contexto (passaporte)", + REF_LGPD_I, REF_GDPR_4, "identificador pessoal (passaporte)", "número de certificado/licença"), + _t("ctps", "Carteira de Trabalho (CTPS)", DIRETO, ATIVO, "formato + palavra de contexto (CTPS)", + REF_LGPD_I, REF_GDPR_4, "identificador pessoal", "outro número de identificação único"), + # --- contato e localização ----------------------------------------------------------------------- + _t("endereco_residencial", "Endereço residencial", PESSOAL, ATIVO, "LLM de visão + casamento com o OCR (+ decisor Laya)", + REF_LGPD_I, REF_GDPR_4 + " (dado de localização)", "endereço", "subdivisão geográfica menor que o estado"), + _t("telefone", "Telefone", PESSOAL, ATIVO, "formato brasileiro (DDD) ou palavra de contexto", + REF_LGPD_I, REF_GDPR_4, "número de telefone", "número de telefone/fax", + "Telefones institucionais também são marcados: o revisor decide."), + _t("email", "E-mail", PESSOAL, ATIVO, "formato", REF_LGPD_I, REF_GDPR_4 + " (identificador online)", + "endereço de e-mail", "endereço de e-mail", "E-mails institucionais também são marcados: o revisor decide."), + _t("ip", "Endereço IP", INDIRETO, ATIVO, "formato IPv4 válido", REF_LGPD_I + " (quando vinculado a pessoa)", + REF_GDPR_4 + " (identificador online)", "informação de ativo (IP)", "endereço IP"), + _t("geolocalizacao", "Coordenadas geográficas", INDIRETO, PLANEJADO, "formato lat/long (planejado)", + REF_LGPD_I, REF_GDPR_4 + " (dado de localização)", "informação vinculável", "subdivisão geográfica"), + # --- financeiro ---------------------------------------------------------------------------------- + _t("cartao_pagamento", "Cartão de pagamento", DIRETO, ATIVO, "Luhn + formatação em grupos ou contexto", + REF_LGPD_I, REF_GDPR_4, "conta financeira / cartão de crédito", "número de conta"), + _t("conta_bancaria", "Agência e conta bancária", DIRETO, ATIVO, "palavra de contexto (agência, conta, c/c)", + REF_LGPD_I, REF_GDPR_4, "conta financeira", "número de conta"), + _t("chave_pix", "Chave Pix aleatória", DIRETO, ATIVO, "formato (UUID)", REF_LGPD_I, REF_GDPR_4, + "conta financeira", "número de conta", + "Chaves Pix de CPF, e-mail ou telefone já são cobertas pelos respectivos tipos."), + # --- características e datas --------------------------------------------------------------------- + _t("data_nascimento", "Data de nascimento", INDIRETO, ATIVO, "data + palavra de contexto (nascido, nascimento)", + REF_LGPD_I, REF_GDPR_4, "informação vinculável (data de nascimento)", "datas relacionadas à pessoa (exceto ano)"), + _t("placa_veiculo", "Placa de veículo", INDIRETO, ATIVO, "formato antigo ou Mercosul", + REF_LGPD_I + " (quando vinculada a pessoa)", REF_GDPR_4, "bem de propriedade pessoal (registro de veículo)", + "identificador de veículo (inclui placa)"), + _t("nome_pessoa", "Nome de pessoa", DIRETO, PLANEJADO, "GLiNER (reconhecimento de nomes) — backlog B-73", + REF_LGPD_I, REF_GDPR_4, "nome", "nomes", + "Na LAI, nome de servidor no exercício da função costuma ser público: exceção decidida por perfil/Laya (B-74)."), + _t("filiacao", "Filiação (nome da mãe/pai)", DIRETO, PLANEJADO, "GLiNER + contexto — backlog B-73", + REF_LGPD_I, REF_GDPR_4, "nome / informação vinculável", "nomes (inclui parentes)"), + _t("assinatura", "Assinatura", DIRETO, PLANEJADO, "detector YOLO (hoje só localiza para auditar CPFs próximos)", + REF_LGPD_I, REF_GDPR_4, "característica pessoal", "—"), + _t("foto_rosto", "Foto de rosto", DIRETO, PLANEJADO, "detecção de rosto (planejado)", REF_LGPD_I, + REF_GDPR_4 + "; art. 9 se usado para identificação biométrica", "característica pessoal (foto)", + "foto de rosto inteiro e imagens comparáveis"), + # --- dados sensíveis (dependem de contexto: só alerta + revisão humana) --------------------------- + _t("saude", "Dado de saúde (diagnóstico, CID, tratamento)", SENSIVEL, PLANEJADO, + "LLM/Laya sobre o contexto, sempre com revisão humana", REF_LGPD_II, "GDPR art. 9 (dados de saúde)", + "informação médica", "número de prontuário e informação de saúde associada", iso="PII sensível"), + _t("biometrico_genetico", "Dado biométrico ou genético", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", + REF_LGPD_II, "GDPR art. 9", "característica pessoal (biometria)", "identificador biométrico", iso="PII sensível"), + _t("religiao", "Convicção religiosa", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", REF_LGPD_II, "GDPR art. 9", + "informação vinculável", "—", iso="PII sensível"), + _t("opiniao_politica", "Opinião política / filiação partidária", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", + REF_LGPD_II, "GDPR art. 9", "informação vinculável", "—", iso="PII sensível"), + _t("filiacao_sindical", "Filiação a sindicato", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", REF_LGPD_II, + "GDPR art. 9", "informação vinculável", "—", iso="PII sensível"), + _t("origem_etnica", "Origem racial ou étnica", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", REF_LGPD_II, + "GDPR art. 9", "informação vinculável", "—", iso="PII sensível"), + _t("vida_sexual", "Vida sexual / orientação sexual", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", REF_LGPD_II, + "GDPR art. 9", "informação vinculável", "—", iso="PII sensível"), + _t("antecedentes_criminais", "Condenações e infrações penais", SENSIVEL, PLANEJADO, "LLM/Laya + revisão", + "LGPD: não listado no art. 5º, II; o tratamento para persecução penal fica fora da LGPD e segue lei específica " + "(art. 4º, III)", "GDPR art. 10", "informação vinculável", "—", + iso="PII sensível"), +] + +BY_ID = {t.id: t for t in CATALOG} +LEVELS = (DIRETO, PESSOAL, SENSIVEL, INDIRETO) + + +def get(type_id): + return BY_ID[type_id] + + +def active_ids(): + return [t.id for t in CATALOG if t.estado == ATIVO] + + +def to_markdown(): + """Tabela do catálogo para docs/catalogo-pii.md (gerada; não edite o .md à mão).""" + lines = [ + "# Catálogo de dados pessoais (PII)", + "", + "> Gerado por `python -m utils.detect --markdown` a partir de `utils/detect/catalog.py`. Não edite à mão.", + ">", + "> O enquadramento indica a que categoria de cada referência o tipo corresponde. Detectar e tarjar esses", + "> tipos **apoia** práticas alinhadas a essas referências; **não** torna um documento \"conforme\".", + "> Itens *planejados* ainda não têm detector.", + "", + ] + for level in LEVELS: + items = [t for t in CATALOG if t.nivel == level] + lines += [f"## {level[0].upper()}{level[1:]}", "", + "| Tipo | Estado | Detecção | LGPD | GDPR | ISO/IEC 29100 | NIST SP 800-122 | HIPAA Safe Harbor |", + "|---|---|---|---|---|---|---|---|"] + for t in items: + nota = f"
*{t.nota}*" if t.nota else "" + lines.append(f"| **{t.nome}** (`{t.id}`){nota} | {t.estado} | {t.deteccao} | {t.lgpd} | {t.gdpr} | " + f"{t.iso29100} | {t.nist} | {t.hipaa} |") + lines.append("") + return "\n".join(lines) diff --git a/utils/detect/profiles.py b/utils/detect/profiles.py new file mode 100644 index 0000000..b221ab3 --- /dev/null +++ b/utils/detect/profiles.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Perfis de política: o que fazer com cada tipo do catálogo. + +Ações: + tarjar vira tarja SUGERIDA (o revisor confirma, como todas as tarjas da IA) + alertar marca a região para revisão, sem tarja (para tipos que nem sempre devem sair do documento) + +O perfil ativo vem de POLICY_PROFILE (padrão: cpf_endereco, o comportamento original). Tipos "planejados" no +catálogo podem aparecer nos perfis: passam a valer sozinhos quando o detector existir. +""" +import os + +from . import catalog + +TARJAR = "tarjar" +ALERTAR = "alertar" + +_DOCUMENTOS = ["cpf", "rg", "cnh", "titulo_eleitor", "pis_nis", "cns", "passaporte", "ctps"] +_FINANCEIRO = ["cartao_pagamento", "conta_bancaria", "chave_pix"] +_CONTATO = ["endereco_residencial", "telefone", "email"] +_SENSIVEIS = ["saude", "biometrico_genetico", "religiao", "opiniao_politica", "filiacao_sindical", "origem_etnica", + "vida_sexual", "antecedentes_criminais"] + + +def _actions(tarjar=(), alertar=()): + out = {t: TARJAR for t in tarjar} + out.update({t: ALERTAR for t in alertar}) + return out + + +PROFILES = { + "cpf_endereco": { + "nome": "Só CPF e endereço residencial", + "descricao": "Comportamento original do SENTRY Redact.", + "acoes": _actions(tarjar=["cpf", "endereco_residencial"]), + }, + "lgpd_publicacao": { + "nome": "LGPD: publicação (transparência / LAI)", + "descricao": "Para publicar documentos (LAI art. 31): tarja identificadores diretos, contato, financeiro e datas " + "de nascimento; alerta para identificadores indiretos e dados sensíveis.", + "acoes": _actions(tarjar=_DOCUMENTOS + _FINANCEIRO + _CONTATO + ["data_nascimento", "nome_pessoa", "filiacao", + "foto_rosto"], + alertar=["placa_veiculo", "ip", "geolocalizacao"] + _SENSIVEIS), + }, + "lgpd_interno": { + "nome": "LGPD: compartilhamento interno", + "descricao": "Para circular dentro da organização: tarja documentos e dados financeiros; alerta para contato e " + "dados sensíveis (decisão do revisor conforme a finalidade).", + "acoes": _actions(tarjar=_DOCUMENTOS + _FINANCEIRO, + alertar=_CONTATO + ["data_nascimento", "placa_veiculo", "ip"] + _SENSIVEIS), + }, + "gdpr": { + "nome": "GDPR (União Europeia)", + "descricao": "Identificadores diretos e online (inclui IP) tarjados; categorias especiais (art. 9) e dados " + "criminais (art. 10) alertados para revisão.", + "acoes": _actions(tarjar=_DOCUMENTOS + _FINANCEIRO + _CONTATO + ["data_nascimento", "ip", "placa_veiculo", + "nome_pessoa", "filiacao", "foto_rosto", + "geolocalizacao"], + alertar=_SENSIVEIS), + }, + "saude_hipaa": { + "nome": "Saúde (referência HIPAA Safe Harbor)", + "descricao": "Tarja os identificadores da lista Safe Harbor que o projeto detecta (documentos, contato, datas, " + "contas, placas, IP, nomes, fotos) e alerta para dados de saúde.", + "acoes": _actions(tarjar=_DOCUMENTOS + _FINANCEIRO + _CONTATO + ["data_nascimento", "ip", "placa_veiculo", + "nome_pessoa", "filiacao", "foto_rosto", + "geolocalizacao"], + alertar=_SENSIVEIS), + }, +} +DEFAULT_PROFILE = "cpf_endereco" + + +def active_profile_id(): + value = (os.getenv("POLICY_PROFILE") or DEFAULT_PROFILE).strip().lower() + return value if value in PROFILES else DEFAULT_PROFILE + + +def get_profile(profile_id=None): + pid = profile_id or active_profile_id() + return pid, PROFILES[pid] + + +def runnable_actions(profile_id=None): + """Ações do perfil só para tipos com detector ativo, separando os que já têm fase própria no pipeline.""" + _, profile = get_profile(profile_id) + return {t: a for t, a in profile["acoes"].items() if catalog.get(t).estado == catalog.ATIVO} + + +def describe_all(): + """Perfis para a API/documentação (sem dado pessoal).""" + out = {} + for pid, p in PROFILES.items(): + out[pid] = {"nome": p["nome"], "descricao": p["descricao"], + "acoes": {t: {"acao": a, "estado": catalog.get(t).estado} for t, a in p["acoes"].items()}} + return out diff --git a/utils/detect/rules.py b/utils/detect/rules.py new file mode 100644 index 0000000..021099d --- /dev/null +++ b/utils/detect/rules.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Detectores por regra dos tipos de PII de formato conhecido (catalog.py). + +Cada regra trabalha sobre o MAPA DE PALAVRAS do OCR (ou do texto digital do PDF) e devolve comandos de tarja no +mesmo formato da busca de CPF: {id_da_palavra: {índices de caracteres}}. Assim, revisão, editor, tarja nativa e +regiões a revisar valem para qualquer tipo. + +Para evitar falso positivo, cada regra exige pelo menos um destes: dígito verificador, formato distintivo +(ex.: telefone com parênteses/hífen) ou palavra de contexto perto do valor (ex.: "RG", "agência", "nascido"). +A palavra de contexto em si nunca é tarjada; só o valor. +""" +import re +import unicodedata +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional, Pattern, Set, Tuple + +from . import validators as v + +Commands = Dict[int, Set[int]] + + +@dataclass(frozen=True) +class Rule: + type_id: str + value: Pattern # grupo nomeado "v" = trecho a tarjar + context: Optional[Pattern] = None # procurado no texto (sem acento, minúsculo) logo ANTES do valor + context_required: bool = False + window: int = 40 # quantos caracteres antes do valor olhar para achar o contexto + accept: Optional[Callable[[str, bool], bool]] = None # (valor, tem_contexto) -> aceita? + + +def _norm(text): + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + return text.lower() + + +def _digits_ok(n_min, n_max): + return lambda value, _ctx: n_min <= len(v.only_digits(value)) <= n_max + + +def _phone_ok(value, has_ctx): + d = v.only_digits(value) + if d.startswith("55") and len(d) in (12, 13): + d = d[2:] + if len(d) not in (10, 11) or len(set(d)) == 1 or not 11 <= int(d[:2]) <= 99: + return False + if len(d) == 11 and d[2] != "9": # celular com 9 dígitos começa com 9 + return False + formatted = bool(re.search(r"[()\-+]", value)) + return formatted or has_ctx + + +def _card_ok(value, has_ctx): + grouped = bool(re.fullmatch(r"\d{4}([\s\-.]\d{4}){2}[\s\-.]\d{1,7}", value.strip())) + return v.is_valid_luhn(value) and (grouped or has_ctx) + + +def _cns_ok(value, has_ctx): + grouped = bool(re.fullmatch(r"\d{3}\s\d{4}\s\d{4}\s\d{4}", value.strip())) + return v.is_valid_cns(value) and (grouped or has_ctx) + + +def _plate_ok(value, has_ctx): + compact = re.sub(r"[\s\-]", "", value) + mercosul = bool(re.fullmatch(r"[A-Z]{3}\d[A-Z]\d{2}", compact)) + old_hyphen = bool(re.fullmatch(r"[A-Z]{3}-\d{4}", value.strip())) + return mercosul or old_hyphen or has_ctx + + +_I = re.IGNORECASE +RULES: List[Rule] = [ + Rule("rg", re.compile(r"(?P\b\d[\d.\-]{3,13}[\dxX]\b)"), + re.compile(r"\b(rg|r\.\s?g\.|identidade|registro geral)\b"), context_required=True, window=30, + accept=_digits_ok(5, 14)), + Rule("cnh", re.compile(r"(?P\b\d{9,11}\b)"), + re.compile(r"\b(cnh|habilitacao)\b"), context_required=True, window=40), + Rule("titulo_eleitor", re.compile(r"(?P\b\d{4}\s?\d{4}\s?\d{4}\b)"), + re.compile(r"titulo\s+(de\s+)?eleitor|titulo\s+eleitoral|\beleitor\b"), context_required=True, window=40, + accept=lambda value, _c: v.is_valid_titulo_eleitor(value)), + Rule("pis_nis", re.compile(r"(?P\b\d{3}\.?\s?\d{5}\.?\s?\d{2}-?\s?\d\b)"), + re.compile(r"\b(pis|pasep|nis|nit)\b"), context_required=True, window=30, + accept=lambda value, _c: v.is_valid_pis(value)), + Rule("cns", re.compile(r"(?P\b[12789]\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\b)"), + re.compile(r"\bcns\b|cartao\s+(do\s+)?sus|cartao\s+nacional\s+de\s+saude"), window=40, accept=_cns_ok), + Rule("passaporte", re.compile(r"(?P\b[A-Z]{2}\s?\d{6,7}\b)"), + re.compile(r"passaporte"), context_required=True, window=30), + Rule("ctps", re.compile(r"(?P\b\d{5,8}(?:\s*(?:/|serie|série)\s*\d{3,5}(?:-[A-Z]{2})?)?\b)", _I), + re.compile(r"\bctps\b|carteira\s+de\s+trabalho"), context_required=True, window=30), + Rule("telefone", re.compile(r"(?P(?:\+\s?55\s?)?\(?\s?\d{2}\s?\)?\s?9?\s?\d{4}\s?[\-.]?\s?\d{4})(?!\d)"), + re.compile(r"\b(tel|telefone|fone|celular|cel|whatsapp|contato)\b"), window=25, accept=_phone_ok), + Rule("email", re.compile(r"(?P[A-Za-z0-9._%+\-]+\s?@\s?[A-Za-z0-9.\-]+\.[A-Za-z]{2,})")), + Rule("ip", re.compile(r"(?P(?\b(?:\d{4}[\s\-.]){3}\d{1,7}\b|\b\d{13,19}\b)"), + re.compile(r"cartao|credito|debito|visa|master|elo\b|amex"), window=30, accept=_card_ok), + Rule("conta_bancaria", re.compile(r"(?P\b\d{3,12}(?:-[\dxX])?\b)"), + re.compile(r"\b(agencia|ag\.?|conta|c/c|cc|conta\s+corrente|poupanca)\s*(n[o.º°]?\s*)?[:\-]?\s*$"), + context_required=True, window=22, accept=_digits_ok(3, 13)), + Rule("chave_pix", re.compile(r"(?P\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)")), + Rule("data_nascimento", + re.compile(r"(?P\b\d{1,2}\s?[/.\-]\s?\d{1,2}\s?[/.\-]\s?\d{2,4}\b|\b\d{1,2}\s+de\s+[a-zç]+\s+de\s+\d{4}\b)", _I), + re.compile(r"nascid|nascimento|\bnasc\b|\bd\.?\s?n\.?\b"), context_required=True, window=40), + Rule("placa_veiculo", re.compile(r"(?P\b[A-Z]{3}[\s\-]?\d[A-Z0-9]\d{2}\b)"), + re.compile(r"\bplaca\b|veiculo|renavam|automovel"), window=30, accept=_plate_ok), +] +RULES_BY_TYPE = {r.type_id: r for r in RULES} + + +def build_text(grounding) -> Tuple[str, List[Optional[Tuple[int, int]]]]: + """Junta as palavras do mapa com espaços e guarda, para cada caractere, (id da palavra, índice) ou None.""" + parts, char_map = [], [] + for wid, word in enumerate(grounding or []): + text = str(word.get("text", "")) + if not text: + continue + if parts: + parts.append(" ") + char_map.append(None) + parts.append(text) + char_map.extend((wid, i) for i in range(len(text))) + return "".join(parts), char_map + + +def find_in_grounding(grounding, type_ids) -> Dict[str, Tuple[Commands, Set[str]]]: + """ + Procura os tipos pedidos no mapa de palavras. + Devolve {tipo: (comandos de tarja, valores distintos normalizados)} só para os tipos encontrados. + """ + text, char_map = build_text(grounding) + if not text: + return {} + norm_text = _norm(text) # mesmo comprimento para texto latino comum; usado só para o contexto + results = {} + for type_id in type_ids: + rule = RULES_BY_TYPE.get(type_id) + if rule is None: + continue + commands: Commands = {} + values: Set[str] = set() + for m in rule.value.finditer(text): + value = m.group("v") + start, end = m.span("v") + has_ctx = False + if rule.context is not None: + window = norm_text[max(0, start - rule.window):start] if len(norm_text) == len(text) else \ + _norm(text[max(0, start - rule.window):start]) + has_ctx = bool(rule.context.search(window)) + if rule.context_required and not has_ctx: + continue + if rule.accept is not None and not rule.accept(value, has_ctx): + continue + for pos in range(start, end): + ref = char_map[pos] + if ref is not None and not text[pos].isspace(): + commands.setdefault(ref[0], set()).add(ref[1]) + values.add(re.sub(r"\s", "", value).lower()) + if commands: + results[type_id] = (commands, values) + return results diff --git a/utils/detect/validators.py b/utils/detect/validators.py new file mode 100644 index 0000000..f36ea44 --- /dev/null +++ b/utils/detect/validators.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Validadores de identificadores com dígito verificador PÚBLICO e estável. + +Regra do projeto: só entra aqui algoritmo oficial e bem estabelecido. Documentos sem algoritmo nacional +confiável (RG, CNH, passaporte, CTPS) são detectados por formato + palavra de contexto (rules.py), nunca por +um cálculo "aproximado" que poderia descartar um número verdadeiro. +""" +import ipaddress + +from utils.validators import is_valid_cnpj, is_valid_cpf # noqa: F401 (reexportados para quem usa este módulo) + + +def only_digits(value): + return "".join(ch for ch in str(value) if ch.isdigit()) + + +def is_valid_pis(value): + """PIS/PASEP/NIS/NIT: 11 dígitos, pesos 3,2,9,8,7,6,5,4,3,2 e módulo 11.""" + d = only_digits(value) + if len(d) != 11 or len(set(d)) == 1: + return False + total = sum(int(a) * w for a, w in zip(d[:10], (3, 2, 9, 8, 7, 6, 5, 4, 3, 2))) + dv = 11 - total % 11 + return (0 if dv >= 10 else dv) == int(d[10]) + + +def is_valid_cns(value): + """Cartão Nacional de Saúde (CNS): 15 dígitos começando com 1, 2, 7, 8 ou 9; soma ponderada (15..1) múltipla de 11.""" + d = only_digits(value) + if len(d) != 15 or d[0] not in "12789" or len(set(d)) == 1: + return False + return sum(int(a) * w for a, w in zip(d, range(15, 0, -1))) % 11 == 0 + + +def is_valid_titulo_eleitor(value): + """Título de eleitor: 12 dígitos = 8 sequenciais + UF (01..28) + 2 verificadores (regra especial para SP/MG).""" + d = only_digits(value) + if len(d) != 12 or len(set(d)) == 1: + return False + seq, uf, dv = d[:8], d[8:10], d[10:] + if not 1 <= int(uf) <= 28: + return False + sp_mg = uf in ("01", "02") + dv1 = sum(int(a) * w for a, w in zip(seq, range(2, 10))) % 11 + dv1 = 1 if (dv1 == 0 and sp_mg) else (0 if dv1 == 10 else dv1) + dv2 = (int(uf[0]) * 7 + int(uf[1]) * 8 + dv1 * 9) % 11 + dv2 = 1 if (dv2 == 0 and sp_mg) else (0 if dv2 == 10 else dv2) + return dv == f"{dv1}{dv2}" + + +def is_valid_luhn(value, min_len=13, max_len=19): + """Cartão de pagamento: 13 a 19 dígitos, algoritmo de Luhn.""" + d = only_digits(value) + if not min_len <= len(d) <= max_len or len(set(d)) == 1: + return False + total = 0 + for i, ch in enumerate(reversed(d)): + n = int(ch) + if i % 2 == 1: + n = n * 2 - 9 if n > 4 else n * 2 + total += n + return total % 10 == 0 + + +def is_valid_ipv4(value): + try: + ip = ipaddress.IPv4Address(str(value).strip()) + except ValueError: + return False + return not (ip.is_unspecified or ip.is_loopback) From e31e6624ae67ff33b7e441e97888a6a546924d34 Mon Sep 17 00:00:00 2001 From: Joberth Firmino Gambati Date: Sun, 4 Oct 2026 15:01:06 -0400 Subject: [PATCH 3/3] =?UTF-8?q?Vers=C3=A3o=205.4.0:=20revis=C3=A3o=20indep?= =?UTF-8?q?endente=20corrigida=20(rota=C3=A7=C3=A3o,=20log,=20editor,=20re?= =?UTF-8?q?gras,=20finaliza=C3=A7=C3=A3o,=20lotes)=20e=20contra-an=C3=A1li?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Joberth Firmino Gambati --- AGENTS.md | 5 +- BACKLOG.md | 10 +- CHANGELOG.md | 22 ++ CITATION.cff | 2 +- docs/MANUAL_DE_USO.md | 14 +- docs/architecture.md | 16 +- docs/limitations.md | 1 + main.py | 64 ++++-- pyproject.toml | 2 +- templates/index.html | 9 +- tests/test_counter_analysis.py | 358 +++++++++++++++++++++++++++++++ tests/test_detect.py | 26 ++- tests/test_main_phases.py | 8 +- tests/ui/test_ui_review_marks.py | 30 +++ utils/detect/rules.py | 42 +++- utils/pii.py | 10 +- utils/session.py | 7 +- utils/verifier.py | 241 ++++++++++++--------- 18 files changed, 718 insertions(+), 149 deletions(-) create mode 100644 tests/test_counter_analysis.py diff --git a/AGENTS.md b/AGENTS.md index fe81742..653df0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,9 @@ em documentos. O único produto existente é o **SENTRY Redact**: um pipeline qu **endereços pessoais** em PDFs (OCR Tesseract + detector YOLO de assinaturas + LLM de visão via Ollama), sugere tarjas, verifica a saída e só gera o PDF final depois da revisão humana num editor web. -Os módulos SENTRY Detect, Mask/Transform, Gateway e Audit são **visão**, não código: não os descreva como -existentes em documentação, interface ou mensagens. +**SENTRY Detect** existe em parte: catálogo de PII, perfis de política e detectores por regra (`utils/detect/`). +Nomes, rostos e dados sensíveis estão no catálogo como *planejados*; não os descreva como detectados. +Os módulos Mask/Transform, Gateway e Audit são **visão**, não código: não os descreva como existentes. ## 2. Invariantes (não negociáveis) diff --git a/BACKLOG.md b/BACKLOG.md index 13ca0b7..537eb17 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,7 +13,7 @@ leia o [AGENTS.md](AGENTS.md): invariantes, arquitetura e como rodar os testes. **P2** (robustez, operação e experiência) · **P3** (melhoria e refatoração). - Itens marcados como **visão** vêm do roteiro do produto: não têm data nem promessa de entrega. - As referências `arquivo:linha` valem para o estado de referência e podem se deslocar. -- Estado de referência: `5.3.0`, 2026-10-04. Itens B-40 a B-65 vieram de uma revisão completa do código e da redação do manual nessa data. +- Estado de referência: `5.4.0`, 2026-10-04. Itens B-40 a B-65 vieram de uma revisão completa do código e da redação do manual nessa data. ## Resumo @@ -52,8 +52,8 @@ Arquitetura: `detectar (regras + GLiNER + LLM) → decidir (Laya) → revisão | ID | Prioridade | Item | Depende de | |---|---|---|---| -| B-71 | P1 | **Política configurável — parte 1 concluída (não publicado):** catálogo com enquadramento legal, 5 perfis via `POLICY_PROFILE`, rótulo do tipo em cada tarja, resumo por documento, API `/policy/*`. **Falta:** escolher o perfil na interface e por tarefa, e registrar a política aplicada num relatório exportável | — | -| B-72 | P1 | **Concluído (não publicado):** RG, CNH, título de eleitor, PIS/NIS, CNS, passaporte, CTPS, telefone, e-mail, IP, cartão (Luhn), conta bancária, chave Pix, data de nascimento e placa, com dígito verificador, formato ou contexto (`tests/test_detect.py`). **Falta:** medir revocação por tipo em documentos fictícios realistas e estender a verificação pós-tarja a esses tipos | B-71 | +| B-71 | P1 | **Política configurável — parte 1 concluída (5.4.0):** catálogo com enquadramento legal, 5 perfis via `POLICY_PROFILE`, rótulo do tipo em cada tarja, resumo por documento, API `/policy/*`. **Falta:** escolher o perfil na interface e por tarefa, e registrar a política aplicada num relatório exportável | — | +| B-72 | P1 | **Concluído (5.4.0):** RG, CNH, título de eleitor, PIS/NIS, CNS, passaporte, CTPS, telefone, e-mail, IP, cartão (Luhn), conta bancária, chave Pix, data de nascimento e placa, com dígito verificador, formato ou contexto (`tests/test_detect.py`). **Falta:** medir revocação por tipo em documentos fictícios realistas e estender a verificação pós-tarja a esses tipos | B-71 | | B-73 | P1 | **Nomes e rótulos livres com GLiNER** (modo leve na CPU): o usuário escreve o rótulo ("nome de pessoa", "número de processo"); trechos mapeados para as caixas do OCR; confiança baixa → revisão | B-71 | | B-74 | P2 | **Exceções decididas pelo Laya**: "nome a proteger" × "servidor/signatário público" (LAI), com perguntas próprias, treino e portão | B-73, B-70 | | B-75 | P2 | **Perfis de política e roteamento**: perfis prontos (Transparência/LAI, Saúde, Jurídico, Só CPF) e o Laya sugerindo o perfil pelo tipo de documento | B-71, B-70 | @@ -125,8 +125,8 @@ Arquitetura: `detectar (regras + GLiNER + LLM) → decidir (Laya) → revisão | ID | Item | Versão | Teste | |---|---|---|---| -| B-12 | Desempenho: `BASE_DPI` 300 (medido: melhor revocação e mais rápido), OCR das páginas em paralelo nas duas passadas e na verificação, pré-carga do Laya, PNG leve. Documento real de 10 páginas: ~14 min → ~3 min | não publicado | `tests/test_performance.py`, `docs/benchmarks.md` | -| B-67 | Regiões a revisar desenhadas na página (alerta com posição) e documento aberto que se atualiza ao terminar o processamento | não publicado | `tests/test_review_marks.py`, `tests/ui/test_ui_review_marks.py` | +| B-12 | Desempenho: `BASE_DPI` 300 (medido: melhor revocação e mais rápido), OCR das páginas em paralelo nas duas passadas e na verificação, pré-carga do Laya, PNG leve. Documento real de 10 páginas: ~14 min → ~3 min | 5.4.0 | `tests/test_performance.py`, `docs/benchmarks.md` | +| B-67 | Regiões a revisar desenhadas na página (alerta com posição) e documento aberto que se atualiza ao terminar o processamento | 5.4.0 | `tests/test_review_marks.py`, `tests/ui/test_ui_review_marks.py` | | B-41 | Token na URL só abre a sessão (redireciona sem o token); cookie de sessão aleatório (`HttpOnly`, `SameSite=Strict`, `Secure` com HTTPS) no lugar do token cru; `token=` mascarado nos registros (`utils/auth.py`) | 5.3.0 | `tests/test_auth.py`, `tests/test_service_endpoints.py` | | B-42 | Painel do gatekeeper exige `API_TOKEN` quando definido; `kill_port_owner` só encerra um `app_service.py` órfão e não usa shell | 5.3.0 | `tests/test_auth.py`, `tests/test_gatekeeper.py` | | B-52 | Saída do app herdada pelo gatekeeper (o PIPE nunca lido travava o app) | 5.3.0 | `gatekeeper.py` | diff --git a/CHANGELOG.md b/CHANGELOG.md index e169564..8266bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Todas as mudanças relevantes ficam registradas aqui. Formato baseado no ## [Não publicado] +## [5.4.0] - 2026-10-04 + +Critérios nacionais e internacionais de dados pessoais (catálogo de PII e perfis de política), ~4,7× mais rápido +num documento real, regiões a revisar desenhadas na página e uma rodada de revisão independente com contra-análise. +**Mudanças de comportamento:** `BASE_DPI` padrão passou de 1000 para 300 (medido: melhor revocação); quem define +`BASE_DPI`/`YOLO_CROP_PADDING` no `.env` deve revisar os valores. O perfil padrão (`cpf_endereco`) mantém o +comportamento anterior; os novos tipos valem ao escolher outro perfil em `POLICY_PROFILE`. + +### Corrigido (revisão independente e contra-análise) +- **Páginas rotacionadas:** a passada de texto digital usava as coordenadas da página sem rotação e colocaria tarjas no lugar errado (o dado ficaria visível); agora aplica a rotação (`page_words`). Testado sobre os pixels desenhados. +- Registros de erro do uvicorn voltaram a mascarar CPF e `token=` também no *traceback* e em argumentos que não são texto. +- A região "Revisar aqui" não bloqueia mais o clique nas tarjas embaixo dela; trocar de documento não dispara mais um "processamento concluído" falso; erro no processamento não mostra mensagem de sucesso. +- Telefone: não pega mais o final de um número maior; aceita fixo sem DDD com palavra de contexto e DDD com zero de operadora "(065)" coberto inteiro. Data de nascimento aceita "1º de março"; outra data ou rótulo entre "nascimento" e o valor (ex.: "Emissão") anula o contexto. CPF logo depois de "RG" não é mais contado como RG. Placa no formato antigo ("ISO-9001") passa a exigir contexto. +- Uma região a revisar por ocorrência (antes, uma caixa podia cobrir a página inteira); regiões sempre limitadas à página. +- Finalizar ("Aplicar proteção") não apaga mais o resumo de tipos e os tempos do processamento; os rótulos das tarjas atravessam a finalização (antes telefone/e-mail viravam "Endereço residencial" no modo legado). +- Verificação pós-tarja em lotes: limita imagens temporárias em disco e o progresso avança durante o OCR. +- `BASE_DPI` lido num só lugar (`render_dpi`), com a mesma faixa válida em todo o pipeline. + +### Testes +- 66 casos de contra-análise (`tests/test_counter_analysis.py`): falsos positivos em texto administrativo comum, formatos reais, lixo aleatório, páginas gigantes com tempo limitado (sem regex explosivo), acentos e caracteres especiais, concorrência, matriz de perfis e uma regressão por achado da revisão. Vetores fixos conferidos à mão para PIS, CNS, título de eleitor (incluindo a regra de SP/MG) e números de teste públicos de cartão. +- Testes que comprovadamente falham sem a correção (verificado para rotação e para o clique sob a região). + ### Adicionado (critérios de dados pessoais) - **Catálogo de PII** (`utils/detect/catalog.py`, documentação gerada em `docs/catalogo-pii.md`): 30 tipos com enquadramento em **LGPD** (art. 5º, I e II), **GDPR** (art. 4, 9 e 10), **ISO/IEC 29100**, **NIST SP 800-122** e **HIPAA Safe Harbor**, nível (identificador direto, dado pessoal, sensível, indireto) e forma de detecção. Tipos sem detector ficam como *planejados* (nomes, rostos, dados sensíveis). - **Perfis de política** (`POLICY_PROFILE`): `cpf_endereco` (padrão, comportamento original), `lgpd_publicacao` (LAI), `lgpd_interno`, `gdpr` e `saude_hipaa`; cada tipo é tarjado (sugestão para revisão) ou só alertado (região a revisar). O endereço residencial também obedece ao perfil. diff --git a/CITATION.cff b/CITATION.cff index 7e0ae7e..0365d21 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: Gambati given-names: Joberth Firmino alias: Yiuky -version: 5.3.0 +version: 5.4.0 date-released: 2026-10-04 license: AGPL-3.0-or-later repository-code: "https://github.com/Yiuky/PrivioSentry" diff --git a/docs/MANUAL_DE_USO.md b/docs/MANUAL_DE_USO.md index 5f9e365..4701393 100644 --- a/docs/MANUAL_DE_USO.md +++ b/docs/MANUAL_DE_USO.md @@ -239,6 +239,8 @@ Todas as configurações são **variáveis de ambiente**, normalmente no arquivo | `OLLAMA_API_URL` | `http://localhost:11434` | Endereço do servidor Ollama | | `OLLAMA_MODEL` / `OLLAMA_VISION_MODEL` | `llama3` / `gemma4:e4b` | Modelos de texto e de visão | | `BASE_DPI` | `300` | DPI de renderização das páginas. 300 teve a melhor revocação e o menor tempo nas medições | +| `OCR_WORKERS` | metade dos núcleos (até 8) | Quantas páginas passam pelo OCR ao mesmo tempo | +| `POLICY_PROFILE` | `cpf_endereco` | Perfil de política: o que tarjar e o que só alertar ([catálogo](catalogo-pii.md)) | | `VERIFY_OCR` | `1` | Liga a verificação pós-tarja por OCR (recomendado manter `1`) | | `VERIFY_DPI` | `300` | DPI do OCR de verificação | | `APP_HOST` / `APP_PORT` | `127.0.0.1` / `8001` | Endereço e porta da aplicação web | @@ -414,13 +416,23 @@ Clique no cartão de uma tarefa para abri-la no editor. Quando o documento **Requer revisão**, aparece acima das páginas a barra **⚠ Requer revisão** com: -- um botão por página com alerta, no formato **Pág N · motivo**; clique para ir até a página; +- um botão por página com alerta, no formato **Pág N · motivo**; clique para ir até a página. Quando a + posição do problema é conhecida, a página mostra um **contorno tracejado laranja "⚠ Revisar aqui"** no + lugar exato, e o clique centraliza e destaca essa região; - alertas gerais (sem página) exibidos como notas; - a caixa **Só páginas pendentes**, que esconde as demais páginas para você focar no que precisa de atenção. As páginas com alerta também ficam destacadas na área de trabalho. O significado de cada alerta está na [seção 7.4](#74-alertas-e-o-que-fazer). +> [!TIP] +> Pode abrir o documento enquanto ele processa: quando o processamento terminar, o editor carrega o resultado +> sozinho. Se você tiver edições não salvas, ele não recarrega e só avisa para salvar. + +Cada tarja sugerida mostra o **tipo** detectado (ex.: "Possível Telefone", "Possível RG"), conforme o perfil de +política (`POLICY_PROFILE`, [catálogo](catalogo-pii.md)). O cartão da tarefa resume os tipos encontrados +(🔎, só quantidades) e o tempo de processamento (⏱; o detalhe por etapa aparece ao passar o mouse). + ### 4.8 Zoom, navegação e atalhos | Controle | Função | diff --git a/docs/architecture.md b/docs/architecture.md index 4133629..ee28ce8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,9 +25,10 @@ Browser ─► gatekeeper.py (:8000, optional) ──proxy──► app_service. | # | Fase | O que acontece | |---|---|---| -| 0 | Renderização | Páginas do PDF para PNG em `BASE_DPI`. | -| 1 | OCR | Tesseract com coordenadas por palavra (página em duas metades sobrepostas; fallback para uma escala menor quando o Tesseract falha). PSM padrão mais uma passada opcional com PSM esparso. | -| 2 | Descoberta de CPF | Os dígitos são remontados entre palavras/linhas, datas/horários são excluídos, CNPJs de 14 dígitos são poupados, janelas de 11 dígitos são validadas com os dígitos verificadores do CPF; as caixas são calculadas por caractere. | +| 0 | Renderização | Páginas do PDF para PNG em `BASE_DPI` (padrão 300: melhor revocação e mais rápido, ver [benchmarks](benchmarks.md)), com compressão PNG leve. | +| 1 | OCR | Tesseract com coordenadas por palavra (página em duas metades sobrepostas; fallback para uma escala menor quando o Tesseract falha). **Duas passadas por página** (PSM padrão + PSM esparso), mantidas de propósito. As páginas rodam **em paralelo** (`OCR_WORKERS`, threads); a falha do Tesseract é contada por thread (`OCREngine.thread_failures`) e marca a página certa para revisão. Quando o PDF tem texto digital, ele vira uma **terceira leitura** (`grounding_maps_native`). | +| 2 | Descoberta de CPF | Sobre as três leituras: os dígitos são remontados entre palavras/linhas, datas/horários são excluídos, CNPJs de 14 dígitos são poupados, janelas de 11 dígitos são validadas com os dígitos verificadores do CPF; as caixas são calculadas por caractere. | +| 2b | Política (SENTRY Detect) | Demais tipos do perfil `POLICY_PROFILE` ([catálogo](catalogo-pii.md)): regras de `utils/detect/rules.py` sobre as três leituras. *Tarjar* vira tarja sugerida com rótulo do tipo; *alertar* vira região a revisar. Resumo por tipo em `pii_summary`/`pii_found` (só quantidades). | | 3 | YOLO | Detecta assinaturas; recorta com margem (padding). | | 4 | Microauditoria dos recortes | OCR (PSM 6) em cada recorte, mapeando as coordenadas de volta para a página. | | 5 | Descoberta de endereços | O LLM de visão lê cada página e retorna endereços classificados como pessoais/profissionais/secundários. Se a chamada à IA falhar, a página é adicionada a `failed_pages` e marcada para revisão (falha fechado, ou fail closed). | @@ -36,6 +37,13 @@ Browser ─► gatekeeper.py (:8000, optional) ──proxy──► app_service. | 8 | Exportação | Grava `redactions_metadata.json` (usado pelo editor) e o PDF final, seja por **tarja nativa** do original (`apply_redactions`, remove o texto e queima os pixels) ou por páginas rasterizadas. A reconstrução falha fechado (página ausente/com erro = nenhum PDF). | | 9 | Verificação | Veja abaixo. | +## Regiões a revisar, tempos e atualização do editor + +* `SentryApp.add_review(página, motivo, boxes)` aceita regiões relativas `[x0, y0, x1, y1]` (0..1, limitadas à página). Elas viajam em `review_marks` no estado da tarefa e o editor as desenha como contorno tracejado ("Revisar aqui"). Vêm da verificação pós-tarja, da cobertura, do decisor de endereços e das ações *alertar* do perfil. +* `timings` guarda os segundos de cada etapa (mostrados no cartão da tarefa). +* O editor consulta `/tasks` a cada 3 s; quando a tarefa aberta termina, recarrega o resultado sozinho, a menos que haja edições não salvas (aí só avisa). +* O decisor local (Laya), se ligado, carrega o modelo em paralelo com a análise de endereços do LLM. + ## Verificação e estados `utils/verifier.py` (a) relê o PDF final (texto nativo + OCR das páginas com imagens/sem texto) e (b) **faz a verificação cruzada da cobertura**: OCR independente do *original* em `VERIFY_DPI`, verificando se cada CPF válido encontrado está dentro de alguma caixa de tarja. Achados, falhas da IA e páginas não verificáveis viram **alertas** por página. @@ -48,7 +56,7 @@ Estados da tarefa (texto de status livre mais um percentual de progresso): `Inic ## API HTTP (app_service) -`GET /` UI · `GET /readme` · `POST /upload` · `GET /tasks` · `POST /reprocess/{id}` · `GET /logs/{id}` · `POST /process-all` · `DELETE /delete-all` · `GET /previews/{id}/{page}` · `GET /metadata/{id}` · `POST /update-redactions/{id}` · `POST /reprocess-metadata/{id}` · `POST /finalize-native/{id}` · `POST /finalize/{id}` (raster legado) · `GET /download/{id}` · `DELETE /task/{id}` · `POST /purge/{id}` (remove as imagens de página não tarjadas após a aprovação) · `POST /internal/update/{id}` (somente workers). Os ids de tarefa são UUIDs e são validados. +`GET /` UI · `GET /readme` · `POST /upload` · `GET /tasks` · `POST /reprocess/{id}` · `GET /logs/{id}` · `POST /process-all` · `DELETE /delete-all` · `GET /previews/{id}/{page}` · `GET /metadata/{id}` · `POST /update-redactions/{id}` · `POST /reprocess-metadata/{id}` · `POST /finalize-native/{id}` · `POST /finalize/{id}` (raster legado) · `GET /download/{id}` · `DELETE /task/{id}` · `POST /purge/{id}` (remove as imagens de página não tarjadas após a aprovação) · `POST /internal/update/{id}` (somente workers). Os ids de tarefa são UUIDs e são validados. · `GET /policy/catalog` e `GET /policy/profiles` (catálogo e perfis, só leitura) ## Outros diretórios diff --git a/docs/limitations.md b/docs/limitations.md index 316c07c..f9ab142 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -4,6 +4,7 @@ Seja explícito sobre o que esta ferramenta não consegue fazer. Sempre combine ## Escopo da tarja * **O que é tarjado depende do perfil de política** (`POLICY_PROFILE`, ver [catálogo](catalogo-pii.md)). O padrão (`cpf_endereco`) cobre só CPF e endereço residencial; os perfis LGPD, GDPR e saúde acrescentam RG, CNH, título de eleitor, PIS/NIS, Cartão SUS, passaporte, CTPS, telefone, e-mail, dados bancários, chave Pix, data de nascimento, placa e IP, detectados por regras (dígito verificador, formato e palavra de contexto). **Não são detectados em nenhum perfil:** nomes, filiação, fotografias/rostos, dados sensíveis (saúde, religião, opinião política...), QR codes/códigos de barras, as próprias assinaturas (apenas CPFs próximos a elas) e metadados/anexos/anotações/marcadores do PDF (inspecione esses itens separadamente). +* A busca de CPF junta dígitos de palavras e linhas vizinhas: um número qualquer de 11 dígitos que passe por acaso no dígito verificador (ex.: alguns telefones, valores quebrados em linhas) também vira tarja de CPF e pode gerar um alerta "CPF ainda detectável". O erro fica do lado seguro (tarja a mais) e a revisão humana resolve; a regra não foi afrouxada para não perder CPF verdadeiro. * Os detectores por regra só foram testados com valores gerados (sem medida de revocação em documentos reais). A verificação pós-tarja confere só CPF: os outros tipos dependem da revisão humana. * Nomes de pessoas deliberadamente **não** são tarjados (uma regra de negócio do caso de uso original), o que pode tornar um indivíduo identificável mesmo sem CPF/endereço. diff --git a/main.py b/main.py index 03f6f56..2f3bf38 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from utils.transform_pdf_to_img import transform_pdf_to_img -from utils.session import Session +from utils.session import Session, render_dpi from utils.ocr_engine import OCREngine, ocr_workers from utils.yolo_engine import YOLOEngine from utils.address_redactor import AddressRedactor @@ -27,6 +27,23 @@ os.environ["OMP_NUM_THREADS"] = "4" os.environ["MKL_NUM_THREADS"] = "4" +def _cluster_boxes(boxes): + """Agrupa caixas [x, y, w, h] da mesma linha e próximas em retângulos [x0, y0, x1, y1].""" + rects = sorted(([b[0], b[1], b[0] + b[2], b[1] + b[3]] for b in boxes), key=lambda r: (r[1], r[0])) + clusters = [] + for r in rects: + for c in clusters: + height = max(c[3] - c[1], r[3] - r[1], 1) + same_line = min(c[3], r[3]) - max(c[1], r[1]) > -0.3 * height + close = r[0] - c[2] < 2.5 * height and c[0] - r[2] < 2.5 * height + if same_line and close: + c[0], c[1], c[2], c[3] = min(c[0], r[0]), min(c[1], r[1]), max(c[2], r[2]), max(c[3], r[3]) + break + else: + clusters.append(r) + return clusters + + class SentryApp: def __init__(self, pdf_path, output_dir=None, final_dir=None): # 1. Initialize Management @@ -44,7 +61,7 @@ def __init__(self, pdf_path, output_dir=None, final_dir=None): # 3. Armazenamento de Estado # 300 DPI: melhor revocação E mais rápido nas medições (docs/benchmarks.md, 2026-10-04) - self.base_dpi = int(os.getenv("BASE_DPI", "300")) + self.base_dpi = render_dpi() self.image_paths = [] self.grounding_maps = [] self.grounding_maps_sparse = [] @@ -73,7 +90,8 @@ def add_review(self, page_num, reason, boxes=None): if reason not in self.review_pages[page_num]: self.review_pages[page_num].append(reason) for box in boxes or []: - mark = {"page": page_num, "reason": mask_text(reason), "box": [round(float(v), 4) for v in box]} + mark = {"page": page_num, "reason": mask_text(reason), + "box": [round(min(1.0, max(0.0, float(v))), 4) for v in box]} if mark not in self.review_marks: self.review_marks.append(mark) msg = mask_text(f"Pág {page_num}: {reason}") @@ -100,6 +118,14 @@ def needs_review(self): def final_state(self): """Estado final reportado ao serviço: só é 'Concluído' se nada exige revisão.""" + state = self._final_state() + if getattr(self, "manual_mode", False): + # Finalização (tarjas do revisor): o resumo de tipos, o perfil e os tempos continuam os do processamento + for key in ("pii_summary", "pii_found", "policy_profile", "timings"): + state.pop(key, None) + return state + + def _final_state(self): if self.needs_review: return { "status": "Requer revisão", @@ -121,6 +147,7 @@ def run(self, progress_callback=None, manual_redactions=None, native_mode=False) """Executa o pipeline completo com suporte a notificações de progresso.""" self.progress_callback = progress_callback + self.manual_mode = manual_redactions is not None # finalização: não sobrescreve resumo/tempos do processamento if native_mode and manual_redactions is not None: self.logger.info("[!] RETARJAMENTO TOTAL ATIVADO: Aplicando Tarjas diretamente no PDF Nativo.") self._load_metadata_safely(manual_redactions) @@ -241,14 +268,14 @@ def _native_text_maps(self, total): Passada EXTRA: palavras do texto digital do PDF (quando existe), já na escala de pixels das imagens. Não substitui o OCR; soma a ele (texto digital não tem erro de leitura). """ - from utils.verifier import _words_to_grounding + from utils.verifier import _words_to_grounding, page_words maps = [[] for _ in range(total)] try: import fitz scale = self.base_dpi / 72.0 with fitz.open(self.session.pdf_path) as doc: for i in range(min(total, len(doc))): - grounding = _words_to_grounding(doc[i].get_text("words")) + grounding = _words_to_grounding(page_words(doc[i])) # coordenadas da página girada for w in grounding: b = w["box"] w["box"] = {"x": b["x"] * scale, "y": b["y"] * scale, "w": b["w"] * scale, "h": b["h"] * scale} @@ -435,7 +462,8 @@ def apply_address_decisions(self): self.logger.info(f"[decisions] {len(log)} endereço(s) avaliados pelo decisor local (modo {service.mode}).") def _relative_boxes(self, page_num, boxes): - """[x, y, w, h] em pixels da imagem da página -> uma região [x0, y0, x1, y1] relativa que envolve todas.""" + """[x, y, w, h] em pixels da imagem da página -> regiões [x0, y0, x1, y1] relativas, uma por ocorrência + (palavras vizinhas na mesma linha formam uma região; ocorrências distantes ficam separadas).""" if not boxes or page_num > len(self.image_paths): return [] try: @@ -444,11 +472,9 @@ def _relative_boxes(self, page_num, boxes): width, height = im.size except Exception: return [] - x0 = min(b[0] for b in boxes) - y0 = min(b[1] for b in boxes) - x1 = max(b[0] + b[2] for b in boxes) - y1 = max(b[1] + b[3] for b in boxes) - return [[x0 / width, y0 / height, x1 / width, y1 / height]] + clamp = lambda v: min(1.0, max(0.0, v)) # noqa: E731 (nunca desenhar fora da página) + return [[clamp(x0 / width), clamp(y0 / height), clamp(x1 / width), clamp(y1 / height)] + for x0, y0, x1, y1 in _cluster_boxes(boxes)] def _address_word_boxes(self, page_num, text): """Caixas [x, y, w, h] das palavras do endereço na página (para aprender com a revisão).""" @@ -685,7 +711,10 @@ def _load_metadata_safely(self, manual_redactions): box = { "x": coords[0], "y": coords[1], "w": coords[2]-coords[0], "h": coords[3]-coords[1] } if source_w: box["source_width"] = float(source_w) self.global_redactions[page_num].append(box) - if rtype == "pii": self.address_redactions[page_num].append(box) + label = red.get("label") + if label: + self.box_labels[page_num][f"{box['x']}_{box['y']}_{box['w']}_{box['h']}"] = str(label) + if rtype == "pii" and label == "Endereço residencial": self.address_redactions[page_num].append(box) elif rtype == "signature": self.cpf_redactions[page_num].append(box) except Exception as e: self.logger.error(f"Erro ao analisar tarja ({source}): {e}") @@ -736,12 +765,13 @@ def run_phase_5(self): bh = box['h'] box_sig = f"{bx}_{by}_{bw}_{bh}" - if box_sig in addr_boxes: - rtype, label = 'pii', "Endereço residencial" - elif box_sig in cpf_boxes: - rtype, label = 'signature', "CPF" + known = self.box_labels.get(page_num, {}).get(box_sig) # rótulo explícito vence a inferência + if box_sig in cpf_boxes: + rtype, label = 'signature', known or "CPF" + elif box_sig in addr_boxes: + rtype, label = 'pii', known or "Endereço residencial" else: - rtype, label = 'pii', self.box_labels.get(page_num, {}).get(box_sig) + rtype, label = 'pii', known frontend_redactions.append({ "page": page_num, diff --git a/pyproject.toml b/pyproject.toml index eab559c..9e46698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "privio-sentry" -version = "5.3.0" +version = "5.4.0" description = "PRIVIO SENTRY - Local AI Privacy Infrastructure. SENTRY Redact: local OCR + YOLO + vision LLM pipeline that suggests redactions of CPF numbers and personal addresses in PDFs, with human review. Supports LGPD-aligned practices; does not guarantee anonymization." readme = "README.md" requires-python = ">=3.10" diff --git a/templates/index.html b/templates/index.html index f43f2a1..e5fce3c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -139,8 +139,8 @@ .task-time { font-size:12px; color:var(--slate); margin-top:4px; cursor:help; } .redaction-layer { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; } /* Região que a verificação pediu para revisar (onde está o problema, não uma tarja) */ - .review-mark { position:absolute; border:3px dashed var(--warning-strong); background:rgba(247,144,9,0.12); border-radius:4px; pointer-events:auto; z-index:3; cursor:help; } - .review-mark .review-mark-label { position:absolute; top:-22px; left:-3px; white-space:nowrap; font-size:12px; font-weight:700; line-height:18px; padding:0 6px; border-radius:4px; background:var(--warning-strong); color:var(--white); } + .review-mark { position:absolute; border:3px dashed var(--warning-strong); background:rgba(247,144,9,0.12); border-radius:4px; pointer-events:none; z-index:3; } + .review-mark .review-mark-label { pointer-events:auto; cursor:help; position:absolute; top:-22px; left:-3px; white-space:nowrap; font-size:12px; font-weight:700; line-height:18px; padding:0 6px; border-radius:4px; background:var(--warning-strong); color:var(--white); } .review-mark.flash { animation: review-flash 0.9s ease-in-out 3; } @keyframes review-flash { 0%, 100% { box-shadow: 0 0 0 0 rgba(247,144,9,0); } 50% { box-shadow: 0 0 0 10px rgba(247,144,9,0.45); } } @media (prefers-reduced-motion: reduce) { .review-mark.flash { animation: none; outline: 4px solid var(--warning-strong); } } @@ -396,6 +396,7 @@

Log de auditoria da tarefa

'pending.region': 'Páginas pendentes de revisão', 'pending.title': 'Requer revisão', 'pending.only': 'Só páginas pendentes', 'pending.page': 'Pág', 'review.mark': 'Revisar aqui', 'ok.finished': 'Processamento concluído: resultado carregado.', 'warn.finished': 'O processamento terminou. Salve suas edições e reabra o documento para ver o resultado.', + 'warn.failed': 'O processamento terminou com erro. Veja o log da tarefa (menu ⋮ › Ver logs).', 'empty.title': 'Nenhum documento ainda', 'empty.sub': 'Adicione um PDF para iniciar a detecção.', 'step.detect.title': 'Detectar', 'step.detect.body': 'OCR, regras de validação e modelos locais sugerem possíveis CPFs, endereços e assinaturas.', 'step.review.title': 'Revisar', 'step.review.body': 'Cada sugestão aparece como uma região editável. Ajuste, mova ou remova antes de continuar.', @@ -443,6 +444,7 @@

Log de auditoria da tarefa

'pending.region': 'Pages pending review', 'pending.title': 'Needs review', 'pending.only': 'Pending pages only', 'pending.page': 'Page', 'review.mark': 'Review here', 'ok.finished': 'Processing finished: result loaded.', 'warn.finished': 'Processing finished. Save your edits and reopen the document to see the result.', + 'warn.failed': 'Processing finished with an error. See the task log (⋮ menu › View logs).', 'empty.title': 'No documents yet', 'empty.sub': 'Add a PDF to begin detection.', 'step.detect.title': 'Detect', 'step.detect.body': 'OCR, validation rules and local models suggest potential CPF numbers, addresses and signatures.', 'step.review.title': 'Review', 'step.review.body': 'Each suggestion appears as an editable region. Adjust, move or remove it before continuing.', @@ -625,6 +627,7 @@

Log de auditoria da tarefa

const label = document.createElement('span'); label.className = 'review-mark-label'; label.textContent = `⚠ ${t('review.mark')}`; + label.title = String(m.reason || ''); // a região deixa os cliques passarem; o motivo fica na etiqueta el.appendChild(label); layer.appendChild(el); }); @@ -781,6 +784,7 @@

Log de auditoria da tarefa

const finishedNow = openTaskWasDone === false && done; openTaskWasDone = done; if (!finishedNow) return; + if (tk.error) { toast(t('warn.failed')); return; } if (JSON.stringify(redactions) !== loadedRedactionsSig) { toast(t('warn.finished')); return; } await loadMetadata(); initDocumentView(tk.total || 1); @@ -789,6 +793,7 @@

Log de auditoria da tarefa

} async function selectTask(id, name) { + openTaskWasDone = null; // antes de qualquer await: o estado da tarefa anterior não pode disparar recarga currentTaskId = id; currentPage = 1; zoom = 0.8; redactions = []; // Limpa tarjas do documento anterior if(window.innerWidth < 1024) document.querySelector('.sidebar').classList.remove('open'); diff --git a/tests/test_counter_analysis.py b/tests/test_counter_analysis.py new file mode 100644 index 0000000..763da6e --- /dev/null +++ b/tests/test_counter_analysis.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Contra-análise: tenta QUEBRAR os detectores e as garantias do pipeline com casos adversos. + +Falsos positivos em texto administrativo comum, entradas aleatórias e gigantes (tempo limitado, sem regex +explosivo), acentos/caracteres especiais (mapeamento de caracteres), propriedades gerais (só o valor é +tarjado; regiões dentro da página) e todos os perfis rodando o pipeline. +""" +import random +import string +import time + +import pytest + +from main import SentryApp +from sentry_testkit import CPF_A, CPF_A_FMT, make_text_pdf, word +from utils.detect import catalog, profiles, rules + +ALL_RULE_TYPES = list(rules.RULES_BY_TYPE) + + +def grounding(*texts): + return [word(i, t, 100 * i) for i, t in enumerate(texts)] + + +def words(text): + return grounding(*text.split()) + + +# --- 1. falsos positivos: frases comuns em documentos oficiais que NÃO devem virar PII ---------------------- +NOT_PII = [ + "Lei nº 13.709/2018 (LGPD) e Decreto nº 10.046/2019", + "Processo nº 0001234-56.2024.8.11.0041 em trâmite na comarca", + "valor total de R$ 1.500,00 (mil e quinhentos reais) pago em 3 parcelas", + "Empresa Exemplo Ltda, CNPJ 00.000.000/0001-91", + "CEP 78000-000 Cuiabá - MT", + "reunião às 14:30 do dia 05/03/2024 na sala 12", + "certificação ISO 9001 e ISO 27001 vigentes", + "fls. 12 a 15 do volume 2, protocolo 2024/000123", + "assinado eletronicamente em 01/02/2024 às 10:15", + "conforme a prestação de contas de 2024 aprovada em assembleia", + "artigo 5º, incisos I e II, parágrafo único", + "edital 001/2024, item 3.2.1", +] + + +@pytest.mark.parametrize("text", NOT_PII) +def test_common_administrative_text_is_not_pii(text): + found = rules.find_in_grounding(words(text), ALL_RULE_TYPES) + assert found == {}, {t: v for t, (_, v) in found.items()} + + +# --- 2. formatos reais que DEVEM ser achados ----------------------------------------------------------------- +SHOULD_FIND = [ + ("telefone", "Telefone: (65) 3321-1234 (fixo)"), + ("telefone", "celular +55 65 99999-1234"), + ("telefone", "WhatsApp 65 99999-1234"), + ("email", "e-mail: maria.exemplo@example.com"), + ("rg", "portador do RG nº 1.234.567 SSP/MT"), + ("data_nascimento", "nascida em 03 de março de 1985"), + ("conta_bancaria", "Ag. 1234-5 C/C 98765-4"), + ("placa_veiculo", "veículo de placa ABC-1234"), +] + + +@pytest.mark.parametrize("type_id, text", SHOULD_FIND) +def test_real_world_formats_are_found(type_id, text): + assert type_id in rules.find_in_grounding(words(text), [type_id]), text + + +# --- 3. robustez: lixo aleatório e texto gigante não quebram nem travam ------------------------------------- +def test_random_garbage_never_crashes(): + rng = random.Random(7) + alphabet = string.ascii_letters + string.digits + " .,-/()@:+ºªçãéÇÃ\t" + "0123456789" * 3 + for _ in range(300): + tokens = ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 15))) for _ in range(rng.randint(1, 40))] + gmap = grounding(*[t for t in tokens if t.strip()] or ["x"]) + for type_id, (commands, _values) in rules.find_in_grounding(gmap, ALL_RULE_TYPES).items(): + for wid, chars in commands.items(): + assert 0 <= wid < len(gmap) + assert all(0 <= c < len(gmap[wid]["text"]) for c in chars), (type_id, gmap[wid]["text"], chars) + + +@pytest.mark.parametrize("unit", ["1 ", "12.", "a-1 ", "(65) ", "9" * 30 + " ", "@a.b ", "RG 1 "]) +def test_huge_page_runs_in_bounded_time(unit): + gmap = words(unit * 6000) # dezenas de milhares de caracteres: um regex com retrocesso explosivo travaria + started = time.time() + rules.find_in_grounding(gmap, ALL_RULE_TYPES) + assert time.time() - started < 10 + + +# --- 4. acentos e caracteres especiais: a tarja cai nos caracteres certos ------------------------------------ +@pytest.mark.parametrize("prefix", ["Telefône", "Telefone", "Teléfono:", "Tel.", "Tel", "ficha tel"]) +def test_unicode_context_keeps_character_mapping(prefix): + gmap = grounding(*prefix.split(), "(65)", "99999-1234") + res = rules.find_in_grounding(gmap, ["telefone"]) + assert "telefone" in res + commands, _ = res["telefone"] + value_ids = {len(prefix.split()), len(prefix.split()) + 1} + assert set(commands) == value_ids # só as palavras do valor + + +def test_context_words_are_never_redacted(): + cases = [("rg", "RG 1.234.567"), ("cnh", "CNH 98765432199"), ("passaporte", "passaporte FT123456"), + ("data_nascimento", "nascido em 01/02/1980"), ("conta_bancaria", "conta 12345-6")] + for type_id, text in cases: + gmap = words(text) + commands, _ = rules.find_in_grounding(gmap, [type_id])[type_id] + assert 0 not in commands, (type_id, text) + + +# --- 5. CPF: os novos detectores não substituem nem atrapalham a busca de CPF ------------------------------- +def test_cpf_is_still_found_by_cpf_detector_with_any_profile(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + for pid in profiles.PROFILES: + app = SentryApp(make_text_pdf(tmp_path / f"{pid}.pdf")) + app.run_phase_0() + app.grounding_maps = [grounding("CPF:", CPF_A_FMT, "Tel", "(65)", "98888-7777")] # telefone que NÃO é CPF válido + app.grounding_maps_sparse = [[]] + app.grounding_maps_native = [[]] + app.policy_profile = pid + app.run_phase_2() + app.run_policy_phase() + assert app.cpf_redactions[1], pid + assert app.known_cpfs == [CPF_A] and app.pii_summary["cpf"] == 1, pid + assert all(0 <= v <= 1 for m in app.review_marks for v in m["box"]), pid + app.session.close() + + +def test_phone_that_happens_to_pass_cpf_check_is_treated_as_cpf_safe_side(tmp_path, monkeypatch): + """Limitação conhecida (docs/limitations.md): um telefone cujos 11 dígitos passam por acaso no DV de CPF é + tarjado também como CPF. O erro fica do lado seguro (tarja a mais); afrouxar a regra de CPF arriscaria perder + CPF verdadeiro. Se este teste mudar, a decisão precisa ser revista conscientemente.""" + from utils.ocr_engine import OCREngine + from utils.validators import is_valid_cpf + # O número é gerado aqui (nenhum valor que passe no DV de CPF fica escrito no código: auditoria I-01) + phone = next(f"6599999{n:04d}" for n in range(10000) if is_valid_cpf(f"6599999{n:04d}")) + gmap = grounding("Tel", "(65)", f"{phone[2:7]}-{phone[7:]}") + _, found = OCREngine(tesseract_path=None).find_cpfs_in_grounding(gmap) + assert found == {phone} + + +def test_policy_phase_tolerates_missing_extra_maps(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + app = SentryApp(make_text_pdf(tmp_path / "d.pdf", n_pages=2)) + app.run_phase_0() + app.grounding_maps = [grounding("Tel", "(65)", "99999-1234"), grounding("nada")] + app.grounding_maps_sparse = [] # listas mais curtas que as páginas: não pode quebrar + app.grounding_maps_native = [] + app.policy_profile = "lgpd_publicacao" + app.run_policy_phase() + assert app.pii_summary == {"telefone": 1} + app.session.close() + + +# --- 6. catálogo × perfis × detectores: nada fica pela metade ---------------------------------------------- +def test_every_profile_action_is_valid_and_runnable_types_have_rules(): + for pid in profiles.PROFILES: + for type_id, action in profiles.runnable_actions(pid).items(): + assert action in (profiles.TARJAR, profiles.ALERTAR) + assert type_id in rules.RULES_BY_TYPE or type_id in ("cpf", "endereco_residencial"), (pid, type_id) + + +def test_sensitive_types_are_never_silently_redacted(): + # Dados sensíveis dependem de contexto: nos perfis, só ALERTAR (revisão humana), nunca tarja automática + sensitive = {t.id for t in catalog.CATALOG if t.nivel == catalog.SENSIVEL} + for pid, p in profiles.PROFILES.items(): + for type_id, action in p["acoes"].items(): + if type_id in sensitive: + assert action == profiles.ALERTAR, (pid, type_id) + + +# --- 7. concorrência: falha e exceção ficam na página que as causou ---------------------------------------- +def test_thread_failure_counters_are_isolated(): + import threading + from utils.ocr_engine import OCREngine + engine = OCREngine(tesseract_path=None) + seen = {} + barrier = threading.Barrier(2) + + def worker(name, fail): + barrier.wait() + if fail: + engine._record_failure() + barrier.wait() # as duas threads leem depois que a falha aconteceu + seen[name] = engine.thread_failures() + + threads = [threading.Thread(target=worker, args=("falha", True)), threading.Thread(target=worker, args=("ok", False))] + for t in threads: + t.start() + for t in threads: + t.join() + assert seen == {"falha": 1, "ok": 0} and engine.failure_count == 1 + + +def test_exception_in_one_page_does_not_hide_the_others(): + from utils.ocr_engine import OCREngine + from utils.verifier import _ocr_images + + class Boom(OCREngine): + def get_grounding_map(self, img_path, psm=3): + if img_path == "b": + raise RuntimeError("tesseract morreu") + return "", [word(0, CPF_A_FMT, 0)] + + out = _ocr_images(Boom(tesseract_path=None), {1: "a", 2: "b", 3: "c"}, "6", workers=3) + assert out[1][1] is None and out[3][1] is None + assert isinstance(out[2][1], RuntimeError) and out[2][0] is None # página 2 vira "não verificada" + + +# --- 8. regressões da revisão independente (2026-10-04) ------------------------------------------------------ +def _types(text, *type_ids): + return set(rules.find_in_grounding(words(text), list(type_ids or ALL_RULE_TYPES))) + + +def test_r5_phone_is_not_the_tail_of_a_longer_number(): + assert "telefone" not in _types("tel 12345678901234", "telefone") + + +@pytest.mark.parametrize("text", ["Telefone: 3321-1234", "Fone 99999-1234", "Tel.: (065) 3321-1234"]) +def test_r6_phone_without_ddd_or_with_operator_zero(text): + assert "telefone" in _types(text, "telefone") + + +def test_r6_operator_zero_ddd_is_fully_covered(): + gmap = words("Tel.: (065) 3321-1234") + commands, _ = rules.find_in_grounding(gmap, ["telefone"])["telefone"] + assert commands[1] == set(range(len("(065)"))) # "(065)" inteiro, não só "65)" + + +def test_r6_ordinal_birth_date(): + assert "data_nascimento" in _types("Data de nascimento 1º de março de 1980", "data_nascimento") + + +def test_r6_unlabelled_8_digit_number_is_not_a_phone(): + assert "telefone" not in _types("protocolo 3321-1234", "telefone") + + +def test_r7_cpf_after_rg_label_is_not_counted_as_rg(): + gmap = words(f"RG nº 1234567 SSP/MT, CPF {CPF_A_FMT}") + commands, values = rules.find_in_grounding(gmap, ["rg"])["rg"] + assert values == {"1234567"} + + +def test_r7_issue_date_is_not_birth_date(): + commands, values = rules.find_in_grounding(words("Nascimento: 01/02/1980 Emissão: 05/06/2010"), + ["data_nascimento"])["data_nascimento"] + assert values == {"01/02/1980"} + + +@pytest.mark.parametrize("text", ["norma ISO-9001", "NBR-1406 da ABNT", "código ABC-1234"]) +def test_r7_old_plate_format_needs_context(text): + assert "placa_veiculo" not in _types(text, "placa_veiculo") + + +def test_r7_old_plate_with_context_and_mercosul_alone(): + assert "placa_veiculo" in _types("placa ABC-1234", "placa_veiculo") + assert "placa_veiculo" in _types("ABC1D23", "placa_veiculo") + + +def test_r2_traceback_and_object_args_are_masked(): + import logging + from utils.pii import ArgsMaskingFilter + try: + raise FileNotFoundError(f"Requerimento {CPF_A_FMT}.pdf") + except FileNotFoundError: + import sys + record = logging.LogRecord("uvicorn.error", logging.ERROR, __file__, 1, "erro %s %d", + (RuntimeError(f"cpf {CPF_A_FMT}"), 500), sys.exc_info()) + ArgsMaskingFilter().filter(record) + full = record.getMessage() + (record.exc_text or "") + assert CPF_A_FMT not in full and "500" in record.getMessage() and "FileNotFoundError" in record.exc_text + + +@pytest.mark.parametrize("raw, expected", [("", 300), ("40", 40), ("10", 300), ("3000", 300), ("abc", 300), ("600", 600)]) +def test_r11_single_dpi_parser(monkeypatch, raw, expected): + from utils.session import render_dpi + monkeypatch.setenv("BASE_DPI", raw) + assert render_dpi() == expected + + +def test_r1_native_text_boxes_follow_page_rotation(tmp_path): + """A caixa do texto digital precisa cair sobre o texto DESENHADO na imagem, mesmo com /Rotate.""" + import fitz + from PIL import Image + from utils.verifier import _words_to_grounding, page_words + pdf = str(tmp_path / "girada.pdf") + with fitz.open() as doc: + page = doc.new_page(width=600, height=800) + page.insert_text((50, 100), f"CPF {CPF_A_FMT}", fontsize=14) + page.set_rotation(90) + doc.save(pdf) + with fitz.open(pdf) as doc: + page = doc[0] + pix = page.get_pixmap(alpha=False) # 72 DPI: 1 ponto = 1 pixel + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples).convert("L") + grounding = _words_to_grounding(page_words(page)) + cpf_word = next(w for w in grounding if CPF_A_FMT in w["text"]) + b = cpf_word["box"] + crop = img.crop((int(b["x"]), int(b["y"]), int(b["x"] + b["w"]) + 1, int(b["y"] + b["h"]) + 1)) + assert crop.getextrema()[0] < 128, "a caixa precisa cobrir pixels escuros do texto, não área em branco" + + +def test_r8_one_review_region_per_occurrence(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "72") + app = SentryApp(make_text_pdf(tmp_path / "d.pdf")) + app.run_phase_0() + # duas ocorrências distantes (topo e rodapé) + duas palavras vizinhas da mesma ocorrência + boxes = [[50, 50, 40, 12], [95, 50, 60, 12], [50, 780, 40, 12]] + regions = app._relative_boxes(1, boxes) + assert len(regions) == 2 + top, bottom = sorted(regions, key=lambda r: r[1]) + assert top[3] < 0.2 and bottom[1] > 0.8 # nenhuma região cobre a página inteira + app.session.close() + + +def test_r9_finalize_keeps_processing_summary_and_labels(tmp_path, monkeypatch): + monkeypatch.setenv("BASE_DPI", "40") + app = SentryApp(make_text_pdf(tmp_path / "d.pdf")) + app.run_phase_0() + app.manual_mode = True + app.pii_summary = {"telefone": 1} + state = app.final_state() + for key in ("pii_summary", "pii_found", "policy_profile", "timings"): + assert key not in state, key # não sobrescreve o que o processamento gravou + app._load_metadata_safely([{"page": 1, "type": "pii", "coords": [1, 1, 30, 10], "label": "Telefone"}]) + app.run_phase_5() + import json + import os + meta = json.load(open(os.path.join(app.session.output_dir, "redactions_metadata.json"), encoding="utf-8")) + assert [m["label"] for m in meta] == ["Telefone"] # antes virava "Endereço residencial" + app.session.close() + + +def test_r13_verifier_works_in_chunks_and_reports_progress(tmp_path): + import fitz + from PIL import Image + from utils.ocr_engine import OCREngine + from utils.verifier import verify_pdf + img = tmp_path / "p.png" + Image.new("RGB", (60, 80), "white").save(img) + pdf = str(tmp_path / "longo.pdf") + with fitz.open() as doc: + for _ in range(23): + doc.new_page().insert_image(fitz.Rect(0, 0, 595, 842), filename=str(img)) + doc.save(pdf) + seen_files, progress = [], [] + + class CountingOCR(OCREngine): + def get_grounding_map(self, img_path, psm=3): + import os as _os + seen_files.append(len(_os.listdir(_os.path.dirname(img_path)))) + return "", [] + + leftovers, unverified = verify_pdf(pdf, ocr_engine=CountingOCR(tesseract_path=None), dpi=36, workers=2, + progress=lambda done, total: progress.append(done)) + assert leftovers == {} and unverified == [] + assert max(seen_files) <= 4 # nunca mais que um lote em disco + assert progress == [10, 20, 23] # progresso avança durante o OCR diff --git a/tests/test_detect.py b/tests/test_detect.py index 624dbd3..3338e75 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -36,11 +36,14 @@ def make_titulo(uf="11"): def make_card(): + # Dígito de Luhn calculado AQUI, de forma independente do validador (dobra da direita para a esquerda) d = [4] + [RNG.randint(0, 9) for _ in range(14)] - for check in range(10): - n = "".join(map(str, d)) + str(check) - if v.is_valid_luhn(n): - return n + total = 0 + for i, n in enumerate(reversed(d)): + if i % 2 == 0: # posições que serão dobradas quando o dígito verificador entrar à direita + n = n * 2 - 9 if n * 2 > 9 else n * 2 + total += n + return "".join(map(str, d)) + str((10 - total % 10) % 10) def mutate(number): @@ -104,6 +107,21 @@ def test_validators_accept_generated_and_reject_mutated(make, check): assert not check(mutate(n)), mutate(n) +# Vetores fixos, conferidos à mão (não dependem dos geradores acima) +def test_fixed_vectors_checked_by_hand(): + # PIS 1203456789-?: 1*3+2*2+0*9+3*8+4*7+5*6+6*5+7*4+8*3+9*2 = 189; 189 % 11 = 2; 11 - 2 = 9 + assert v.is_valid_pis("12034567899") and not v.is_valid_pis("12034567898") + # CNS 100000000000007: 1*15 + 7*1 = 22, múltiplo de 11 + assert v.is_valid_cns("100000000000007") and not v.is_valid_cns("100000000000008") + # Título SP (UF 01): sequência 10000001 -> 1*2 + 1*9 = 11, resto 0 -> regra SP/MG: dv1 = 1; + # dv2 = (0*7 + 1*8 + 1*9) % 11 = 6 -> 100000010116. Pela regra geral seria dv1 = 0 (inválido aqui). + assert v.is_valid_titulo_eleitor("100000010116") + assert not v.is_valid_titulo_eleitor("100000010105") + # Números de teste públicos das bandeiras (Luhn válido) + assert v.is_valid_luhn("4111111111111111") and v.is_valid_luhn("5555555555554444") + assert not v.is_valid_luhn("4111111111111112") + + def test_validators_reject_degenerate_numbers(): assert not v.is_valid_pis("00000000000") assert not v.is_valid_cns("000000000000000") diff --git a/tests/test_main_phases.py b/tests/test_main_phases.py index c646055..12a8ddf 100644 --- a/tests/test_main_phases.py +++ b/tests/test_main_phases.py @@ -295,7 +295,9 @@ def test_load_metadata_orders_images_and_maps_redaction_types(app): for n in (10, 2, 1): make_blank_image(os.path.join(orig, f"doc_page_{n}.png"), size=(10, 10)) reds = [ - {"page": 1, "type": "pii", "coords": [10, 20, 110, 70], "source": "AI_Engine", "image_width": 800}, + {"page": 1, "type": "pii", "coords": [10, 20, 110, 70], "source": "AI_Engine", "image_width": 800, + "label": "Endereço residencial"}, + {"page": 1, "type": "pii", "coords": [200, 20, 260, 40], "source": "AI_Engine", "label": "Telefone"}, {"page": "2", "type": "signature", "coords": [0, 0, 5, 5]}, {"page": 1, "type": "outro", "coords": [1, 1, 2, 2]}, {"page": 1, "type": "pii", "coords": [1, 2, 3]}, # coords invalidas: ignorada @@ -303,9 +305,11 @@ def test_load_metadata_orders_images_and_maps_redaction_types(app): ] app._load_metadata_safely(reds) assert [os.path.basename(p) for p in app.image_paths] == ["doc_page_1.png", "doc_page_2.png", "doc_page_10.png"] + # só o que veio rotulado como endereço é endereço (antes, telefone/e-mail viravam "Endereço residencial") assert app.address_redactions[1] == [{"x": 10, "y": 20, "w": 100, "h": 50, "source_width": 800.0}] + assert app.box_labels[1]["200_20_60_20"] == "Telefone" assert app.cpf_redactions[2] == [{"x": 0, "y": 0, "w": 5, "h": 5}] - assert len(app.global_redactions[1]) == 2 and len(app.global_redactions[2]) == 1 + assert len(app.global_redactions[1]) == 3 and len(app.global_redactions[2]) == 1 def test_load_metadata_without_original_images_dir(app): diff --git a/tests/ui/test_ui_review_marks.py b/tests/ui/test_ui_review_marks.py index 7a9a682..a3208f2 100644 --- a/tests/ui/test_ui_review_marks.py +++ b/tests/ui/test_ui_review_marks.py @@ -86,3 +86,33 @@ def test_task_card_shows_processing_time(page, live_app, open_app): t = page.locator("[data-testid=task-time]") expect(t).to_have_text("⏱ 1 min 6 s") expect(t).to_have_attribute("title", "OCR Scanning: 61.4 s\nRenderizando PDF: 4.2 s") + + +# --- regressões da revisão independente ----------------------------------------------------------------- +def test_r3_boxes_under_a_review_mark_stay_editable(page, live_app, open_task): + # região a revisar exatamente sobre uma tarja: clicar na tarja precisa selecioná-la + mark = {"page": 1, "reason": "CPF ainda detectável", "box": [0.1, 0.08, 0.5, 0.2]} + tid = live_app.seed_task("sob.pdf", status="Requer revisão", needs_review=True, alerts=["Pág 1: CPF"], + redactions=[box(1)], extra={"review_marks": [mark]}) + open_task(tid) + target = page.locator("#layer-1 [data-testid=redaction-box]") + target.click() + expect(target).to_have_class(__import__("re").compile(r"\bselected\b")) + + +def test_r4_switching_documents_does_not_fake_a_finished_refresh(page, live_app, open_task): + running = live_app.seed_task("rodando.pdf", status="OCR: Pagina 1/2", percentage=30, completed=False, redactions=[]) + done = live_app.seed_task("pronto.pdf", redactions=[box(1)]) + open_task(running) + page.locator(f"#t-{done}").click() + page.wait_for_timeout(4000) # mais que um ciclo de atualização (3 s) + assert "Processamento concluído" not in (page.locator("#toast").text_content() or "") + assert "Salve suas edições" not in (page.locator("#toast").text_content() or "") + + +def test_r10_failed_processing_is_not_reported_as_success(page, live_app, open_task): + tid = live_app.seed_task("falha.pdf", status="Processando", percentage=40, completed=False, redactions=[]) + open_task(tid) + live_app.update_task(tid, status="Erro: Tesseract", error=True) + expect(page.locator("#toast")).to_contain_text("terminou com erro", timeout=10000) + assert "Processamento concluído" not in (page.locator("#toast").text_content() or "") diff --git a/utils/detect/rules.py b/utils/detect/rules.py index 021099d..bb7e1e9 100644 --- a/utils/detect/rules.py +++ b/utils/detect/rules.py @@ -27,6 +27,9 @@ class Rule: context_required: bool = False window: int = 40 # quantos caracteres antes do valor olhar para achar o contexto accept: Optional[Callable[[str, bool], bool]] = None # (valor, tem_contexto) -> aceita? + # Se isto aparecer ENTRE a palavra de contexto e o valor, o contexto não vale (ex.: outra data ou outro + # rótulo no meio: "Nascimento: 01/02/1980 Emissão: 05/06/2010" não faz da emissão uma data de nascimento) + stop: Optional[Pattern] = None def _norm(text): @@ -42,7 +45,13 @@ def _phone_ok(value, has_ctx): d = v.only_digits(value) if d.startswith("55") and len(d) in (12, 13): d = d[2:] - if len(d) not in (10, 11) or len(set(d)) == 1 or not 11 <= int(d[:2]) <= 99: + if d.startswith("0") and len(d) in (11, 12): # DDD com zero de operadora: (065) + d = d[1:] + if len(set(d)) == 1: + return False + if len(d) in (8, 9): # sem DDD: só com palavra de contexto ("Telefone: 3321-1234") + return has_ctx and (len(d) == 8 or d[0] == "9") + if len(d) not in (10, 11) or not 11 <= int(d[:2]) <= 99: return False if len(d) == 11 and d[2] != "9": # celular com 9 dígitos começa com 9 return False @@ -61,17 +70,27 @@ def _cns_ok(value, has_ctx): def _plate_ok(value, has_ctx): + # Só o padrão Mercosul (ABC1D23) é distintivo o bastante sozinho; o antigo (ABC-1234) confunde com + # normas e códigos ("ISO-9001", "NBR-1406") e exige palavra de contexto compact = re.sub(r"[\s\-]", "", value) - mercosul = bool(re.fullmatch(r"[A-Z]{3}\d[A-Z]\d{2}", compact)) - old_hyphen = bool(re.fullmatch(r"[A-Z]{3}-\d{4}", value.strip())) - return mercosul or old_hyphen or has_ctx + return bool(re.fullmatch(r"[A-Z]{3}\d[A-Z]\d{2}", compact)) or has_ctx + + +_CPF_FORMATTED = re.compile(r"\d{3}\.\d{3}\.\d{3}-\d{2}") + + +def _rg_ok(value, has_ctx): + # Um CPF logo depois de "RG ... ," não é RG: a busca de CPF já o trata (evita contar duas vezes) + if _CPF_FORMATTED.fullmatch(value.strip()) or v.is_valid_cpf(v.only_digits(value)): + return False + return 5 <= len(v.only_digits(value)) <= 14 _I = re.IGNORECASE RULES: List[Rule] = [ Rule("rg", re.compile(r"(?P\b\d[\d.\-]{3,13}[\dxX]\b)"), re.compile(r"\b(rg|r\.\s?g\.|identidade|registro geral)\b"), context_required=True, window=30, - accept=_digits_ok(5, 14)), + accept=_rg_ok, stop=re.compile(r"\bcpf\b|\d{3}\.\d{3}\.\d{3}")), Rule("cnh", re.compile(r"(?P\b\d{9,11}\b)"), re.compile(r"\b(cnh|habilitacao)\b"), context_required=True, window=40), Rule("titulo_eleitor", re.compile(r"(?P\b\d{4}\s?\d{4}\s?\d{4}\b)"), @@ -86,7 +105,7 @@ def _plate_ok(value, has_ctx): re.compile(r"passaporte"), context_required=True, window=30), Rule("ctps", re.compile(r"(?P\b\d{5,8}(?:\s*(?:/|serie|série)\s*\d{3,5}(?:-[A-Z]{2})?)?\b)", _I), re.compile(r"\bctps\b|carteira\s+de\s+trabalho"), context_required=True, window=30), - Rule("telefone", re.compile(r"(?P(?:\+\s?55\s?)?\(?\s?\d{2}\s?\)?\s?9?\s?\d{4}\s?[\-.]?\s?\d{4})(?!\d)"), + Rule("telefone", re.compile(r"(?(?:\+\s?55\s?)?(?:\(?\s?0?\d{2}\s?\)?\s?)?9?\s?\d{4}\s?[\-.]?\s?\d{4})(?!\d)"), re.compile(r"\b(tel|telefone|fone|celular|cel|whatsapp|contato)\b"), window=25, accept=_phone_ok), Rule("email", re.compile(r"(?P[A-Za-z0-9._%+\-]+\s?@\s?[A-Za-z0-9.\-]+\.[A-Za-z]{2,})")), Rule("ip", re.compile(r"(?P(?\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)")), Rule("data_nascimento", - re.compile(r"(?P\b\d{1,2}\s?[/.\-]\s?\d{1,2}\s?[/.\-]\s?\d{2,4}\b|\b\d{1,2}\s+de\s+[a-zç]+\s+de\s+\d{4}\b)", _I), - re.compile(r"nascid|nascimento|\bnasc\b|\bd\.?\s?n\.?\b"), context_required=True, window=40), + re.compile(r"(?P\b\d{1,2}\s?[/.\-]\s?\d{1,2}\s?[/.\-]\s?\d{2,4}\b|\b\d{1,2}[ºo°]?\s+de\s+[a-zç]+\s+de\s+\d{4}\b)", _I), + re.compile(r"nascid|nascimento|\bnasc\b|\bd\.?\s?n\.?\b"), context_required=True, window=40, + stop=re.compile(r"\d{1,2}\s?[/.\-]\s?\d{1,2}\s?[/.\-]\s?\d{2,4}|emiss|expedi|validade|vencimento|" + r"admiss|\bdata\b(?!\s+de\s+nasc)")), Rule("placa_veiculo", re.compile(r"(?P\b[A-Z]{3}[\s\-]?\d[A-Z0-9]\d{2}\b)"), re.compile(r"\bplaca\b|veiculo|renavam|automovel"), window=30, accept=_plate_ok), ] @@ -144,7 +165,10 @@ def find_in_grounding(grounding, type_ids) -> Dict[str, Tuple[Commands, Set[str] if rule.context is not None: window = norm_text[max(0, start - rule.window):start] if len(norm_text) == len(text) else \ _norm(text[max(0, start - rule.window):start]) - has_ctx = bool(rule.context.search(window)) + matches = list(rule.context.finditer(window)) + has_ctx = bool(matches) + if has_ctx and rule.stop is not None and rule.stop.search(window[matches[-1].end():]): + has_ctx = False # outro valor/rótulo entre o contexto e este valor if rule.context_required and not has_ctx: continue if rule.accept is not None and not rule.accept(value, has_ctx): diff --git a/utils/pii.py b/utils/pii.py index a0cacc8..2135115 100644 --- a/utils/pii.py +++ b/utils/pii.py @@ -72,7 +72,9 @@ class ArgsMaskingFilter(logging.Filter): @staticmethod def _clean(value): - return mask_secrets(mask_text(value)) if isinstance(value, str) else value + if isinstance(value, (int, float)) or value is None: + return value # números (ex.: status HTTP) ficam como estão: o formatador espera o tipo original + return mask_secrets(mask_text(value if isinstance(value, str) else str(value))) def filter(self, record): if isinstance(record.msg, str): @@ -81,6 +83,12 @@ def filter(self, record): record.args = tuple(self._clean(a) for a in record.args) elif isinstance(record.args, dict): record.args = {k: self._clean(v) for k, v in record.args.items()} + # Traceback (ex.: nome de arquivo com CPF numa exceção) também é mascarado + if record.exc_info: + record.exc_text = mask_secrets(mask_text("".join(traceback.format_exception(*record.exc_info)))) + record.exc_info = None + elif record.exc_text: + record.exc_text = mask_secrets(mask_text(record.exc_text)) return True diff --git a/utils/session.py b/utils/session.py index 4f31179..74dcf65 100644 --- a/utils/session.py +++ b/utils/session.py @@ -16,6 +16,11 @@ DEFAULT_FINAL_JPEG_QUALITY = 75 +def render_dpi(): + """BASE_DPI efetivo (padrão 300; fora de 30..2400 volta ao padrão). Usado por TODO o pipeline.""" + return _env_int("BASE_DPI", 300, 30, 2400) + + def _env_int(name, default, minimum, maximum=None): """Lê um inteiro do ambiente; valor ausente/inválido/fora da faixa volta ao padrão.""" raw = os.getenv(name) @@ -442,7 +447,7 @@ def apply_native_pdf_redactions(self, global_redactions, output_suffix="_NATIVO_ src_w = b.get("source_width") or b.get("image_width") if not src_w or src_w <= 0: # Sem a largura da imagem de origem: assume a resolução de renderização (BASE_DPI) - src_w = pdf_w * (_env_int("BASE_DPI", 300, 72, 2400) / 72.0) + src_w = pdf_w * (render_dpi() / 72.0) scale = pdf_w / float(src_w) diff --git a/utils/verifier.py b/utils/verifier.py index 3eedded..0ddbfc0 100644 --- a/utils/verifier.py +++ b/utils/verifier.py @@ -82,6 +82,30 @@ def find_cpfs_in_words(words, ocr_engine=None, page_size=None, boxes_out=None): return found +def page_words(page): + """ + Palavras do texto digital da página nas coordenadas da página RENDERIZADA (com /Rotate aplicado). + O PyMuPDF devolve as palavras no espaço da página sem rotação, mas get_pixmap() desenha a página girada: + sem esta conversão, em páginas rotacionadas a tarja cairia em outro lugar e o dado ficaria visível. + """ + words = page.get_text("words") + if not page.rotation: + return words + matrix = page.rotation_matrix + out = [] + for w in words: + r = (fitz.Rect(w[:4]) * matrix) + r.normalize() + out.append((r.x0, r.y0, r.x1, r.y1) + tuple(w[4:])) + return out + + +def _chunks(total, size): + """Páginas em lotes (1-based): limita imagens temporárias em disco e deixa o progresso avançar.""" + for first in range(0, total, size): + yield list(range(first + 1, min(total, first + size) + 1)) + + def verify_pdf(pdf_path, ocr_engine=None, use_ocr=True, dpi=300, psm="6", logger=None, progress=None, locations=None, workers=None): """ @@ -89,6 +113,7 @@ def verify_pdf(pdf_path, ocr_engine=None, use_ocr=True, dpi=300, psm="6", logger Páginas sem texto nativo só são checadas por OCR se use_ocr=True e ocr_engine tiver Tesseract. Também retorna a lista de páginas que não puderam ser verificadas. locations (dict opcional): recebe {page_num: [[x0, y0, x1, y1], ...]} relativos (0..1) do que foi achado. + Processa em lotes: renderiza (sequencial: o documento do PyMuPDF não é thread-safe), faz OCR em paralelo. """ leftovers = {} unverified = [] @@ -96,57 +121,60 @@ def verify_pdf(pdf_path, ocr_engine=None, use_ocr=True, dpi=300, psm="6", logger with fitz.open(pdf_path) as doc, tempfile.TemporaryDirectory() as tmp: total = len(doc) - pages, jobs = [], {} - # 1) leitura do texto nativo e renderização (sequencial: o documento do PyMuPDF não é thread-safe) - for idx in range(total): - page_num = idx + 1 - page = doc[idx] - words = page.get_text("words") - has_images = bool(page.get_images()) - found_here, boxes_here = set(), [] - if words: - found_here |= find_cpfs_in_words(words, engine, (page.rect.width, page.rect.height), boxes_here) - # Página escaneada: pode ter algumas palavras de texto nativo (carimbo, cabeçalho) e o - # conteúdo real em imagem. Se há imagens (ou nenhum texto), só o OCR verifica o resto. - needs_ocr = has_images or not words - size = (page.rect.width * dpi / 72.0, page.rect.height * dpi / 72.0) - if needs_ocr and use_ocr: - try: - pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) - img_path = os.path.join(tmp, f"verify_{page_num}.png") - pix.save(img_path) - pix = None - jobs[page_num] = img_path - except Exception as e: - if logger: - logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {e}") - unverified.append(page_num) - elif needs_ocr and not words: - unverified.append(page_num) - pages.append((page_num, found_here, boxes_here, size)) - - # 2) OCR das páginas em paralelo - results = _ocr_images(engine, jobs, psm, workers or ocr_workers(dpi, len(jobs))) - - # 3) resultado em ordem de página - for page_num, found_here, boxes_here, size in pages: - if page_num in results: - grounding, error = results[page_num] - if error is not None: - if logger: - logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {error}") + n_workers = workers or ocr_workers(dpi, total) + for chunk in _chunks(total, max(4, 2 * n_workers)): + pages, jobs = [], {} + for page_num in chunk: + page = doc[page_num - 1] + words = page_words(page) + has_images = bool(page.get_images()) + found_here, boxes_here = set(), [] + if words: + found_here |= find_cpfs_in_words(words, engine, (page.rect.width, page.rect.height), boxes_here) + # Página escaneada: pode ter algumas palavras de texto nativo (carimbo, cabeçalho) e o + # conteúdo real em imagem. Se há imagens (ou nenhum texto), só o OCR verifica o resto. + needs_ocr = has_images or not words + size = (page.rect.width * dpi / 72.0, page.rect.height * dpi / 72.0) + if needs_ocr and use_ocr: + try: + pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) + img_path = os.path.join(tmp, f"verify_{page_num}.png") + pix.save(img_path) + pix = None + jobs[page_num] = img_path + except Exception as e: + if logger: + logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {e}") + unverified.append(page_num) + elif needs_ocr and not words: unverified.append(page_num) - else: - commands_ocr, found_ocr = engine.find_cpfs_in_grounding(grounding) - found_here |= found_ocr - if found_ocr: - boxes_here.extend(_relative_boxes(grounding, commands_ocr, *size)) - if found_here: - leftovers[page_num] = found_here - if locations is not None and boxes_here: - locations[page_num] = boxes_here - if progress and (page_num % 10 == 0 or page_num == total): - progress(page_num, total) + pages.append((page_num, found_here, boxes_here, size)) + + results = _ocr_images(engine, jobs, psm, min(n_workers, max(1, len(jobs)))) + + for page_num, found_here, boxes_here, size in pages: + if page_num in results: + grounding, error = results[page_num] + if error is not None: + if logger: + logger.error(f"[!] Verificação por OCR falhou na pág {page_num}: {error}") + unverified.append(page_num) + else: + commands_ocr, found_ocr = engine.find_cpfs_in_grounding(grounding) + found_here |= found_ocr + if found_ocr: + boxes_here.extend(_relative_boxes(grounding, commands_ocr, *size)) + if found_here: + leftovers[page_num] = found_here + if locations is not None and boxes_here: + locations[page_num] = boxes_here + if progress and (page_num % 10 == 0 or page_num == total): + progress(page_num, total) + for path in jobs.values(): # libera o disco antes do próximo lote + try: + os.remove(path) + except OSError: + pass return leftovers, sorted(set(unverified)) @@ -162,59 +190,74 @@ def find_uncovered_cpfs(pdf_path, boxes_by_page, ocr_engine, base_dpi=300, dpi=3 boxes_by_page: {page_num: [{x, y, w, h, source_width?, image_width?}, ...]} em pixels de imagem. Retorna {page_num: [cpfs não cobertos]} e a lista de páginas que falharam no OCR. + Processa em lotes (renderização sequencial, OCR em paralelo). """ uncovered = {} failed = [] with fitz.open(pdf_path) as doc, tempfile.TemporaryDirectory() as tmp: total = len(doc) - widths, jobs = {}, {} - for idx in range(total): # renderização sequencial - page_num = idx + 1 - page = doc[idx] - widths[page_num] = (page.rect.width, page.rect.height) - try: - pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) - img_path = os.path.join(tmp, f"cover_{page_num}.png") - pix.save(img_path) - pix = None - jobs[page_num] = img_path - except Exception as e: - if logger: - logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {e}") - failed.append(page_num) - - results = _ocr_images(ocr_engine, jobs, psm, workers or ocr_workers(dpi, len(jobs))) # OCR em paralelo - - for page_num in sorted(results): - grounding, error = results[page_num] - if error is not None: - if logger: - logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {error}") - failed.append(page_num) - continue - page_w, page_h = widths[page_num] - commands, found = ocr_engine.find_cpfs_in_grounding(grounding) - if commands: - rects = [] - for b in boxes_by_page.get(page_num, []): - src_w = b.get("source_width") or b.get("image_width") or (page_w * base_dpi / 72.0) - scale = page_w / float(src_w) - rects.append((b["x"] * scale - tolerance_pt, b["y"] * scale - tolerance_pt, - (b["x"] + b["w"]) * scale + tolerance_pt, (b["y"] + b["h"]) * scale + tolerance_pt)) - - pt_per_px = 72.0 / dpi - for word_id in commands: - box = grounding[word_id]["box"] - cx = (box["x"] + box["w"] / 2.0) * pt_per_px - cy = (box["y"] + box["h"] / 2.0) * pt_per_px - if not any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in rects): - uncovered.setdefault(page_num, set()).update(found) - if locations is not None: - scale = dpi / 72.0 - locations.setdefault(page_num, []).extend(_relative_boxes( - grounding, [word_id], page_w * scale, page_h * scale)) - break - if progress and (page_num % 10 == 0 or page_num == total): - progress(page_num, total) + n_workers = workers or ocr_workers(dpi, total) + for chunk in _chunks(total, max(4, 2 * n_workers)): + sizes, jobs = {}, {} + for page_num in chunk: + page = doc[page_num - 1] + sizes[page_num] = (page.rect.width, page.rect.height) + try: + pix = page.get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False) + img_path = os.path.join(tmp, f"cover_{page_num}.png") + pix.save(img_path) + pix = None + jobs[page_num] = img_path + except Exception as e: + if logger: + logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {e}") + failed.append(page_num) + + results = _ocr_images(ocr_engine, jobs, psm, min(n_workers, max(1, len(jobs)))) + + for page_num in chunk: + if page_num in results: + grounding, error = results[page_num] + if error is not None: + if logger: + logger.error(f"[!] Verificação de cobertura falhou na pág {page_num}: {error}") + failed.append(page_num) + else: + _check_coverage(page_num, grounding, ocr_engine, boxes_by_page, sizes[page_num], base_dpi, + dpi, tolerance_pt, uncovered, locations) + if progress and (page_num % 10 == 0 or page_num == total): + progress(page_num, total) + for path in jobs.values(): + try: + os.remove(path) + except OSError: + pass return uncovered, failed + + +def _check_coverage(page_num, grounding, ocr_engine, boxes_by_page, size, base_dpi, dpi, tolerance_pt, + uncovered, locations): + """Marca a página como descoberta se algum CPF do OCR do original estiver fora de todas as tarjas.""" + page_w, page_h = size + commands, found = ocr_engine.find_cpfs_in_grounding(grounding) + if not commands: + return + rects = [] + for b in boxes_by_page.get(page_num, []): + src_w = b.get("source_width") or b.get("image_width") or (page_w * base_dpi / 72.0) + scale = page_w / float(src_w) + rects.append((b["x"] * scale - tolerance_pt, b["y"] * scale - tolerance_pt, + (b["x"] + b["w"]) * scale + tolerance_pt, (b["y"] + b["h"]) * scale + tolerance_pt)) + pt_per_px = 72.0 / dpi + for word_id in commands: + box = grounding[word_id]["box"] + cx = (box["x"] + box["w"] / 2.0) * pt_per_px + cy = (box["y"] + box["h"] / 2.0) * pt_per_px + if not any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in rects): + uncovered.setdefault(page_num, set()).update(found) + if locations is not None: + scale = dpi / 72.0 + locations.setdefault(page_num, []).extend(_relative_boxes( + grounding, [word_id], page_w * scale, page_h * scale)) + break