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 está no banco, dividido em pedaços e com os vetores gerados. A busca por similaridade funciona, mas a segunda parte mostrou que o trecho mais parecido com a pergunta nem sempre traz a resposta. Falta transformar esses trechos em uma resposta útil.

Nesta última parte, entra o agente do Laravel AI SDK. Ele recebe uma ferramenta de busca, consulta o edital quantas vezes precisar e devolve a lista de documentos em JSON validado por um schema. No fim, coloco uma tela em React em cima de tudo.

O agente do SDK

No Laravel AI SDK, um agente é uma classe PHP que reúne as instruções para o modelo, o contexto da conversa, as ferramentas que ele pode chamar e o formato da resposta. Você configura uma vez e usa como qualquer outro serviço da aplicação.

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

O comando cria o arquivo app/Ai/Agents/HabilitationAnalyst.php. Os próximos trechos de código alteram esse arquivo.

A vantagem em relação a chamar a API diretamente é que o agente cuida do laço de ferramentas. Ele decide que precisa buscar, chama a busca, lê o resultado, decide se busca de novo e só depois responde. Esse laço eu não precisei escrever.

A ferramenta de busca

O SDK já traz uma ferramenta (tool) de busca vetorial pronta, que aponta para um model Eloquent:

Arquivo: app/Ai/Agents/HabilitationAnalyst.php

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 recebe o query builder antes da busca, então dá para restringir o resultado. Aqui ele limita a busca ao documento da requisição. Sem isso, o agente poderia responder sobre um edital usando trechos de outro.

É aqui que o $hidden da segunda parte entra. A ferramenta devolve os models serializados para o modelo de linguagem. Se a coluna embedding não estivesse escondida, cada trecho viria com 1536 números, o contexto se encheria de dados inúteis e o custo de cada chamada subiria bastante.

O schema da resposta

Um agente com HasStructuredOutput não devolve texto livre, devolve dados em um formato definido. O schema é escrito com o JsonSchema do Laravel:

Arquivo: app/Ai/Agents/HabilitationAnalyst.php

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(),
    ];
}

Alguns campos existem para que a resposta possa ser conferida.

O evidence obriga o modelo a copiar o trecho do edital que justifica cada item. Com ele, a lista deixa de ser algo em que você precisa acreditar e passa a ser algo que você confere. O page é obrigatório pelo mesmo motivo: se fosse opcional, o modelo poderia devolver uma resposta válida sem dizer a página. O notes também é obrigatório, porque carrega a condição dos documentos condicionais. Nos outros itens, ele pode vir vazio.

O confidence existe porque às vezes o trecho recuperado vem cortado ou ambíguo. Nesses casos, é melhor o modelo sinalizar a dúvida do que ser obrigado a escolher entre afirmar e omitir.

O warnings recebe o que não cabe na lista. Editais se contradizem, e o de Vargem Bonita é um exemplo: o Termo de Referência exige balanço patrimonial na qualificação econômico-financeira, mas o Anexo II, que é a lista oficial, não pede balanço. Se o sistema escolhesse um dos lados, estaria escondendo um problema do usuário. O certo é apontar o conflito e citar as duas cláusulas. Para isso funcionar, o campo precisa existir no schema e as instruções precisam mandar usá-lo.

As instruções do agente

De tudo no projeto, as instruções foram o que mais mudou o resultado. Um modelo de linguagem conhece a Lei 14.133 e sabe quais documentos uma habilitação costuma pedir. Se ninguém proibir, ele completa a lista com o que é comum, em vez de ficar só no que o edital diz. A resposta parece ótima e está errada.

Arquivo: app/Ai/Agents/HabilitationAnalyst.php

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, e ela vem de uma observação da segunda parte. A busca por "documentos exigidos para habilitação" traz em primeiro lugar o item 7 do edital, que se chama HABILITAÇÃO mas só remete ao Anexo II. Sem essa regra, o agente lê esse trecho, entende que os documentos estão em um anexo e para por ali, ou então preenche a lista de memória. Com a regra, ele faz uma nova busca e encontra a lista de verdade.

A regra 1 protege contra o tipo de alucinação mais difícil de perceber, a que parece plausível. A regra 7 dá ao modelo uma saída honesta: sem ela, quando o contexto não basta, a única opção dele é inventar.

Configuração por atributos

Arquivo: app/Ai/Agents/HabilitationAnalyst.php

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

O MaxSteps define quantas vezes o agente pode chamar ferramentas antes de responder. Como as instruções pedem várias buscas com termos diferentes, o valor padrão não bastaria.

O Timeout aumenta para 180 segundos o tempo máximo de uma execução, já que o agente pode fazer várias buscas antes de responder.

Deixei de fora o atributo Temperature. Zero seria o valor natural para uma tarefa de extração, mas os modelos de raciocínio mais recentes da OpenAI rejeitam esse parâmetro, e o schema já restringe bem o formato da resposta. Assim o projeto não fica preso a uma família de modelos.

Chamando o agente

Arquivo: app/Console/Commands/AnalyzeEdital.php

$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 do Laravel, o que permite injeção de dependência e argumentos nomeados no construtor. A resposta estruturada se comporta como um array, então os campos do schema ficam acessíveis direto.

Antes de fazer a interface, criei um comando para rodar o agente pelo terminal. Ajustar prompt testando pela tela é muito mais lento.

docker compose exec app php artisan edital:analyze

O endpoint

A aplicação tem um endpoint só, que recebe o PDF, indexa e analisa:

Arquivo: app/Http/Controllers/EditalController.php

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);
    }
}

A requisição é longa, leva dezenas de segundos. Em um sistema de verdade, esse trabalho iria para uma fila. Aqui, como é um estudo, preferi a simplicidade de um endpoint só.

