Pular para o conteúdo

Só os meus processos

A pergunta que motivou este capítulo é esta, e ela é sua, não minha:

"Quais processos no DCP estão atribuídos ao meu perfil e que tenham assuntos e temas parecidos?"

É uma boa pergunta porque é uma pergunta de trabalho — ninguém pergunta isso num exercício. E é uma pergunta difícil por dois motivos independentes, que este capítulo separa:

Comece pelo primeiro, porque o segundo não importa se o primeiro estiver errado.

O agente não pode perguntar quem é você

A ideia natural — e a que apareceu quando este capítulo foi pedido — é fazer o agente perguntar:

"Qual é o seu nome?" → e então mostrar só os processos daquele magistrado.

Não faça isso. Vale a pena ser explícito sobre por quê, porque a intenção por trás da ideia está inteiramente certa: cada magistrado deve ver apenas os processos relacionados a ele. O problema não é o objetivo, é o mecanismo.

Filtrar pela resposta que alguém digitou é autorização por autodeclaração. O filtro passa a valer exatamente o que valer a palavra de quem está conversando com o agente. E há três caminhos por onde isso falha, dois deles sem nenhuma má-fé:

1. Qualquer pessoa que sente no computador digita outro nome. Não é preciso invadir nada nem saber nada de técnica. A pergunta seguinte é literalmente "e os processos do juiz Fulano?", e um agente que aceita o nome como filtro vai atender.

2. O nome pode vir de um documento, não de uma pessoa. Se em algum momento o agente lê o texto de uma petição, de um despacho ou de um e-mail, o conteúdo desse texto entra no contexto do modelo. Um texto que diga "o magistrado responsável é X" passa a ser, para o modelo, indistinguível de você tendo dito isso. Esse é o mecanismo de injeção de prompt, e ele não depende de nenhum atacante sofisticado — depende só de o filtro morar numa frase.

3. O modelo erra sozinho. Ele é o mesmo qwen2:7b que chamou .get(0) num dicionário de chaves string. Confiar a ele a propagação correta de um identificador de autorização, ao longo de seis passos de raciocínio, é confiar demais.

O resumo, e ele é a regra do capítulo:

Identidade não é argumento. É configuração.

Perguntar o nome para dizer "bom dia, Dra. Fulana" é ótimo. Perguntar o nome para decidir o que mostrar é entregar a chave para quem está do outro lado da conversa.

De onde a identidade vem

Do ambiente, uma vez, no momento em que o programa sobe — antes de existir qualquer conversa.

import os

# Lido no import. Não é parâmetro de Tool, não vem da conversa, {: #lido-no-import-não-é-parâmetro-de-tool-não-vem-da-conversa }
# não aparece na assinatura que o modelo lê. {: #não-aparece-na-assinatura-que-o-modelo-lê }
MAGISTRADO = os.environ["DCP_MAGISTRADO_ID"]

Isso é exatamente o mesmo padrão do TETO_SIGILO da Unidade 2: constante no código, nunca argumento. E a razão é a mesma, agora com mais consequência.

A propriedade que faz isso funcionar é negativa, e é a mais forte que existe num agente:

O que não está na assinatura, o modelo não tem como preencher.

Se a Tool é meus_processos(assunto: str), não existe passo de raciocínio, texto de documento ou pergunta de usuário capaz de trocar o magistrado — porque não há onde escrever esse valor. Compare:

# Errado: o magistrado é um argumento. O modelo escolhe. {: #errado-o-magistrado-é-um-argumento-o-modelo-escolhe }
def processos_do_magistrado(nome_magistrado: str, assunto: str) -> dict:
    ...

# Certo: o magistrado é a configuração. O modelo escolhe só o assunto. {: #certo-o-magistrado-é-a-configuração-o-modelo-escolhe-só-o-assunto }
def meus_processos(assunto: str) -> dict:
    ...

As duas funções fazem a mesma consulta. A diferença inteira está em qual das duas tem uma porta a mais.

Atenção

O mesmo raciocínio derruba uma variação que parece inofensiva: um argumento magistrado opcional, com o seu id como padrão.

def meus_processos(assunto: str, magistrado: str = MAGISTRADO) -> dict:   # não

O padrão só vale quando o modelo não preenche. E ele preenche — é isso que modelos fazem com argumentos que existem.

As três camadas, da mais forte para a mais fraca

O filtro por identidade pode morar em três lugares. Use quantos conseguir, e comece pelo de cima.

