Parte 3 : O agente, a checklist e os limites de um RAG mínimo

Parte 3 : O agente, a checklist e os limites de um RAG mínimo

O edital já está no banco, cortado em pedaços e vetorizado, e a busca por similaridade já encontra os trechos certos. Falta transformar isso em resposta. Nesta última parte entra o agente do Laravel AI SDK, que recebe uma ferramenta de busca, consulta o edital por conta própria e devolve a lista de documentos em JSON validado por schema. Depois a gente coloca uma tela em cima.

É também a parte onde o projeto quebrou mais vezes, e as três falhas rendem mais aprendizado que o caminho felizoso. Elas estão no fim do texto.

O que é um agente, no vocabulário do SDK

No Laravel AI SDK, um agente é uma classe PHP que junta quatro coisas: as instruções que o modelo deve seguir, o contexto de conversa, as ferramentas que ele pode chamar e o formato da resposta. Você configura uma vez e usa como um serviço qualquer da aplicação.

docker compose exec app php artisan make:agent HabilitationAnalyst --structured

A diferença em relação a chamar a API na mão é que o agente resolve o laço de ferramentas sozinho. Ele decide que precisa buscar, chama a busca, recebe o resultado, decide se busca de novo, e só então responde. Você não escreve esse laço.

A ferramenta de busca, de graça

A parte que mais impressiona é essa. O SDK já traz uma tool de busca vetorial pronta, que aponta para um model Eloquent seu:

use App\Models\DocumentChunk;
use Laravel\Ai\Tools\SimilaritySearch;

public function tools(): iterable
{
    return [
        SimilaritySearch::usingModel(
            model: DocumentChunk::class,
            column: 'embedding',
            minSimilarity: 0.15,
            limit: 8,
            query: fn ($query) => $query->where('document_id', $this->document->id),
        )->withDescription(
            'Busca trechos do edital por similaridade semântica.'
        ),
    ];
}

O argumento query é o que torna isso usável de verdade. Ele recebe o query builder antes da busca, então dá para escopar o resultado. Aqui ele limita a busca ao documento daquela requisição, o que impede o agente de responder sobre um edital com trechos de outro.

É também onde o $hidden do artigo anterior mostra a que veio. A tool devolve os models serializados para o modelo de linguagem, e sem esconder a coluna embedding cada trecho viria acompanhado de 1536 números de ponto flutuante. O contexto encheria de lixo e o custo por chamada explodiria.

O schema, que é o contrato da resposta

Agente com HasStructuredOutput não devolve texto, devolve dados. O schema é definido com o JsonSchema do Laravel:

public function schema(JsonSchema $schema): array
{
    return [
        'documents' => $schema->array()->items(
            $schema->object(fn ($schema) => [
                'name' => $schema->string()->required(),
                'category' => $schema->string()->enum([
                    'habilitacao_juridica',
                    'regularidade_fiscal_trabalhista',
                    'qualificacao_tecnica',
                    'qualificacao_economico_financeira',
                    'declaracoes',
                    'outros',
                ])->required(),
                'requirement' => $schema->string()
                    ->enum(['obrigatorio', 'condicional'])
                    ->required(),
                'notes' => $schema->string()->required(),
                'evidence' => $schema->string()->required(),
                'page' => $schema->integer()->required(),
                'confidence' => $schema->string()
                    ->enum(['alta', 'media', 'baixa'])
                    ->required(),
            ])
        )->required(),

        'warnings' => $schema->array()->items($schema->string())->required(),
    ];
}

Três campos aqui não são enfeite, são consequência direta do que a gente descobriu no artigo anterior.

O evidence obriga o modelo a copiar o trecho do edital que embasa cada item. É a diferença entre uma lista em que você acredita e uma lista que você confere. page também é obrigatório: a regra mandava informar a página, mas deixar o campo opcional no schema permitia uma resposta formalmente válida sem ela. notes segue a mesma lógica, porque precisa carregar a condição dos documentos condicionais; nos demais itens pode vir como string vazia.

O confidence existe porque o trecho recuperado às vezes vem truncado ou ambíguo, e forçar o modelo a escolher entre afirmar e omitir seria pior que deixá-lo sinalizar dúvida.

