Pular para o conteúdo

Apêndice — consultas estruturadas no eproc

O capítulo anterior entregou uma Tool: processos meus com um determinado movimento. Ela responde uma pergunta de cada vez, e responde bem.

Este apêndice é sobre o que vem depois — quando a pergunta deixa de ser "quais processos têm o movimento X" e passa a ser "quantos entraram este ano, quantos foram julgados, quantos estão suspensos e quantos continuam pendentes". É a família de perguntas que produz indicador, e é a que mais dá resposta errada com cara de certa.

A boa notícia: não são dezenas de consultas diferentes. São quatro tijolos, combinados de jeitos diferentes.

Atenção

Medido: os padrões deste apêndice vêm de um projeto real de cálculo das Metas do CNJ para a 2ª instância, rodando sobre o acervo do eproc de 2º grau ao lado do sistema legado. As armadilhas descritas aqui — o código que não existe, a contagem inflada pelo JOIN, estoque confundido com fluxo — são erros que aconteceram e foram corrigidos, não hipóteses.

Não medido: o seu esquema, de novo. Todo nome de tabela e de coluna abaixo é hipótese a confirmar com o WHERE 1=0 do capítulo do banco. O nome da coluna de situação, o nome da coluna de data, o esquema onde a tabela mora: os três variam.

E o de sempre: senha nenhuma entra em consulta, em código ou em anotação. Ela vive em variável de ambiente, como no capítulo do eproc. Se você encontrar credencial escrita dentro de um script — e vai encontrar —, isso é achado a comunicar, não a copiar.

Os quatro tijolos

Toda consulta de indicador é feita destes quatro, nesta ordem:

Tijolo O que responde
1 Recorte quais processos sequer entram na conta
2 Varredura de eventos o que aconteceu com eles, e quando
3 Primeiro / último por processo qual evento vale, quando há vários
4 O que falta quem não tem o evento seguinte

O tijolo 3 é o que separa uma consulta que funciona de uma que quase funciona, e o tijolo 4 é o que quase todo mundo escreve errado. Um de cada vez.

1. Recorte

O recorte é uma CTE, e existe para ser lido em voz alta:

WITH elegiveis AS (
    SELECT p.id_processo,
           p.numero_processo,
           p.id_classe_judicial
      FROM processo p
     WHERE p.nivel_sigilo   = 0
       AND p.cod_magistrado  = %(magistrado)s
)

Duas coisas moram aqui e em nenhum outro lugar: o sigilo e a identidade. Repetidas em cada consulta, elas viram algo que se esquece uma vez; concentradas numa CTE que toda consulta usa, elas viram algo que só dá para esquecer de propósito.

2. Varredura de eventos

eventos AS (
    SELECT e.id_processo,
           e.cod_mni_movimento,
           e.data_evento
      FROM processo_evento e
      JOIN elegiveis g ON g.id_processo = e.id_processo
     WHERE e.cod_mni_movimento = ANY(%(codigos)s)
       AND e.data_evento < %(fim)s
)

= ANY(%(codigos)s) recebe uma lista Python direto, sem montar IN (...) com concatenação de string — que é como se escreve injeção de SQL sem perceber.

Repare no < %(fim)s sem limite inferior. É proposital, e a seção de estoque e fluxo explica por quê.

3. Primeiro ou último por processo

Um processo tem muitos eventos do mesmo tipo. A pergunta quase sempre quer um: a primeira distribuição, o último julgamento, a última suspensão.

No PostgreSQL há duas formas, e a segunda é a que ele faz melhor que os outros bancos:

-- Forma geral (funciona em qualquer banco moderno)
SELECT id_processo, data_evento
  FROM (
      SELECT id_processo, data_evento,
             row_number() OVER (PARTITION BY id_processo
                                ORDER BY data_evento ASC) AS n
        FROM eventos
  ) t
 WHERE n = 1
-- Forma do PostgreSQL, mais curta e mais rápida
SELECT DISTINCT ON (id_processo) id_processo, data_evento
  FROM eventos
 ORDER BY id_processo, data_evento ASC

Troque ASC por DESC e "primeiro" vira "último". É a mesma consulta.

Atenção

No DISTINCT ON, o ORDER BY precisa começar pelas mesmas colunas do DISTINCT ON. Se não começar, o PostgreSQL devolve uma linha qualquer do grupo — sem erro, sem aviso. É a assinatura que este curso persegue desde a Unidade 3: resultado plausível.

4. O que falta

"Distribuídos e ainda não julgados" não é uma subtração de contagens. É um anti-join:

SELECT d.id_processo
  FROM distribuidos d
 WHERE NOT EXISTS (
       SELECT 1 FROM julgados j
        WHERE j.id_processo = d.id_processo
 )

Subtrair count(distribuídos) - count(julgados) dá um número que quase sempre está errado, porque os dois conjuntos não são um subconjunto do outro: há processos julgados este ano que foram distribuídos no ano passado. O anti-join responde a pergunta certa; a subtração responde uma pergunta que ninguém fez.

Estoque e fluxo