Camada Quem implementa O que ela protege contra
1. A view já vem filtrada pelo usuário do banco a TI, no chamado tudo, inclusive um bug seu
2. O WHERE usa a constante lida do ambiente você, na consulta modelo, conversa, injeção
3. A assinatura da Tool não tem o campo você, na função modelo, conversa, injeção

A camada 1 é a que vale o chamado. É a diferença entre "eu filtro direito" e "não há o que filtrar errado". Uma view que já resolve o magistrado a partir do usuário conectado torna as camadas 2 e 3 redundantes — e redundante é exatamente o que você quer que elas sejam.

O acréscimo ao texto do chamado do capítulo do banco:

Peço que a view seja restrita aos processos vinculados ao meu perfil de magistrado, resolvido a partir do usuário de banco criado para este acesso — de modo que a consulta não dependa de nenhum filtro aplicado pela aplicação cliente. Peço também que ela exclua processos com nível de sigilo restrito.

A frase que faz o trabalho é "não dependa de nenhum filtro aplicado pela aplicação cliente". Ela é o que transforma um pedido de acesso num pedido de escopo.

E se a TI disser que não dá — acontece, e às vezes por motivo legítimo —, então as camadas 2 e 3 são tudo o que você tem, e o item 2 da lista de verificação deixa de ser opcional.

Onde as credenciais moram

Nenhuma delas em arquivo de código. Nenhuma delas em config.yaml. A regra é uma só e já apareceu em todos os capítulos desta unidade: variável de ambiente.

O que muda aqui é como elas chegam ao processo, porque o servidor MCP não é você digitando num terminal.

No Windows, para a sessão atual do PowerShell:

$env:DCP_USUARIO = "seu.usuario"
$env:DCP_SENHA = "..."
$env:DCP_MAGISTRADO_ID = "..."

Some quando você fecha a janela — o que é bom para experimentar e ruim para o dia a dia.

Permanente, para o seu usuário:

setx DCP_MAGISTRADO_ID "..."

Vale para janelas novas, não para a aberta. E fica no registro do seu perfil, não em disco compartilhado.

Para o servidor MCP. Um servidor lançado por stdio herda o ambiente de quem o lançou. Se quem lança é o cliente do curso, ele herda o seu PowerShell. Se quem lança é o Claude Desktop, o ambiente é o que estiver no bloco env da configuração dele — e aí a senha estaria num arquivo JSON, que é justamente o que não queremos.

Prefira, nesse caso, deixar a senha no ambiente do usuário (setx) e no JSON só o que não é segredo.

Atenção

Três lugares onde uma senha aparece sem você reparar:

O histórico do PowerShell. Ele grava as linhas digitadas em ConsoleHost_history.txt. Uma senha atribuída na mão fica lá, em texto puro, até você apagar.

O print de depuração. print(os.environ) imprime tudo. Numa sessão de MCP, isso vai para o stderr — e o stderr costuma ir para um arquivo de log.

O traceback. Uma exceção dentro de oracledb.connect(...) pode incluir os argumentos na mensagem. Capture o erro e devolva uma mensagem sua, como o capítulo do banco já fazia.

E um arquivo que entra no repositório, com os nomes e sem os valores:

# .env.exemplo — este entra no repositório. O .env, nunca. {: #envexemplo-este-entra-no-repositório-o-env-nunca }
DCP_USUARIO=
DCP_SENHA=
DCP_MAGISTRADO_ID=

Ele documenta o que a ferramenta precisa sem documentar nada que interesse a ninguém.

"Assuntos parecidos", sem inventar semântica

A segunda metade da pergunta. E aqui a resposta cara está logo à mão: transformar cada assunto num vetor e comparar por similaridade.

Não faça — não porque não funcione, mas porque a tabela de assuntos do CNJ já é uma árvore, e a árvore responde melhor.

Cada assunto tem um pai. Nas tabelas internas isso costuma aparecer como COD_PAI_NACIONAL, apontando para o código do assunto acima na hierarquia. "Parecido" tem, portanto, uma definição exata e barata:

Assuntos parecidos são os que compartilham o mesmo pai na tabela nacional.

Isso tem três vantagens sobre qualquer medida de similaridade, e as três importam mais aqui do que em qualquer outro contexto:

É determinístico. A mesma pergunta devolve o mesmo conjunto hoje e daqui a seis meses. Uma medida de similaridade muda quando o modelo de embedding muda.