O warnings é a saída para o que não cabe na lista. Editais se contradizem, e no de Vargem Bonita isso acontece: o Termo de Referência exige balanço patrimonial para qualificação econômico-financeira, e o Anexo II, que é a lista oficial, não pede balanço nenhum. Um sistema que escolhesse um lado estaria mentindo. O comportamento correto é apontar o conflito e citar as duas cláusulas.

Prompt e schema conversam: o campo existe no schema e a instrução manda usá-lo.

As instruções, onde o projeto ganha ou perde

Essa é a parte com mais efeito por linha escrita. Um modelo de linguagem treinado em português brasileiro conhece a Lei 14.133 e sabe de cor quais documentos uma habilitação normalmente pede. Se você não proibir, ele completa a lista com o que é usual em vez do que o edital diz, e o resultado parece ótimo e está errado.

public function instructions(): string
{
    return <<<'PROMPT'
    Você analisa editais de licitação brasileiros e monta a lista de documentos que o
    licitante precisa apresentar para se habilitar.

    Use a ferramenta de busca para localizar os trechos do edital. Faça várias buscas,
    com termos diferentes, antes de responder. Sugestões de termos: habilitação
    jurídica, contrato social, regularidade fiscal, certidão negativa, FGTS, CNDT,
    falência e recuperação judicial, qualificação técnica, atestado de capacidade
    técnica, qualificação econômico-financeira, balanço patrimonial, declarações,
    anexo de documentos de habilitação.

    Regras que você deve seguir sem exceção:

    1. Liste apenas documentos que apareçam explicitamente exigidos no texto que a
       busca devolveu. Não complete a lista com o que a Lei 14.133/2021 normalmente
       exige, nem com o que outros editais costumam pedir. Se o edital não disse, não
       entra.

    2. Para cada documento, copie em "evidence" o trecho do edital que o exige, na
       forma literal em que ele aparece, sem reescrever. Informe em "page" a página
       indicada no resultado da busca.

    3. Quando um trecho apenas remeter a outra parte do edital, por exemplo dizendo
       que os documentos estão previstos em um anexo, isso não é uma exigência. Busque
       novamente para encontrar o anexo e a lista concreta.

    4. Se o edital exigir o mesmo documento em lugares diferentes com redações
       divergentes, ou se uma parte exigir algo que outra não menciona, registre isso
       em "warnings" citando as duas partes. Não escolha um lado.

    5. Marque "requirement" como "condicional" quando a exigência depender da natureza
       do licitante, do porte da empresa ou de alguma hipótese específica, e explique a
       condição em "notes".

    6. Use "confidence" igual a "baixa" quando o trecho recuperado for ambíguo,
       truncado ou insuficiente para afirmar a exigência com segurança.

    7. Se a busca não devolver base suficiente para montar a lista, devolva "documents"
       vazio e explique em "warnings" o que faltou. Não preencha a lista por dedução.

    Escreva em português. Use o nome do documento como ele aparece no edital.
    PROMPT;
}

A regra 3 é a mais importante das sete, e ela existe por causa de uma observação concreta do artigo anterior. A busca por "documentos exigidos para habilitação" traz em primeiro lugar o item 7 do edital, que se chama HABILITAÇÃO e não lista documento nenhum: ele só remete ao Anexo II. Sem essa regra, o agente lê o trecho mais bem colocado, entende que os documentos estão em um anexo e para ali, ou pior, preenche a lista de memória. Com ela, ele busca de novo e encontra a lista de verdade.

A regra 1 é a que protege contra alucinação plausível, que é a pior espécie. E a regra 7 dá ao modelo uma saída honesta, porque sem ela a única opção diante de contexto insuficiente é inventar.

Configuração por atributos

#[Provider(Lab::OpenAI)]
#[MaxSteps(10)]
#[Timeout(180)]
class HabilitationAnalyst implements Agent, HasStructuredOutput, HasTools

O MaxSteps é o número de idas e voltas que o agente pode dar chamando ferramentas. Como as instruções pedem várias buscas com termos diferentes, o padrão não bastaria.

