Pular para o conteúdo

As Tools, agora contra o CNJ

São duas, e são as mesmas duas da Unidade 1:

  1. listar_assuntos(termo) — resolve o que o juiz falou para o que o índice conhece
  2. buscar_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 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.

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.

Nota

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]
Atenção

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":

Atenção

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.

Nota

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.

Nota

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.

Fixando o capítulo

Q1: Por que listar_assuntos filtra a lista em Python, em vez de deixar o Elasticsearch buscar pelo termo parcial?

Q2: Por que buscar_processos devolve uma lista de processos, e não um dicionário com a lista dentro?

Q3: A Tool formata 00290982020268190000 como 0029098-20.2026.8.19.0000 antes de devolver. Por que não deixar o modelo formatar?