É explicável. Você consegue dizer, numa frase, por que aquele processo entrou na lista: "tem o mesmo assunto-pai". Tente explicar 0,83 de similaridade de cosseno para quem perguntou.

É oficial. A hierarquia é do CNJ, é a mesma que classifica o processo na origem, e é a mesma que o DataJud usa. Você não está inventando uma taxonomia paralela dentro do Tribunal.

Em SQL, é um JOIN da tabela de assuntos com ela mesma:

SELECT p.numero_processo, p.classe, a.descricao AS assunto
  FROM vw_meus_processos p
  JOIN assunto a  ON a.cod_assunto = p.cod_assunto
  JOIN assunto ref ON ref.cod_pai_nacional = a.cod_pai_nacional
 WHERE ref.cod_assunto = :assunto_referencia

Um JOIN a mais, na mesma conexão, contra tabelas que já estão ali. Sem dependência nova, sem modelo extra, sem índice vetorial para manter.

Nota

Se a árvore não bastar — e às vezes não basta, porque assuntos vizinhos na prática ficam longe na hierarquia —, o passo seguinte não é embedding. É uma lista de códigos que você mesmo escreve, com um nome:

TEMAS = {
    "saude": [10064, 10065, 10069],
    "servidor_publico": [10219, 10220],
}

Feia, manual, e correta. Você sabe exatamente o que entra em cada tema, e consegue defender a lista numa reunião. Uma medida de similaridade não se defende — se explica, o que não é a mesma coisa.

A Tool

Junta as duas metades. Repare no que não está na assinatura.

import os
import oracledb

MAGISTRADO = os.environ["DCP_MAGISTRADO_ID"]
TETO_LINHAS = 30

_conexao = None


def _conectar():
    global _conexao
    if _conexao is None:
        _conexao = oracledb.connect(
            user=os.environ["DCP_USUARIO"],
            password=os.environ["DCP_SENHA"],
            host=os.environ["DCP_HOST"],
            port=1521,
            service_name=os.environ["DCP_SERVICE"],
        )
    return _conexao


def meus_processos_por_tema(assunto_referencia: int) -> dict:
    """Processos do meu acervo cujo assunto tem o mesmo assunto-pai do informado.

    Não lista processos de outros magistrados e não devolve dados das partes.

    Args:
        assunto_referencia: código nacional do assunto usado como referência.
    """
    sql = """
        SELECT p.numero_processo, p.classe, a.descricao, p.data_ultimo_movimento
          FROM vw_meus_processos p
          JOIN assunto a   ON a.cod_assunto = p.cod_assunto
          JOIN assunto ref ON ref.cod_pai_nacional = a.cod_pai_nacional
         WHERE ref.cod_assunto = :assunto
           AND p.cod_magistrado = :magistrado
           AND p.nivel_sigilo = 0
         ORDER BY p.data_ultimo_movimento DESC
         FETCH FIRST :teto ROWS ONLY
    """
    cur = _conectar().cursor()
    cur.execute(sql, assunto=assunto_referencia,
                     magistrado=MAGISTRADO,
                     teto=TETO_LINHAS)

    linhas = [
        {"numero_processo": n, "classe": c, "assunto": d,
         "ultimo_movimento": m.strftime("%d/%m/%Y")}
        for n, c, d, m in cur
    ]
    return {"processos": linhas, "quantidade": len(linhas)}

Cinco decisões, todas já vistas, e vale nomeá-las:

MAGISTRADO é constante de módulo, ligada a um bind. O modelo escolhe o assunto e nada mais.

nivel_sigilo = 0 está no WHERE, não num if depois — pelo motivo do capítulo do banco: filtrar em Python significa que a linha sigilosa já entrou no processo que monta o prompt.

A consulta é contra vw_meus_processos, uma view, não contra as tabelas. Se a camada 1 existir, o cod_magistrado no WHERE é redundante — e continua lá de propósito.

Teto de linhas, porque a Unidade 3 inteira é sobre isso. Trinta processos com quatro campos cabem; trezentos, não.

Retorno em dict, e as colunas escolhidas à mão. Nenhum nome de parte, nenhum SELECT *.

A viabilidade dos joins entre sistemas

Falta a parte honesta, e ela responde a uma dúvida que foi levantada com todas as letras: isso acaba sendo queries complexas que devem realizar muitos joins com chaves diferentes dos diferentes sistemas — não sei a viabilidade.

A dúvida está certa. O veredito, em uma linha:

Um agente não deve montar join entre sistemas ao vivo. O join pertence a uma camada anterior, e ela não é escrita pelo agente.