O Timeout tem história, e ela está na seção de depuração no fim do texto.

Uma observação sobre o que eu não coloquei: o atributo Temperature. Zero seria o valor natural para uma tarefa de extração, mas modelos de raciocínio mais recentes da OpenAI rejeitam esse parâmetro, e o schema já restringe o formato o suficiente. Ficou de fora para não amarrar o projeto a uma família de modelos.

Chamando o agente

$response = HabilitationAnalyst::make(document: $document)
    ->prompt('Monte a lista de documentos de habilitação exigidos por este edital.');

$response['documents'];
$response['warnings'];

O make() resolve o agente pelo container, o que permite injeção de dependência e argumentos nomeados no construtor. A resposta estruturada se comporta como array, então você acessa os campos do schema direto.

Vale criar um comando para rodar isso pelo terminal antes de existir interface, porque depurar prompt pela tela é lento:

docker compose exec app php artisan edital:analyze

O endpoint

Um endpoint só, que recebe o PDF, indexa e analisa:

public function analyze(Request $request): JsonResponse
{
    $validated = $request->validate([
        'edital' => ['required', 'file', 'mimes:pdf', 'max:20480'],
    ]);

    $file = $validated['edital'];
    $path = $file->store('editais', 'local');

    $absolutePath = Storage::disk('local')->path($path);

    try {
        $document = $this->ingestor->ingest(
            path: $absolutePath,
            originalName: $file->getClientOriginalName(),
        );

        $response = HabilitationAnalyst::make(document: $document)
            ->prompt('Monte a lista de documentos de habilitação exigidos por este edital.');

        return response()->json([
            'document' => [
                'name' => $document->original_name,
                'pages' => $document->page_count,
            ],
            'documents' => $response['documents'] ?? [],
            'warnings' => $response['warnings'] ?? [],
        ]);
    } catch (Throwable $e) {
        Log::error('Falha ao analisar o edital', ['exception' => $e]);

        return response()->json([
            'message' => 'Não foi possível analisar o edital. Consulte os logs da aplicação.',
        ], 500);
    }
}

É uma requisição longa, de dezenas de segundos, e isso é uma escolha consciente de projeto de estudo. Em uso real esse trabalho iria para uma fila. Aqui a simplicidade de um endpoint só vale mais. A exceção completa fica no log; a resposta HTTP é deliberadamente genérica, para não entregar ao navegador caminho interno, mensagem do provedor ou detalhe de configuração. O status é 500 porque uma falha na extração, no banco ou no provedor não torna o PDF semanticamente inválido; os erros de validação do upload continuam sendo tratados pelo Laravel como 422.

Note também que o serviço de ingestão é o mesmo que o comando de terminal usa. Era por isso que aquele código foi para App\Services\EditalIngestor em vez de morar dentro do comando.

A interface

A tela é uma página só: área de drop no centro, mensagem de carregando, e a lista com checkbox. Uma página só não justifica o Inertia, então é React montado pelo Vite direto em um Blade, com axios em um hook próprio.

npm install react react-dom @vitejs/plugin-react axios

O hook concentra a chamada e os três estados que a tela precisa conhecer:

export function useEditalAnalysis() {
    const [status, setStatus] = useState('idle');
    const [result, setResult] = useState(null);
    const [error, setError] = useState(null);

    const analyze = useCallback(async (file) => {
        setStatus('loading');

        const payload = new FormData();
        payload.append('edital', file);

        try {
            const { data } = await axios.post('/editais/analyze', payload, {
                headers: { 'Content-Type': 'multipart/form-data' },
                timeout: 300000,
            });

            setResult(data);
            setStatus('done');
        } catch (exception) {
            setError(exception.response?.data?.message ?? 'Não foi possível analisar.');
            setStatus('error');
        }
    }, []);

    return { status, result, error, analyze };
}

Aquele timeout de 300 mil milissegundos não é exagero. O padrão do axios seria suficiente para uma API comum e curto demais para uma requisição que indexa 48 páginas e consulta um agente.

