As Tools, agora contra o CNJ
São duas, e são as mesmas duas da Unidade 1:
listar_assuntos(termo)— resolve o que o juiz falou para o que o índice conhecebuscar_processos(assunto, quantidade)— traz os processos
O par não mudou de forma porque o problema não mudou. O magistrado diz "violência doméstica"; o índice guarda "Violência Doméstica Contra a Mulher". Alguém precisa fazer a tradução, e esse alguém não pode ser o modelo chutando.
Por que não uma Tool só
A tentação é escrever buscar_processos("violência doméstica") e resolver tudo lá dentro.
O capítulo anterior mostrou por que isso dá errado: buscar por nome parcial exige match, e match traz 301.922 processos em vez de 14.723, misturando "Decorrente de Violência Doméstica" com "Violência Doméstica Contra a Mulher".
Com duas Tools, o modelo vê os nomes disponíveis antes de escolher:
["Decorrente de Violência Doméstica", "Violência Doméstica Contra a Mulher"]
E a escolha vira uma decisão explícita, num passo do ciclo, que você pode ler no log. Isso é exatamente o que a Unidade 1 chamava de tornar o raciocínio visível.
O catálogo
listar_assuntos precisa de uma lista de nomes válidos. Ela vem da própria API, com a agregação do capítulo anterior:
_CACHE_ASSUNTOS: list[str] = []
def _catalogo_de_assuntos() -> list[str]:
"""Todos os nomes de assunto que aparecem em processos do TJRJ."""
if _CACHE_ASSUNTOS:
return _CACHE_ASSUNTOS
resposta = consultar(
{
"size": 0,
"aggs": {"nomes": {"terms": {"field": "assuntos.nome.keyword", "size": 3000}}},
},
timeout=120,
)
baldes = resposta["aggregations"]["nomes"]["buckets"]
_CACHE_ASSUNTOS.extend(b["key"] for b in baldes)
return _CACHE_ASSUNTOS
Duas decisões aqui.
size: 3000. A cardinalidade medida foi 2.804. Pedimos 3.000 para caber tudo com folga. Se pedíssemos 100, teríamos os cem assuntos mais comuns — e "Violência Doméstica Contra a Mulher", com 14.723 processos num acervo de 23 milhões, não estaria entre eles.
O cache. A lista é buscada uma vez e guardada em memória. Dentro do laço de um agente, a mesma Tool pode ser chamada três, quatro vezes; sem o cache, seriam três, quatro consultas idênticas ao CNJ.
Essa agregação, medida na máquina do curso, leva menos de um segundo e devolve 2.804 nomes. É rápida porque agregação não devolve documento: o Elasticsearch conta e manda os números.
Compare com a alternativa ingênua — baixar processos para descobrir que assuntos existem. Seriam 23 milhões de documentos para responder a uma pergunta que cabe em 2.804 strings.
Tool 1: resolver o assunto
def listar_assuntos(termo: str) -> list:
"""Nomes de assunto do TJRJ que contêm o termo procurado.
Devolve uma LISTA de strings — não um JSON serializado.
"""
alvo = termo.lower().strip()
achados = [nome for nome in _catalogo_de_assuntos() if alvo in nome.lower()]
if not achados:
raise ValueError(
f"Nenhum assunto contém '{termo}'. Tente um termo mais curto, "
"por exemplo apenas 'violência' ou 'doméstica'."
)
return achados[:20]
Aquele -> list na assinatura, e o return achados[:20] em vez de json.dumps(...), custaram uma execução inteira do agente para serem descobertos. O capítulo seguinte mostra o que aconteceu quando essa função devolvia uma string.
O filtro é feito em Python, sobre a lista já em memória. Não é preguiça: é a forma de ter busca por substring — "doméstica" in "Violência Doméstica Contra a Mulher" — que o Elasticsearch não daria sem cair de volta no match e nos seus 301.922.
Repare no raise ValueError em vez de devolver "não encontrado". É a lição que custou uma execução inteira na Unidade 1: erro pelo mecanismo de erro. No smolagents a exceção vira Observation e volta ao modelo, sem sujar o tipo de retorno da função. E a mensagem foi escrita para ser lida por ele — sugere o que tentar em seguida, em vez de só constatar a falha.
Teste:
listar_assuntos('violência doméstica'):
['Decorrente de Violência Doméstica', 'Violência Doméstica Contra a Mulher']
Dois nomes. Um deles é o que o juiz quer; o outro não é. Agora a escolha é visível.
Tool 2: buscar os processos
LIMITE_MAXIMO = 20
TETO_SIGILO = 0
CORTE_DE_FORMATO = "2100-01-01"
def buscar_processos(assunto: str, quantidade: int = 5) -> list:
"""Processos mais recentes de um assunto, do mais novo para o mais antigo.
O `assunto` precisa ser o nome EXATO, tal como devolvido por listar_assuntos.
A data de ajuizamento vem do índice em dois formatos, e por isso a busca é
feita em duas consultas e reordenada aqui — ver CORTE_DE_FORMATO.
Devolve uma LISTA de dicionários, e não um dicionário com a lista dentro.
A diferença importa porque um agente compõe resultados: chamado uma vez
por assunto, ele vai juntar as respostas com `.extend()`. Listas se juntam;
dicionários, quando percorridos, entregam as CHAVES. Cada processo carrega
o próprio `assunto` justamente para sobreviver a essa junção.
"""
quantidade = max(1, min(int(quantidade), LIMITE_MAXIMO))
# Rejeita aqui o que o índice não conhece, antes de gastar uma chamada de
# rede. Um assunto inválido é erro de quem chamou, e o erro deve custar
# uma exceção local — não uma requisição ao CNJ.
if assunto not in _catalogo_de_assuntos():
raise ValueError(
f"'{assunto}' não é um nome de assunto do TJRJ. "
"Use listar_assuntos() para obter os nomes exatos."
)
# Uma consulta só, ordenada por dataAjuizamento, devolveria apenas a família
# compacta: ela ocupa TODAS as primeiras posições, por milhares de documentos.
# Então perguntamos duas vezes, uma por família de formato, e juntamos aqui.
achados = []
for familia in (
{"range": {"dataAjuizamento": {"lt": CORTE_DE_FORMATO}}},
{"range": {"dataAjuizamento": {"gte": CORTE_DE_FORMATO}}},
):
corpo = {
"size": quantidade,
"query": {
"bool": {
"filter": [
# term em .keyword: comparação exata, sem quebrar em palavras.
{"term": {"assuntos.nome.keyword": assunto}},
{"range": {"nivelSigilo": {"lte": TETO_SIGILO}}},
familia,
]
}
},
"sort": [{"dataAjuizamento": {"order": "desc"}}],
"_source": [
"numeroProcesso",
"dataAjuizamento",
"nivelSigilo",
"grau",
"classe.nome",
"orgaoJulgador.nome",
],
}
achados.extend(consultar(corpo)["hits"]["hits"])
if not achados:
return []
# Reordena pela data normalizada. É este passo que intercala as famílias.
achados.sort(
key=lambda h: chave_data(h["_source"].get("dataAjuizamento")),
reverse=True,
)
processos = []
for h in achados:
p = h["_source"]
# Cinto e suspensório: se algo com sigilo escapar da origem, para aqui.
if p.get("nivelSigilo", 99) > TETO_SIGILO:
continue
processos.append(
{
"assunto": assunto,
"numero_processo": formatar_numero(p["numeroProcesso"]),
"data_ajuizamento": formatar_data(p["dataAjuizamento"]),
"grau": p.get("grau"),
"classe": p.get("classe", {}).get("nome"),
"orgao_julgador": p.get("orgaoJulgador", {}).get("nome"),
}
)
# Duas consultas trouxeram até 2x o pedido; o corte final é aqui.
return processos[:quantidade]
Seis coisas nesse código vêm de erros cometidos de verdade — três na Unidade 1, três nesta. A última foi descoberta depois de a Unidade 2 já estar escrita, e é a mais instrutiva de todas.
1. O teto de resultados, de novo
quantidade = max(1, min(int(quantidade), LIMITE_MAXIMO))
Na Unidade 1, pedir "os 500 processos mais recentes" fez o modelo mandar limite=500. O clamp reduziu para 20, o banco devolveu 13, e o modelo respondeu "Aqui estão os 500 processos mais recentes sobre furto", terminando com "E assim por diante até o 500º processo".
Nenhum número foi inventado. A contagem foi.
2. O retorno é uma lista, e cada item sabe de onde veio
Este ponto custou duas execuções do agente para chegar à forma acima, e vale acompanhar o raciocínio inteiro — porque a primeira solução foi razoável e mesmo assim estava errada.
Depois do episódio da Unidade 1, em que o modelo pediu 500 processos, recebeu 13 e respondeu "aqui estão os 500", a conclusão parecia óbvia: escreva a contagem no retorno, para o modelo não ter que deduzir nada. Assim:
{"quantidade_pedida": 3, "encontrados": 3, "processos": [...]}
É um envelope: um dicionário com metadados e a lista dentro. Resolve o problema da contagem. E introduz outro, que só aparece quando o agente usa a Tool mais de uma vez.
O modelo tem dois assuntos para consultar, então escreve o que qualquer pessoa escreveria:
processos = []
for assunto in listar_assuntos('violência doméstica'):
processos.extend(buscar_processos(assunto, quantidade=3))
extend sobre um dicionário percorre as chaves. A lista final ficou:
['assunto', 'quantidade_pedida', 'encontrados', 'assunto', 'quantidade_pedida', 'encontrados']
Sem erro. Sem aviso. Os processos estavam ali, dentro do envelope, e foram descartados por uma linha de código correta.
A regra que sai daí é mais forte do que "devolva objeto Python":
O retorno de uma Tool tem que ter a forma que o chamador vai compor.
Um agente raramente chama uma Tool uma vez só. Ele chama num laço e junta os resultados. Listas se juntam; envelopes não — e a junção errada não levanta exceção, devolve outra coisa.
Por isso buscar_processos devolve list, e por isso cada processo carrega o próprio assunto. O envelope guardava a proveniência do lado de fora, onde a concatenação a perde; o campo por item sobrevive:
juntando os dois assuntos numa lista só:
4 processos
0007972-81.2026.8.19.0203 Decorrente de Violência Doméstica
0001381-92.2026.8.19.0045 Decorrente de Violência Doméstica
0802320-05.2026.8.19.0024 Violência Doméstica Contra a Mulher
0007418-41.2024.8.19.0002 Violência Doméstica Contra a Mulher
Fica a dívida: sem o envelope, quem conta é o modelo. A diferença em relação à Unidade 1 é que agora ele conta em Python — len(processos) — e não de cabeça. Num Code Agent, contar é executar; e executar é a única coisa que um LLM faz sem errar.
3. .get() com padrão, nunca acesso direto
p.get("classe", {}).get("nome")
O primeiro processo que a API devolveu, no primeiro capítulo, tinha sistema.nome igual a "Inválido". Campo torto é o caso comum, não a exceção. E uma quebra aqui acontece dentro do laço do agente, onde o diagnóstico é bem mais chato.
O p.get("nivelSigilo", 99) é o mesmo padrão levado a sério: campo ausente vira 99, 99 é maior que o teto, o processo sai. Numa checagem de acesso, o padrão é negar.
4. A guarda antes da rede
if assunto not in _catalogo_de_assuntos():
raise ValueError(f"'{assunto}' não é um nome de assunto do TJRJ. ...")
O catálogo já está em memória. Conferir contra ele custa uma comparação; deixar passar custa uma requisição a um servidor de terceiro.
Isso não é otimização. É a diferença entre um erro que custa microssegundos e um erro que, multiplicado pelo laço de um agente, vira dezenas de chamadas à API do CNJ. O próximo capítulo mostra exatamente esse cenário acontecendo.
5. Número e data formatados na saída
def formatar_numero(numero: str) -> str:
"""Põe a máscara CNJ em um número de 20 dígitos."""
n = "".join(c for c in numero if c.isdigit())
if len(n) != 20:
return numero
return f"{n[:7]}-{n[7:9]}.{n[9:13]}.{n[13]}.{n[14:16]}.{n[16:]}"
def chave_data(bruta: str) -> str:
"""Reduz qualquer um dos dois formatos do índice a AAAAMMDD.
20260529185822 -> 20260529
2025-03-06T13:16:00.000Z -> 20250306
"""
digitos = "".join(c for c in str(bruta or "") if c.isdigit())
return digitos[:8]
def formatar_data(bruta: str) -> str:
"""20260529185822 -> 29/05/2026. Aceita também o formato ISO."""
d = chave_data(bruta)
if len(d) != 8:
return bruta
return f"{d[6:8]}/{d[4:6]}/{d[:4]}"
A API devolve 00290982020268190000 e 20260430093025. O juiz lê 0029098-20.2026.8.19.0000 e 30/04/2026.
Formatar aqui, e não deixar para o modelo, tem uma razão que vale além da estética: transformação de dado feita pelo modelo é transformação que pode sair errada e ninguém percebe. Um dígito trocado numa máscara de vinte números não chama atenção de ninguém — e leva o magistrado a um processo que não existe, ou a outro.
Cada operação que a Tool faz é uma operação que o modelo não faz.
Repare que formatar_data passa por chave_data em vez de fatiar a string direto. O motivo é o item seguinte.
6. Duas consultas para uma pergunta
Este código já esteve mais simples. Ele fazia uma consulta:
"sort": [{"dataAjuizamento": {"order": "desc"}}]
e devolvia o topo. A saída era impecável — cinco processos de 2026, datas formatadas, tudo em ordem decrescente. Ficou assim por semanas.
Estava errado, e o erro era invisível justamente porque a saída era bonita.
Como o capítulo A API Pública do DataJud explicou, dataAjuizamento convive em duas grafias no mesmo campo, e o Elasticsearch lê a compacta como milissegundos desde 1970 — jogando-a para o século 27. O efeito na ordenação é total, não parcial:
processo compacto MAIS ANTIGO: 00104844319988190001 1998 chave de ordenação: 19980417000000
processo ISO MAIS NOVO: 00173502520258190000 2025 chave de ordenação: 1741266960000
Um processo de 1998 ordena acima de um de 2025. Não por pouco: por um fator de dez mil.
A consequência prática é que a família compacta ocupa todas as primeiras posições — milhares delas. Um processo gravado em ISO nunca chegava ao topo, por mais recente que fosse. A Tool não devolvia "os mais recentes"; devolvia "os de formato compacto", e por acaso os mais recentes deles.
O conserto é o laço:
for familia in (
{"range": {"dataAjuizamento": {"lt": CORTE_DE_FORMATO}}},
{"range": {"dataAjuizamento": {"gte": CORTE_DE_FORMATO}}},
):
Uma pergunta por família, size cheio em cada uma, e a reordenação final em Python pela data normalizada. Custa uma requisição a mais por chamada. É o preço de a resposta ser sobre data, e não sobre formato de gravação.
Três coisas que este erro ensina, e que valem mais do que o conserto:
A saída bonita não é evidência. Cinco processos de 2026, ordenados, formatados. Nada nessa tela sugeria defeito. O defeito só apareceu quando um date_histogram devolveu o ano 2610 — três capítulos depois, e por acaso.
O sort não é opcional só porque a lista parece certa. Ordenar é uma afirmação sobre o dado. Se você não mediu o campo, a afirmação é um chute com cara de fato.
Custe a requisição extra. A tentação de deixar como estava era grande: uma consulta é mais rápida, mais barata e mais simples de explicar. Mas a Tool prometia uma coisa no docstring — "mais recentes" — e entregava outra. O modelo lê esse docstring e acredita nele. Uma Tool que mente para o modelo produz um agente que mente para o magistrado, e nenhum dos dois tem como desconfiar.
O teste
python exemplos\tools_cnj.py
Catálogo de assuntos: 2804 nomes distintos
(a primeira chamada levou 0.3s)
listar_assuntos('violência doméstica'):
['Decorrente de Violência Doméstica', 'Violência Doméstica Contra a Mulher']
buscar_processos('Violência Doméstica Contra a Mulher', 3):
{'assunto': 'Violência Doméstica Contra a Mulher', 'numero_processo':
'0802320-05.2026.8.19.0024', 'data_ajuizamento': '29/05/2026', 'grau': 'G1',
'classe': 'Carta Precatória Criminal', 'orgao_julgador': 'ITAGUAI VARA CRIMINAL'}
{'assunto': 'Violência Doméstica Contra a Mulher', 'numero_processo':
'0007418-41.2024.8.19.0002', 'data_ajuizamento': '30/04/2026', 'grau': 'TR',
'classe': 'Apelação Criminal', 'orgao_julgador': 'CAPITAL 1 TURMA RECURSAL
DOS JUI ESP CRIMINAL'}
{'assunto': 'Violência Doméstica Contra a Mulher', 'numero_processo':
'0029098-20.2026.8.19.0000', 'data_ajuizamento': '30/04/2026', 'grau': 'G2',
'classe': 'Agravo Interno Cível', 'orgao_julgador': 'GAB DES SIMONE DE
ARAUJO ROLIM'}
juntando os dois assuntos numa lista só:
4 processos
0007972-81.2026.8.19.0203 Decorrente de Violência Doméstica
0001381-92.2026.8.19.0045 Decorrente de Violência Doméstica
0802320-05.2026.8.19.0024 Violência Doméstica Contra a Mulher
0007418-41.2024.8.19.0002 Violência Doméstica Contra a Mulher
Três processos, três graus diferentes, três órgãos que existem. E, embaixo, a prova de que o retorno compõe: dois assuntos consultados, quatro processos numa lista só, cada um ainda sabendo de onde veio.
Repare nas aspas simples. Não é detalhe de impressão: é o repr de dict e de list do Python. Estas Tools devolvem objetos, não texto que se parece com objetos — e o capítulo seguinte mostra as duas execuções do agente que foram necessárias para chegar exatamente a esta forma de retorno.
A prova dos nove
Na Unidade 1, o experimento 4 pegou o modelo inventando processos: sem a stop sequence, ele escreveu a Observation sozinho e produziu três números plausíveis. Você os testou com validar_cnj.py, e os três reprovaram no dígito verificador.
Agora rode o mesmo validador contra estes:
import sys
sys.path.insert(0, "../../unidade1/exemplos")
from validar_cnj import validar_cnj
for n in ["0802320-05.2026.8.19.0024",
"0007418-41.2024.8.19.0002",
"0029098-20.2026.8.19.0000"]:
print(n, "->", validar_cnj(n))
0802320-05.2026.8.19.0024 -> True
0007418-41.2024.8.19.0002 -> True
0029098-20.2026.8.19.0000 -> True
Os três passam. É o fecho do arco que começou na Unidade 1: o mesmo teste que reprovou o que o modelo inventou aprova o que a Tool trouxe.
Guarde esse hábito. Um número CNJ carrega o próprio dígito verificador, e conferi-lo custa uma linha de código.
Sempre que um número de processo passar por um LLM — numa resposta, num resumo, numa transcrição — passe o validador nele. É a verificação mais barata que existe contra a falha mais convincente que um modelo comete.