Escrever Tool é escolher o que não devolver
O capítulo anterior mediu o problema: mediana de 74 movimentos por processo, máximo de 613, e ~108 mil tokens numa Observation se a Tool for ingênua.
Agora a Tool. O enunciado ficou combinado desde o fim da Unidade 2:
ultimos_movimentos(numero_processo, quantidade=5)— busca o processo pelo número, ordena os movimentos por data e devolve só os últimos, cada um com nome e data já formatada.
Repare que o enunciado inteiro é sobre reduzir. Trazer movimentos[] é trivial: já vem no _source, sem esforço. A engenharia toda está no corte.
Onde cortar
Há dois lugares possíveis, e eles não competem — somam.
No CNJ, via _source. O Elasticsearch aceita excluir campos aninhados na própria consulta. complementosTabelados é a parte mais gorda de cada movimento e não responde a "o que andou no processo":
"_source": {
"includes": ["numeroProcesso", "grau", "nivelSigilo", "classe.nome", "movimentos"],
"excludes": ["movimentos.complementosTabelados"]
}
Medido no mesmo processo, mesmo número de movimentos:
| bytes | |
|---|---|
movimentos[] como vem |
4.693 |
sem complementosTabelados |
2.652 |
1,8×. Menos que o corte final, mas é o corte mais barato que existe: o dado nem chega a virar tráfego.
Na Tool, depois da rede. Ordenar e fatiar. É aqui que os 613 viram 5, e é o corte que decide se o passo cabe ou não.
A regra que vale além deste exemplo: reduza o mais cedo que o formato permitir, mas conte com o corte final.
O _source não fatia arrays — não existe "me dê só os 5 últimos movimentos" em Query DSL sem entrar em nested e inner_hits, complexidade que este curso não precisa. Então o corte de 613 para 5 é obrigatoriamente Python.
O que o _source faz é evitar que 105 KB atravessem a rede para 233 bytes serem usados.
Quatro decisões antes da primeira linha
1. Aceitar o número com máscara. Um juiz tem 0018683-81.2020.8.19.0066 na tela; a API quer 00186838120208190066. Exigir a forma da API é empurrar para o modelo — e para o usuário — um trabalho que uma linha resolve.
2. Guarda antes da rede. Mesma disciplina das Tools da Unidade 2: se não tem 20 dígitos, não é número de processo, e descobrir isso não vale uma requisição ao CNJ.
3. Juntar os graus. O capítulo anterior mostrou que o mesmo número devolve um documento por grau. "O que andou neste processo" não é pergunta sobre instância — é sobre o processo. A Tool junta e ordena tudo, e cada movimento carrega o grau de onde veio.
4. Ordenar pelo campo cru, não pelo formatado. Esta é a que tem armadilha, e ela tem nome: 0995-09-12.
A data ISO da API ordena corretamente como texto — ano, mês, dia, nessa ordem, com zeros à esquerda. A data brasileira, não: 12/09/0995 compara o dia primeiro, e uma lista ordenada por ela sai embaralhada. Formatar antes de ordenar é uma das formas mais fáceis de quebrar isto sem receber exceção nenhuma.
A solução é guardar o campo cru só para ordenar, e apagá-lo antes de devolver.
A Tool
LIMITE_MOVIMENTOS = 20
def ultimos_movimentos(numero_processo: str, quantidade: int = 5) -> list:
"""Os últimos andamentos de um processo, do mais recente para o mais antigo.
Aceita o número com ou sem máscara: 0018683-81.2020.8.19.0066 e
00186838120208190066 dão no mesmo.
"""
quantidade = max(1, min(int(quantidade), LIMITE_MOVIMENTOS))
digitos = "".join(c for c in str(numero_processo) if c.isdigit())
if len(digitos) != 20:
raise ValueError(
f"'{numero_processo}' não é um número de processo. "
"São 20 dígitos, com ou sem máscara."
)
resposta = consultar(
{
# size 10 é para os graus, não para os movimentos: um processo
# tramita em poucas instâncias, mas nunca em dez.
"size": 10,
"query": {
"bool": {
"filter": [
{"term": {"numeroProcesso": digitos}},
{"range": {"nivelSigilo": {"lte": TETO_SIGILO}}},
]
}
},
"_source": {
"includes": ["numeroProcesso", "grau", "nivelSigilo", "classe.nome", "movimentos"],
"excludes": ["movimentos.complementosTabelados"],
},
}
)
achados = resposta["hits"]["hits"]
if not achados:
raise ValueError(
f"Nenhum processo público com o número {formatar_numero(digitos)} "
"no índice do TJRJ."
)
movimentos = []
for h in achados:
p = h["_source"]
# Cinto e suspensório, como nas outras duas Tools.
if p.get("nivelSigilo", 99) > TETO_SIGILO:
continue
for m in p.get("movimentos") or []:
movimentos.append(
{
"numero_processo": formatar_numero(p["numeroProcesso"]),
"grau": p.get("grau"),
"classe": p.get("classe", {}).get("nome"),
"codigo": m.get("codigo"),
"movimento": m.get("nome"),
# dataHora vem ISO: 2021-06-11T00:00:00.000Z. formatar_data
# aceita os dois formatos do índice, então não há conversão aqui.
"data": formatar_data(m.get("dataHora")),
# Guardado só para ordenar; o formato ISO ordena certo como
# texto, o formato brasileiro não ordenaria.
"_ordem": m.get("dataHora") or "",
}
)
movimentos.sort(key=lambda m: m["_ordem"], reverse=True)
for m in movimentos:
del m["_ordem"]
return movimentos[:quantidade]
Trinta linhas úteis, e nenhuma delas é sobre consultar o CNJ — a consulta é um bloco só. O resto é decidir o que sai.
Rodando
ultimos_movimentos('0018683-81.2020.8.19.0066', 5):
14/08/2026 G1 [581] Documento
13/08/2026 G1 [106] Mandado
07/08/2026 G1 [12288] Confirmada
07/08/2026 G1 [985] Mandado
06/08/2026 G1 [12287] Expedida/Certificada
Cinco linhas, de um processo com 613 movimentos em dois graus.
E repare no que a ordenação fez sem alarde: os cinco mais recentes são todos de G1. A apelação em segundo grau é de 2022; o processo continuou andando em primeiro grau até agosto de 2026. Uma Tool que pegasse o primeiro hit do índice poderia ter devolvido a história errada — e devolveria sem erro nenhum.
O guarda também funciona:
>>> ultimos_movimentos('123')
ValueError: '123' não é um número de processo. São 20 dígitos, com ou sem máscara.
Zero requisições ao CNJ para produzir esse erro.
O que a redução custou
Toda redução perde alguma coisa. Vale dizer o quê, em voz alta:
complementosTabelados foi embora. Com ele, o motivo de uma remessa, o tipo de uma distribuição, a classe anterior numa mudança de classe. Para "o que andou no processo", não faz falta. Para "por que este processo foi remetido", faria — e aí a resposta certa não é devolver tudo sempre, é escrever uma segunda Tool que traga o complemento de um movimento específico.
Movimentos antigos ficaram fora. É o desenho, não um efeito colateral: o pedido é "últimos". Se a pergunta for "quando transitou em julgado", esta Tool não serve — serve uma que filtre por código da TPU.
A tentação, ao perceber isso, é aumentar o quantidade padrão "para garantir". Ou subir LIMITE_MOVIMENTOS de 20 para 100.
Não é o caminho. O padrão de 5 e o teto de 20 não estão ali porque 21 movimentos seriam demais para um humano ler — estão porque o agente chama esta Tool em laço, uma vez por processo, e cada retorno se soma no contexto. Cinco processos × 20 movimentos já é uma Observation grande.
Quando a resposta certa exigir mais dado, escreva outra Tool com outro recorte. Uma Tool que devolve mais é uma Tool pior; o que resolve é ter a Tool certa para a pergunta.
A lição
A Unidade 2 terminou com uma frase sobre o tipo que a Tool devolve. Esta metade da Unidade 3 acrescenta uma sobre o tamanho:
A rede traz o processo inteiro; a janela do modelo não. O que separa uma coisa da outra é a sua Tool — e ela separa por decisão, não por sorte.
É por isso que este ofício não é "chamar a API". Chamar a API são seis linhas de JSON. O trabalho é saber que a mediana é 74, que o máximo é 613, que o mesmo número existe em dois graus, e que a data vem em ISO por um motivo.
Nada disso estava na documentação. Tudo isso saiu de medir.
Exercício
Escreva movimentos_por_codigo(numero_processo, codigo), que devolve os movimentos de um processo cujo código da TPU seja o pedido — por exemplo, 848 para trânsito em julgado.
Duas perguntas para responder antes de escrever, porque são elas que este capítulo treinou:
- O filtro por código deve ir na consulta ao CNJ ou no Python depois? (Dica:
movimentosnão é camponestedneste índice. Teste antes de decidir.) - Se o código não aparecer no processo, a Tool devolve lista vazia ou levanta
ValueError? Reveja o quelistar_assuntosfaz na Unidade 2, e por quê.
No próximo capítulo, estas três Tools saem do script — e passam a existir fora dele.