A área de drop é um componente pequeno que trata onDragOver, onDragLeave e onDrop, e que também abre o seletor de arquivo no clique, porque nem todo mundo arrasta. E o item da lista risca o texto quando marcado, com uma classe condicional e text-decoration: line-through no CSS. Cada item tem um botão que revela o trecho do edital que o originou, que é o evidence do schema.

Exportar em PDF sem biblioteca nenhuma

O requisito era exportar a lista em PDF sem adicionar dependência. A resposta é a impressão do navegador.

<button type="button" onClick={() => window.print()}>
    Exportar PDF
</button>

O trabalho todo está no CSS, em uma folha de estilo de impressão que esconde o que não deve sair no papel e abre os trechos que estavam recolhidos:

@media print {
    body {
        background: #fff;
    }

    .page-header p,
    .result-actions,
    .items li .link,
    .dropzone,
    .loading {
        display: none;
    }

    .items li,
    .warnings {
        border: 1px solid #ccc;
        break-inside: avoid;
    }

    .item-evidence {
        display: block;
    }
}

O break-inside: avoid evita que um item da checklist seja cortado no meio na virada de página. O usuário clica em exportar, cai no diálogo de impressão e escolhe salvar como PDF. Zero kilobyte de biblioteca.

Depurando integração com provedor de IA

Esta seção é o que eu mais gostaria de ter lido antes de começar. O projeto quebrou três vezes, e nas três a mensagem de erro apontava para o lugar errado.

Primeiro: a coluna que não salvava

Descrito no artigo anterior, mas vale na lista. O campo page não estava no $fillable e o Eloquent descartou o valor em silêncio. Sem erro. O sintoma era a página em branco na saída.

Segundo: o arquivo que não existia

O upload gravava bem e o pdftotext reclamava que o arquivo não existe. Eu montava o caminho absoluto assim:

// errado
$absolutePath = storage_path('app/'.$path);

Isso era correto no Laravel 10. A partir do Laravel 11 a raiz do disco local passou a ser storage/app/private, e não storage/app. O arquivo estava em outro lugar. A correção é não montar caminho na mão:

// certo
$absolutePath = Storage::disk('local')->path($path);

Terceiro: o timeout disfarçado de falha de conexão

Esse foi o mais interessante. A mensagem na tela era:

Could not connect to AI provider [openai].

O que sugere problema de rede, chave inválida ou provedor fora do ar. Nada disso: os embeddings vinham funcionando pelo terminal minutos antes. O stack trace contou a história de verdade, e ele merece ser lido de baixo para cima:

#22 vendor/laravel/ai/src/Gateway/OpenAi/OpenAiGateway.php(257):
    PendingRequest->post('embeddings', Array)
#25 vendor/laravel/ai/src/Providers/Concerns/GeneratesEmbeddings.php(52):
    OpenAiGateway->generateEmbeddings(..., 'text-embedding-...', Array, 1536, 30, Array)
#28 app/Services/EditalIngestor.php(43):
    PendingEmbeddingsGeneration->generate()

Duas informações aparecem aí. A falha não estava no agente, estava na chamada de embeddings, ou seja, ainda na ingestão. E aquele 30 no meio dos argumentos é o timeout em segundos que o SDK usava.

É isso que acontece: quando o cliente HTTP do Laravel estoura o tempo, ele lança ConnectionException, e o SDK traduz essa exceção para uma mensagem sobre não conseguir conectar. A conexão estava ótima, só demorada.

Em vez de adivinhar se havia como aumentar esse valor, um grep no pacote respondeu:

docker compose exec app grep -n "timeout" \
  vendor/laravel/ai/src/PendingResponses/PendingEmbeddingsGeneration.php

46:    protected int $timeout = 30;
116:    public function timeout(int $seconds = 30): self

Existe o método, com padrão de 30 segundos. O agente tem o seu, com padrão de 60, configurável pelo atributo Timeout. São valores confortáveis para uma frase e apertados para um documento inteiro, e nenhum dos dois aparece ao lado dos exemplos na documentação.

A lição prática, para quem for levar o SDK a uso real: percorra os padrões de timeout de cada operação que você usa antes de mandar um documento grande. E quando o erro falar de conexão, confira o stack trace antes de mexer em rede ou em chave.