Esta distinção vale metade do apêndice.

Fluxo Estoque
Pergunta quantos aconteceram no período quantos estão assim no fim do período
Exemplo processos suspensos durante o ano processos suspensos em 31/12
Filtro de data BETWEEN inicio AND fim < fim, e depois o último evento
Muda se eu rodar amanhã? não sim

Um processo suspenso em março e retomado em agosto entra no fluxo de suspensões do ano e não está no estoque de 31/12. Contar os dois com a mesma consulta é o erro mais caro desta família, porque o número sai coerente, sai estável entre execuções, e sai errado.

A regra prática, em SQL:

-- ESTOQUE: último evento de suspensão até o fim do período,
-- e ele tem que ser um evento que SUSPENDE (não um que retoma).
suspensos_no_fim AS (
    SELECT DISTINCT ON (id_processo) id_processo, cod_mni_movimento, data_evento
      FROM eventos_suspensao          -- suspende E retoma, juntos
     WHERE data_evento < %(fim)s
     ORDER BY id_processo, data_evento DESC
)
SELECT count(*) FROM suspensos_no_fim
 WHERE cod_mni_movimento = ANY(%(codigos_que_suspendem)s)

Repare que a varredura precisa trazer os dois tipos de evento — suspensão e retomada — para descobrir qual foi o último. Uma consulta que filtra só os eventos de suspensão nunca fica sabendo da retomada, e devolve como suspenso quem voltou a andar em agosto.

Numa Tool, isso vira uma chave na resposta. "recorte": "estoque em 2026-12-31" ou "recorte": "fluxo em 2026" custa nada, aparece na conversa e impede o modelo de dizer "suspensos" sem dizer qual dos dois. Sem essa chave, você tem dois números diferentes com o mesmo nome — e nenhuma forma de saber qual está na tela.

O gatilho depende da classe

Aqui a regra de negócio invade a consulta, e não tem como evitar.

Um processo entra numa meta quando fica pendente de julgamento. Mas "ficar pendente" só vale se antes tiver acontecido o evento de entrada certo para aquela classe — e o evento certo é diferente conforme o tipo de processo:

Grupo de classe Eventos de entrada válidos
Processos de conhecimento distribuição
Procedimentos investigatórios recebimento de denúncia, início de fase, evolução para ação penal

Um processo de conhecimento cuja pendência foi precedida de "recebimento de denúncia" não vale: é gatilho da família errada. O contrário também.

Duas consequências para quem escreve a Tool:

A validação é uma comparação de datas, não uma presença. Não basta o processo ter o evento de entrada em algum lugar da vida: o evento tem que ser anterior à primeira pendência. Em SQL, WHERE gatilho.data <= pendencia.primeira_data — e é aí que o tijolo 3 é obrigatório dos dois lados.

A lista de códigos não é constante do código. Ela muda quando o CNJ republica a parametrização, e republica a cada poucos meses. Deixe num arquivo de configuração ao lado, não espalhada em if. O agente não precisa saber que a lista existe; você precisa saber onde ela está no dia em que o número mudar sem ninguém ter mexido em nada.

O código que não existe

A armadilha mais silenciosa deste apêndice, e a mais fácil de reproduzir.

Uma consulta escrita para o sistema legado filtra cinco códigos de entrada. Copiada para o eproc, ela roda sem erro nenhum — e devolve menos processos, porque um daqueles cinco códigos não existe no eproc. O evento correspondente simplesmente não é gerado por esse sistema.

Não há mensagem. Não há linha faltando visível. Há um total menor, que ninguém questiona porque ninguém tinha o total certo para comparar.

A defesa é uma consulta de três linhas, rodada antes de confiar em qualquer filtro:

SELECT cod_mni_movimento, count(*) AS n
  FROM processo_evento
 WHERE cod_mni_movimento = ANY(%(codigos)s)
 GROUP BY cod_mni_movimento
 ORDER BY cod_mni_movimento

Se um dos códigos que você passou não aparece na saída, ele não existe nessa base. Ou o sistema não o gera, ou o nome da coluna é outro, ou a sua janela de data cortou tudo. Os três casos exigem uma decisão sua; nenhum dos três aparece se você pular esta consulta.

Da mesma família, e igualmente barata:

SELECT count(*)                                                   AS total,
       count(*) FILTER (WHERE cod_mni_movimento IS NULL)          AS sem_movimento,
       count(*) FILTER (WHERE data_evento IS NULL)                AS sem_data,
       min(data_evento)                                           AS mais_antigo,
       max(data_evento)                                           AS mais_recente
  FROM processo_evento

NULL numa coluna de filtro é linha que some sem avisar — WHERE coluna = 3 descarta os NULL silenciosamente. E o min/max de data responde de graça a pergunta que evita a maior perda de tempo possível: esta base tem os anos que eu preciso?

Conte antes de filtrar. Uma consulta de diagnóstico custa dois minutos; um indicador errado custa a confiança de quem o leu.

Contagem inflada pelo JOIN

O erro mais comum de todos, e o único deste apêndice que dá para provar em dez segundos.

