Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 57 additions & 5 deletions src/tarrafa/core/identity_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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": [],
Expand All @@ -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] = []
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/tarrafa/skills/tarrafa.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions src/tarrafa/tools/djen/scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
25 changes: 4 additions & 21 deletions src/tarrafa/tools/order_risk/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/tarrafa/tools/pdf_extract/scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
],
)
Expand All @@ -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 [])} "
Expand Down
32 changes: 32 additions & 0 deletions tests/test_identity_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -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,
Expand Down