diff --git a/AGENTS.md b/AGENTS.md index 8935500..1e5db42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,9 @@ A ferramenta canônica é o **CLI `tarrafa`** (Playwright embutido). Não usar P Do **not** fuse processes by short name alone (homônimo). 11. `datajud`: only with known CNJs (from DJEN or other sources); index typically has **no** party names. 12. `pdf-extract`: material-only identity/contact/CNJ hints from court PDFs; validate before citing. + `cpfs` traz só sequências com dígito verificador válido; as reprovadas ficam em + `cpfs_rejected` (em autos, 11 dígitos seguidos costumam ser protocolo/conta/guia). + DV válido **não** confirma titularidade — segue sendo candidato a conferir. 13. **Perfil / influencer:** ver `docs/PROFILE_PIPELINE.md` (IG shots, homônimo/CPF, djen parte, V1 HTML vs V2 anexo). 14. **IG prints e comentários:** `tarrafa shot` / `tarrafa ig` no path do caso. Não depender de MCP para gravar PNG/JSON. Never embed login-wall shots in `dossier`. 15. **CPF vs CNPJ mask:** CNPJá `***ABCDEF**` ≈ CPF digits 4–9. Do **not** attach a company QSA to the person if the mask does not match the CPF from court PDFs. diff --git a/CHANGELOG.md b/CHANGELOG.md index daca3d2..6c5ef71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Fixed +- **Indícios de CPF (`pdf-extract`, `djen`)** — a extração passa a conferir os dígitos + verificadores antes de tratar uma sequência de 11 dígitos como CPF. Número de protocolo, + conta e guia deixam de aparecer em `cpfs`; o que é reprovado fica registrado em + `cpfs_rejected`, sem descarte silencioso. A validação, que já existia em `order-risk`, + vira fonte única em `core.identity_extract` e deixa de ser duplicada. +- **`tarrafa djen --cpf`** — avisa quando o CPF informado tem dígito verificador inválido. + A consulta segue, mas zero resultados nesse caso indica erro de digitação, não ausência + de processos. + ### Security - **`tarrafa profile`** — impede CPF, e-mail e telefone nas consultas e limita a coleta a páginas públicas; dados pessoais reais não são embutidos em código, testes ou exemplos. diff --git a/src/tarrafa/core/identity_extract.py b/src/tarrafa/core/identity_extract.py index 8c62fd0..eb21207 100644 --- a/src/tarrafa/core/identity_extract.py +++ b/src/tarrafa/core/identity_extract.py @@ -60,14 +60,55 @@ def digits_only(s: str) -> str: return re.sub(r"\D", "", s or "") +def _format_11_digits(d: str) -> str: + return f"{d[0:3]}.{d[3:6]}.{d[6:9]}-{d[9:11]}" + + +def validate_cpf(cpf: str | None) -> dict[str, Any]: + """Valida os dígitos verificadores do CPF. + + Fonte única de verdade do projeto: qualquer tool que precise decidir se uma + sequência de 11 dígitos é um CPF real deve usar esta função, e não apenas + conferir o comprimento. Sem isso, número de protocolo, conta e guia viram + "CPF encontrado". + """ + d = digits_only(cpf) + if len(d) != 11: + return {"ok": False, "reason": "len", "digits": d, "formatted": cpf or ""} + if re.fullmatch(r"(.)\1{10}", d): + return {"ok": False, "reason": "repeated", "digits": d, "formatted": _format_11_digits(d)} + nums = [int(c) for c in d] + s = sum(nums[i] * (10 - i) for i in range(9)) + r = s % 11 + dv1 = 0 if r < 2 else 11 - r + if nums[9] != dv1: + return {"ok": False, "reason": "dv1", "digits": d, "formatted": _format_11_digits(d)} + s = sum(nums[i] * (11 - i) for i in range(10)) + r = s % 11 + dv2 = 0 if r < 2 else 11 - r + if nums[10] != dv2: + return {"ok": False, "reason": "dv2", "digits": d, "formatted": _format_11_digits(d)} + return {"ok": True, "reason": "valid", "digits": d, "formatted": _format_11_digits(d)} + + +def cpf_has_valid_dv(cpf: str | None) -> bool: + """Atalho booleano sobre :func:`validate_cpf`.""" + return bool(validate_cpf(cpf)["ok"]) + + def format_cpf(digits: str) -> str | None: + """Formata 11 dígitos como CPF. Formatador, não validador. + + Não confere dígito verificador de propósito: chamadores que precisam saber + se o número é um CPF real usam :func:`validate_cpf`. + """ d = digits_only(digits) if len(d) != 11: return None # weak check: reject obvious invalids if d == d[0] * 11: return None - return f"{d[0:3]}.{d[3:6]}.{d[6:9]}-{d[9:11]}" + return _format_11_digits(d) def format_cnj(digits: str) -> str | None: @@ -104,6 +145,7 @@ def extract_identity_hints(text: str, *, max_each: int = 40) -> dict[str, Any]: if not text: return { "cpfs": [], + "cpfs_rejected": [], "rgs": [], "datas_nascimento": [], "emails": [], @@ -114,13 +156,21 @@ def extract_identity_hints(text: str, *, max_each: int = 40) -> dict[str, Any]: "addresses": [], } + # Só entra em `cpfs` a sequência cujos dígitos verificadores fecham. As demais + # ficam em `cpfs_rejected`: em autos, 11 dígitos seguidos costumam ser protocolo, + # conta ou guia, e emiti-los como CPF gera conferência manual inútil. cpfs: list[str] = [] + cpfs_rejected: list[str] = [] for m in _CPF_RE.finditer(text): fmt = format_cpf(m.group(1)) - if fmt and fmt not in cpfs: - # skip if looks like part of longer digit run only when 11 digits valid - cpfs.append(fmt) - if len(cpfs) >= max_each: + if not fmt: + continue + if cpf_has_valid_dv(fmt): + if fmt not in cpfs: + cpfs.append(fmt) + elif fmt not in cpfs_rejected: + cpfs_rejected.append(fmt) + if len(cpfs) >= max_each or len(cpfs_rejected) >= max_each: break rgs: list[str] = [] @@ -207,6 +257,7 @@ def extract_identity_hints(text: str, *, max_each: int = 40) -> dict[str, Any]: return { "cpfs": cpfs, + "cpfs_rejected": cpfs_rejected, "rgs": rgs, "datas_nascimento": dns, "emails": emails, @@ -222,6 +273,7 @@ def merge_hints(hint_list: list[dict[str, Any]], *, max_each: int = 50) -> dict[ """Merge multiple hint dicts; preserve first-seen order, count frequencies.""" keys = ( "cpfs", + "cpfs_rejected", "rgs", "datas_nascimento", "emails", diff --git a/src/tarrafa/skills/tarrafa.md.tmpl b/src/tarrafa/skills/tarrafa.md.tmpl index df23f5f..6f562bb 100644 --- a/src/tarrafa/skills/tarrafa.md.tmpl +++ b/src/tarrafa/skills/tarrafa.md.tmpl @@ -225,6 +225,9 @@ JSON. Não afirme exaustividade quando a cobertura foi parcial. local. Sem CPF, use `--nome` / `--texto` e trate o resultado como `identity_hints`. - Não funda processos por nome curto — homônimo é a falha mais comum aqui. - `--follow-datajud` encadeia o Datajud a partir dos CNJs encontrados. +- Em `identity_hints`, `cpfs` só traz sequências com dígito verificador válido; as + reprovadas ficam em `cpfs_rejected` (protocolo, conta, guia). DV válido não confirma + titularidade — continua candidato a conferir. ```{{TARRAFA_SHELL}} {{TARRAFA_CALL}} djen --oab 12345 --uf SP --max-items 100 --out "PASTA/djen.json" diff --git a/src/tarrafa/tools/djen/scraper.py b/src/tarrafa/tools/djen/scraper.py index 47b3d47..ec39751 100644 --- a/src/tarrafa/tools/djen/scraper.py +++ b/src/tarrafa/tools/djen/scraper.py @@ -39,6 +39,7 @@ from tarrafa.core.envelope import build_envelope from tarrafa.core.http import DEFAULT_TIMEOUT, DEFAULT_UA, ensure_httpx from tarrafa.core.identity_extract import ( + cpf_has_valid_dv, digits_only, extract_identity_hints, format_cpf, @@ -591,6 +592,14 @@ def main(argv: list[str] | None = None) -> int: if args.cpf and not cpf_normalizado: print("djen: --cpf inválido: informe 11 dígitos não repetidos", file=sys.stderr) return 2 + if cpf_normalizado and not cpf_has_valid_dv(cpf_normalizado): + # Não bloqueia: a consulta segue. Mas zero resultados de um CPF com dígito + # verificador errado é sintoma de digitação, não ausência de processos. + print( + f"djen: aviso: --cpf {mask_cpf(cpf_normalizado)} tem dígito verificador " + "inválido; zero resultados aqui provavelmente indica erro de digitação", + file=sys.stderr, + ) if args.cpf and papel != "parte": print("djen: --cpf só pode ser usado com --papel parte (ou --papel auto)", file=sys.stderr) return 2 diff --git a/src/tarrafa/tools/order_risk/checks.py b/src/tarrafa/tools/order_risk/checks.py index 4a76fa8..035fef4 100644 --- a/src/tarrafa/tools/order_risk/checks.py +++ b/src/tarrafa/tools/order_risk/checks.py @@ -11,32 +11,15 @@ from datetime import date, datetime from typing import Any +# Validação de CPF mora no núcleo (fonte única da verdade); reexportada aqui +# porque order_risk.scraper a consome por este módulo. +from tarrafa.core.identity_extract import validate_cpf as validate_cpf + def digits_only(value: str | None) -> str: return re.sub(r"\D", "", value or "") -def validate_cpf(cpf: str | None) -> dict[str, Any]: - """Valida dígitos verificadores do CPF.""" - d = digits_only(cpf) - if len(d) != 11: - return {"ok": False, "reason": "len", "digits": d, "formatted": cpf or ""} - if re.fullmatch(r"(.)\1{10}", d): - return {"ok": False, "reason": "repeated", "digits": d, "formatted": format_cpf(d)} - nums = [int(c) for c in d] - s = sum(nums[i] * (10 - i) for i in range(9)) - r = s % 11 - dv1 = 0 if r < 2 else 11 - r - if nums[9] != dv1: - return {"ok": False, "reason": "dv1", "digits": d, "formatted": format_cpf(d)} - s = sum(nums[i] * (11 - i) for i in range(10)) - r = s % 11 - dv2 = 0 if r < 2 else 11 - r - if nums[10] != dv2: - return {"ok": False, "reason": "dv2", "digits": d, "formatted": format_cpf(d)} - return {"ok": True, "reason": "valid", "digits": d, "formatted": format_cpf(d)} - - def format_cpf(digits: str) -> str: d = digits_only(digits) if len(d) != 11: diff --git a/src/tarrafa/tools/pdf_extract/scraper.py b/src/tarrafa/tools/pdf_extract/scraper.py index 5f159e4..b8a5b4e 100644 --- a/src/tarrafa/tools/pdf_extract/scraper.py +++ b/src/tarrafa/tools/pdf_extract/scraper.py @@ -237,6 +237,8 @@ def main(argv: list[str] | None = None) -> int: notes=[ "Extração via pypdf (texto embutido). PDFs só-imagem exigem OCR externo.", "identity_hints são heurísticas sobre o texto — validar antes de usar em peça.", + "cpfs traz apenas sequências com dígito verificador válido; as demais, " + "normalmente protocolo/conta/guia, ficam em cpfs_rejected.", "Não fundir identidade só por homônimo de nome em PDF de terceiro.", ], ) @@ -246,7 +248,8 @@ def main(argv: list[str] | None = None) -> int: hints = s.get("identity_hints") or {} print( f"pdf-extract: wrote {out} files={s['ok_files']}/{s['files']} " - f"cpfs={len(hints.get('cpfs') or [])} " + f"cpfs={len(hints.get('cpfs') or [])}" + f"(-{len(hints.get('cpfs_rejected') or [])} dv) " f"cnjs={len(hints.get('cnjs') or [])} " f"emails={len(hints.get('emails') or [])} " f"addresses={len(hints.get('addresses') or [])} " diff --git a/tests/test_identity_extract.py b/tests/test_identity_extract.py index c5478ca..728730c 100644 --- a/tests/test_identity_extract.py +++ b/tests/test_identity_extract.py @@ -2,10 +2,12 @@ from __future__ import annotations from tarrafa.core.identity_extract import ( + cpf_has_valid_dv, extract_identity_hints, format_cnj, format_cpf, merge_hints, + validate_cpf, ) @@ -14,6 +16,36 @@ def test_format_cpf_cnj(): assert format_cnj("00012345620248260100") == "0001234-56.2024.8.26.0100" +def test_validate_cpf_check_digits(): + assert validate_cpf("529.982.247-25")["ok"] is True + assert validate_cpf("111.444.777-35")["ok"] is True + # Dígito verificador errado: 11 dígitos, mas não é CPF que exista. + assert validate_cpf("529.982.247-26")["reason"] == "dv2" + assert validate_cpf("123.456.789-01")["ok"] is False + assert validate_cpf("111.111.111-11")["reason"] == "repeated" + assert validate_cpf("5299822472")["reason"] == "len" + assert validate_cpf(None)["ok"] is False + assert cpf_has_valid_dv("52998224725") is True + assert cpf_has_valid_dv("12345678901") is False + + +def test_cpf_hints_reject_non_cpf_digit_runs(): + """Protocolo/conta com 11 dígitos não pode sair como CPF encontrado.""" + text = "Protocolo 20240000123 e guia 12345678901 nos autos, CPF 529.982.247-25." + h = extract_identity_hints(text) + assert h["cpfs"] == ["529.982.247-25"] + assert h["cpfs_rejected"] == ["202.400.001-23", "123.456.789-01"] + + +def test_cpf_rejected_is_recorded_not_discarded(): + """Descarte fica registrado: nada some em silêncio.""" + h = extract_identity_hints("Guia 529.982.247-26 emitida.") + assert h["cpfs"] == [] + assert h["cpfs_rejected"] == ["529.982.247-26"] + m = merge_hints([h, h]) + assert m["counts"]["cpfs_rejected"].get("529.982.247-26") == 2 + + def test_extract_from_sample_text(): text = """ MARIA EXEMPLO DA SILVA, CPF nº 529.982.247-25, RG 12.345.678-SSP/SP,