-- ERRADO: conta EVENTOS, não processos
SELECT count(*)
  FROM processo p
  JOIN processo_evento e ON e.id_processo = p.id_processo
 WHERE e.cod_mni_movimento = %(codigo)s

Um processo com quatro sobrestamentos conta quatro vezes. O número sai maior que a realidade, e sai plausível.

-- CERTO: conta PROCESSOS
SELECT count(DISTINCT p.id_processo)
  FROM processo p
  JOIN processo_evento e ON e.id_processo = p.id_processo
 WHERE e.cod_mni_movimento = %(codigo)s

A prova de que você acertou é rodar as duas e olhar a diferença. Se count(*) e count(DISTINCT ...) derem o mesmo número, ótimo — mas confira por quê, porque muitas vezes significa que a sua janela de data está tão estreita que cada processo só tem um evento dentro dela.

Na Tool, isto é a diferença entre "42 processos sobrestados" e "42 sobrestamentos, em 19 processos". A segunda frase é a verdadeira, e é a que o magistrado precisa ouvir.

Uma Tool que responde indicador

Juntando: recorte com sigilo e identidade, tijolo 3 para pegar o primeiro evento, tijolo 4 para o que falta, teto de linhas, e as chaves que impedem o modelo de inventar o contexto.

TETO_LINHAS = 30

@mcp.tool(structured_output=True)
def pendentes_de_julgamento_2grau(ano: int) -> dict:
    """Processos meus distribuídos no ano e ainda não julgados.

    Args:
        ano: ano de referência, quatro dígitos. Ex.: 2026.
    """
    sql = """
        WITH elegiveis AS (
            SELECT p.id_processo, p.numero_processo, p.classe
              FROM processo p
             WHERE p.nivel_sigilo  = 0
               AND p.cod_magistrado = %(magistrado)s
        ),
        distribuidos AS (
            SELECT DISTINCT ON (e.id_processo)
                   e.id_processo, e.data_evento
              FROM processo_evento e
              JOIN elegiveis g ON g.id_processo = e.id_processo
             WHERE e.cod_mni_movimento = ANY(%(cod_entrada)s)
             ORDER BY e.id_processo, e.data_evento ASC
        ),
        julgados AS (
            SELECT DISTINCT e.id_processo
              FROM processo_evento e
             WHERE e.cod_mni_movimento = ANY(%(cod_julgamento)s)
               AND e.data_evento < %(fim)s
        )
        SELECT g.numero_processo, g.classe, d.data_evento
          FROM distribuidos d
          JOIN elegiveis g ON g.id_processo = d.id_processo
         WHERE d.data_evento >= %(inicio)s
           AND d.data_evento <  %(fim)s
           AND NOT EXISTS (SELECT 1 FROM julgados j
                            WHERE j.id_processo = d.id_processo)
         ORDER BY d.data_evento
         LIMIT %(teto)s
    """
    ...
    return {
        "fonte": "eproc 2º grau",
        "recorte": f"distribuídos em {ano}, sem julgamento até 31/12/{ano}",
        "quantidade": len(linhas),
        "truncado": len(linhas) == TETO_LINHAS,
        "processos": [...],
    }

Três detalhes que não são estilo:

A janela de julgamento não é a janela de distribuição. julgados olha tudo até o fim do período; distribuidos olha só o ano. Um processo distribuído em fevereiro e julgado em novembro do ano seguinte ainda estava pendente em 31/12 — e essa é a resposta certa. Igualar as duas janelas é o erro que faz o número parecer melhor do que é.

"recorte" está escrito por extenso, com as datas. É a frase que o modelo vai repetir. Se ela estiver certa, ele acerta; se estiver ausente, ele inventa uma.

O LIMIT corta a lista, não a contagem. Se a Tool também precisa dizer quantos são, a contagem tem que vir de um count(DISTINCT ...) sem LIMIT — são duas consultas, e é assim mesmo. Devolver len(linhas) como se fosse o total é o mesmo bug do "truncado" do capítulo anterior, com outra roupa.

Antes de acreditar no número

  1. A consulta de diagnóstico rodou, e todos os códigos que você filtra aparecem na saída com contagem maior que zero?
  2. Você sabe dizer, sem pensar, se o seu número é estoque ou fluxo — e a Tool diz isso na resposta?
  3. count(*) e count(DISTINCT id_processo) foram comparados, e você sabe explicar a diferença entre os dois?
  4. As colunas de sigilo e de identidade estão na CTE de recorte, e não espalhadas por cada consulta?
  5. Alguma senha, host ou usuário aparece escrito em algum arquivo do projeto? (Se sim, isso sai antes de qualquer outra coisa.)
  6. Um total conhecido — um que você possa conferir na tela do sistema — bate com o que a consulta devolve?

O item 6 é o único que mede. Todos os outros são higiene; esse é prova. Um único processo conferido à mão, do começo ao fim, vale mais do que seis consultas que rodaram sem erro.


Próximo: SEI — o lado administrativo, onde a chave deixa de ser sua outra vez.