Quando algo dá errado, a exceção completa vai para o log e o navegador recebe só uma mensagem genérica. Assim a resposta não expõe caminho interno, mensagem do provedor ou detalhe de configuração. O status é 500 porque uma falha na extração, no banco ou no provedor é erro do servidor, não do arquivo enviado. Os erros de validação do upload, como um arquivo que não é PDF, o próprio Laravel devolve como 422.

O serviço de ingestão é o mesmo que o comando de terminal usa. Foi por isso que, na segunda parte, esse código ficou em App\Services\EditalIngestor e não dentro do comando.

A interface

A tela é uma página só: uma área para soltar o arquivo, uma mensagem enquanto a análise roda e a lista com checkbox. Para uma página só, não vi motivo para usar o Inertia (a camada que liga o Laravel a um front-end em React ou Vue). Montei o React direto em uma view Blade, com o Vite, e as chamadas usam axios em um hook próprio.

Os arquivos desta etapa são resources/views/app.blade.php, resources/js/app.jsx, resources/js/components/DropZone.jsx, resources/js/components/Checklist.jsx, resources/js/hooks/useEditalAnalysis.js e resources/css/app.css. A rota POST fica em routes/web.php.

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

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

Arquivo: resources/js/hooks/useEditalAnalysis.js

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 };
}

O timeout de 300 mil milissegundos (cinco minutos) é proposital. O padrão do axios serve para uma API comum, mas é curto para uma requisição que indexa 48 páginas e ainda consulta um agente.

A área de soltar arquivo é um componente pequeno que trata onDragOver, onDragLeave e onDrop. Ela também abre o seletor de arquivo no clique, para quem não quer arrastar. Cada item da lista fica riscado quando marcado, com uma classe condicional e text-decoration: line-through no CSS, e tem um botão que mostra o trecho do edital que originou aquele item, o evidence do schema.

Exportando em PDF

Eu queria exportar a lista em PDF sem adicionar nenhuma dependência ao projeto. A solução foi usar a impressão do navegador.

Arquivo: resources/js/components/Checklist.jsx

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

O trabalho fica no CSS, numa folha de estilo de impressão que esconde o que não deve ir para o papel e abre os trechos que estavam recolhidos:

Arquivo: resources/css/app.css

@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 impede que um item da checklist seja cortado ao meio na virada de página. O usuário clica em exportar, o navegador abre o diálogo de impressão e ele escolhe salvar como PDF.

O que ficou de fora

Com o sistema funcionando de ponta a ponta, dá para comparar o que foi feito com o que ficou faltando.

A limitação mais séria está na busca. A similaridade vetorial mede proximidade de assunto, e a segunda parte mostrou o efeito disso: o trecho mais parecido com a pergunta sobre habilitação era justamente o que não listava documento nenhum. Além disso, editais têm muita sigla e número de artigo, como CNDT, FGTS, SICAF e art. 68, que um embedding representa mal. A solução conhecida é a busca híbrida, que combina a busca vetorial com uma busca por palavra-chave e junta os dois rankings. A coluna content_tsv, criada na primeira parte com índice GIN e dicionário em português, está pronta para isso e nunca foi consultada.

Depois vem o corte. Cortar por tamanho fixo é o mais simples, mas gera pedaços que começam e terminam no meio de uma frase. O edital já tem uma estrutura pronta, com numeração hierárquica no início de cada linha. Cortar por cláusula daria pedaços completos e um identificador mais preciso que o número da página para citar.

Por fim, falta medir. Hoje, avaliar o sistema é olhar a tela e achar que está bom. O caminho mais confiável é levantar à mão, uma vez, a lista de exigências do edital de teste e usar essa lista como gabarito para contar quantas o sistema encontra. Com isso, "funcionou bem" vira um número, e dá para saber quanto cada melhoria acima vale de fato.

Deixei tudo isso de fora de propósito, para manter o projeto pequeno o bastante para ser estudado. O repositório está aberto, e implementar a busca híbrida em cima do que já existe leva cerca de meio dia de trabalho. É um bom próximo passo para quem quiser continuar.

Encerrando

Depois de três artigos, minha avaliação do SDK é positiva. Gerar embeddings é uma linha. Buscar por similaridade é um método do query builder. Dar busca vetorial a um agente é uma ferramenta pronta que aponta para um model Eloquent. Saída estruturada é um schema, e a resposta chega como array. Não precisei de cliente HTTP nem de pacote de terceiros, e trocar de provedor é mudar configuração.

O trabalho difícil continua fora do SDK: entender o documento, decidir onde cortar e escrever instruções que impeçam o modelo de inventar.

Um último aviso. Este projeto é um estudo, e a lista que ele gera pode ter omissões. Ela ajuda na triagem de um edital e mostra onde conferir, mas não substitui a leitura do edital inteiro. Um documento de habilitação que falte ou esteja errado leva à inabilitação do licitante, e essa conferência continua sendo trabalho de uma pessoa.

Demonstração em funcionamento

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

Como Instalar e Usar o MailHog com Laravel para Testes de Email

Como Instalar e Usar o MailHog com Laravel para Testes de Email

No desenvolvimento de aplicações web, especialmente no Laravel, enviar emails é uma tarefa comum. Porém, testar o envio de emails em ambientes de desenvolvimento pode ser desafiador, já que você não quer correr o risco…

GO, Laravel, Linux, php
Collections no PHP e seu uso no Laravel

Collections no PHP e seu uso no Laravel

Ao desenvolver aplicações modernas com PHP, uma das tarefas mais comuns é manipular arrays — filtrando, transformando ou agrupando dados. Embora o PHP nativamente ofereça funções poderosas para isso, como array_map,…

Laravel, php, Tutorial