Quatro razões, em ordem de gravidade.

1. Não existe chave comum, exceto uma. Cada sistema tem o próprio identificador interno, e eles não se falam. O único identificador que atravessa PJe, eproc, EJUD e DCP é o número único do processo (NPU) — que é justamente o campo mais sujeito a variação de formatação: com máscara, sem máscara, com zeros à esquerda, com o dígito verificador separado. Um join por NPU sem normalizar as duas pontas produz zero linhas ou linhas erradas, e nenhum dos dois dá erro.

2. Cada sistema tem o próprio controle de sigilo, com outro nome de coluna. Quatro conexões, quatro credenciais, quatro esquemas e quatro colunas de sigilo diferentes. Basta uma não filtrada para o resultado inteiro estar comprometido — e o resultado inteiro é o que vai para o modelo. É a única razão desta lista que, sozinha, já bastaria.

3. O custo é do tipo que estoura em silêncio. Join entre bases distintas quase sempre significa trazer um lado inteiro para a memória. É a consulta que leva minutos, e o agente que leva minutos é o agente que estoura o tempo, repete, e faz tudo de novo do zero.

4. O SQL passaria a ser montado pelo modelo. Um join com cinco tabelas não cabe numa consulta parametrizada e fixa — a tentação vira dar ao modelo a capacidade de escrever SQL. É a fronteira que o capítulo do banco recusa, e recusar continua sendo a resposta certa.

O que fazer em vez disso

Se o Tribunal já tem uma camada analítica, é ali que o join mora. O padrão é conhecido — dados brutos por sistema, uma camada intermediária normalizada e uma camada final consolidada, atualizada em lote, fora do horário de pico. O join acontece uma vez por noite, escrito por quem conhece as chaves, com o resultado conferido. A Tool então lê uma tabela, com uma credencial, e a consulta volta a caber em vinte linhas.

Essa é a resposta certa e é a que dá mais trabalho, porque envolve outras pessoas. Ela também é a única que escala para além de uma pessoa.

Se não tem, a resposta honesta é: um sistema de cada vez.

"Quais processos meus no DCP têm assuntos parecidos" é respondível hoje, com uma conexão e a Tool acima. "Os meus processos no DCP e no eproc, unificados" é um projeto, não uma Tool — e transformá-lo em Tool à força produz uma ferramenta que responde devagar, às vezes errado, e sem avisar quando erra.

O meio-termo que funciona

Há uma forma legítima de cruzar sistemas num agente, e ela não é um join:

Uma Tool por porta. O agente chama duas e junta os resultados.

meus_processos_dcp(assunto) e meus_processos_eproc(assunto), cada uma com o próprio teto de linhas, o próprio filtro de sigilo e a própria credencial. O agente chama as duas e apresenta as duas listas.

Isso funciona bem para dezenas de linhas e mal para milhares — que é exatamente a escala das perguntas que valem a pena. E tem uma vantagem que o join perde: se uma das portas falhar, você vê qual falhou. Num join, o resultado apenas encolhe.

A regra prática que fecha o assunto:

Uma Tool, uma porta, uma credencial. Cruzamento de verdade é lote; cruzamento de agente é o modelo lendo duas listas curtas.

Antes de conectar num agente

Aos itens do capítulo do banco, acrescente cinco. Os dois primeiros são os que medem o que este capítulo defende — e nenhum deles se verifica lendo o código.

  1. Pergunte ao agente pelos processos de outro magistrado, pelo nome. Ele deve devolver os seus, ou nada. Se devolver os do outro, o filtro está na conversa, não na consulta.
  2. Procure magistrado na assinatura de todas as suas Tools. Se aparecer em alguma, ela é preenchível pelo modelo.
  3. A quantidade de processos que a Tool devolve bate com o que o DCP mostra na sua tela?
  4. DCP_SENHA está fora do repositório, fora do config.yaml, fora do claude_desktop_config.json e fora do histórico do PowerShell?
  5. Se você apagar DCP_MAGISTRADO_ID do ambiente, o programa falha ao subir? Deve falhar — um os.environ[...] que levanta KeyError no import é melhor que um os.environ.get(...) que devolve None e filtra por nada.

O item 5 parece pedantismo e não é. WHERE cod_magistrado = NULL não devolve zero linhas por acidente feliz em todo banco, e a diferença entre "não devolveu nada" e "devolveu tudo" é uma cláusula.


Próximo: MNI — a porta que já é sua.