Pular para o conteúdo

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.

Nota

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.

Atenção

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:

  1. O filtro por código deve ir na consulta ao CNJ ou no Python depois? (Dica: movimentos não é campo nested neste índice. Teste antes de decidir.)
  2. Se o código não aparecer no processo, a Tool devolve lista vazia ou levanta ValueError? Reveja o que listar_assuntos faz na Unidade 2, e por quê.

No próximo capítulo, estas três Tools saem do script — e passam a existir fora dele.