O que este projeto deixa na mesa

Agora que existe algo funcionando de ponta a ponta, dá para falar do que foi cortado com propriedade, o que é diferente de falar antes de ter o quê comparar.

A limitação mais séria é a busca. Similaridade vetorial mede semelhança de assunto, e vimos isso doer: o trecho mais parecido com a pergunta sobre habilitação era justamente o que não listava documento nenhum. Editais também são cheios de sigla e número de artigo, coisas como CNDT, FGTS, SICAF e art. 68, que um embedding representa mal. A resposta conhecida para isso é busca híbrida: somar à vetorial uma busca por palavra-chave e fundir os dois rankings. A coluna content_tsv que criamos no primeiro artigo está lá esperando exatamente esse uso, com índice GIN e dicionário em português, e nunca foi consultada.

Depois vem o corte. Cortar por tamanho fixo é a escolha mais simples e produz pedaços que começam no meio de uma frase e terminam no meio de outra. O edital oferece uma estrutura pronta de graça, com numeração hierárquica no início da linha, e usá-la daria pedaços que correspondem a cláusulas inteiras, com identificador conferível melhor que o número de página.

E falta medir. Hoje a avaliação é olhar a tela e achar que está bom. O jeito honesto é levantar à mão a lista de exigências do edital de teste, uma vez, com calma, e usá-la como gabarito para contar quantas o sistema recupera. Isso transforma "funcionou bem" em um número, e permite dizer quanto cada melhoria da lista acima realmente valeu.

Nenhuma dessas ausências é acidente. Um projeto de estudo que tenta ser completo costuma não terminar, e um que assume o próprio escopo ensina mais. Fica o convite: o repositório está aberto, e implementar a busca híbrida em cima do que já existe é meio dia de trabalho.

Encerrando

Três artigos, e o balanço sobre o SDK é positivo. Gerar embeddings é uma linha. Consultar por similaridade é um método no query builder. Dar busca vetorial a um agente é uma tool pronta que aponta para um model Eloquent. Saída estruturada é um schema e a resposta se comporta como array. Nada disso exigiu cliente HTTP, tratamento de payload ou pacote de terceiros, e trocar de provedor é mudar configuração.

O que exigiu trabalho foi o de sempre, e é bom que seja: entender o documento, escolher onde cortar, escrever instruções que impeçam o modelo de ser criativo onde não deve, e ler stack trace quando a mensagem de erro mente. A IA ficou fácil. O problema continua sendo problema, e é aí que está o ofício.

Este projeto é um estudo técnico. O código foi escrito para ser legível e didático, não para operar em produção, e a saída é indicativa e sujeita a omissões. Serve para acelerar a triagem de um edital, apontando onde conferir, e não substitui a leitura integral do instrumento convocatório. Documento de habilitação ausente ou incorreto leva à inabilitação do licitante, e essa responsabilidade permanece integralmente humana.

Referências

LARAVEL. Laravel AI SDK. Documentação oficial, versão 13.x. Disponível em: https://laravel.com/framework/docs/ai-sdk. Acesso em: 18 set. 2026.

BRASIL. Lei nº 14.133, de 1º de abril de 2021. Lei de Licitações e Contratos Administrativos. Brasília, DF: Presidência da República, 2021. Disponível em: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/l14133.htm. Acesso em: 18 set. 2026.

MUNICÍPIO DE VARGEM BONITA. Pregão Eletrônico nº 006/2026: contratação de serviços de arbitragem esportiva. Processo Administrativo nº 012/2026. Vargem Bonita, SC, 2 fev. 2026. Disponível em: https://vargembonita.sc.gov.br/uploads/sites/93/2026/02/PL012.2026-PE006.2026-ARBITRAGEM.pdf. Acesso em: 18 set. 2026.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Design Pattern Facade em PHP : Exemplo prático

Design Pattern Facade em PHP : Exemplo prático

O Design Pattern Facade é uma solução elegante para simplificar a interface de um subsistema complexo, tornando-o mais fácil de usar. Ele atua como uma fachada que esconde a complexidade de classes e interações…

Artigo, Iniciantes, Intermediários, php, Postgres, Tutorial