Os desafios de desenvolver serviços GovTech
Da integração de dados públicos à inteligência artificial, o que aprendemos construindo um produto para aproximar o Congresso de quem precisa entendê-lo.
PorLuis Felipe Nicolau,Erick Mendes, Eduardo Fernandes eJuan Talyson Macêdo
52 min de leitura
Seções deste artigo
Resumo
- O Olho na Lei (abre em outra aba) é um produto nosso que lê o Congresso Nacional todos os dias e entrega busca por sentido, análise por IA e uma nota de relevância sobre as proposições da Câmara e do Senado, e serve tanto ao cidadão que quer entender o que tramita quanto a quem trabalha com Relações Institucionais e Governamentais.
- O detector de jabuti rodou em 16 de 66 dias enquanto o agendador registrou sucesso nos 66, e quase todos os incidentes do projeto tiveram essa mesma assinatura, de cursor que pulava proposição a cache que nunca estava ligado.
- Nas otimizações, a medição desfez a intuição mais de uma vez, como o corte para 512 dimensões que teria custado 20% do recall e a busca de 1,45 s em que o banco respondia em 18 ms.
- A maior conta do projeto não veio da IA, veio dos robôs, e o texto termina com o que essa medição não prova, incluindo uma audiência ainda mínima.
O Olho na Lei existe porque dado parado não fiscaliza ninguém
O Congresso publica tudo, e só não publica pronto. A Câmara e o Senado mantêm APIs públicas de dados abertos, cada uma com formato próprio, limites que ninguém documenta e inteiro teor em PDF que nem sempre é texto, e quem tenta acompanhar uma proposição por esses canais descobre que precisa saber o número dela, a palavra exata que a lei usa e em qual casa ela está. O Olho na Lei é o produto que construímos para fechar essa distância. Ele lê as duas casas todos os dias, guarda o inteiro teor de cada proposição substantiva, quebra o texto em trechos que podem ser buscados pelo sentido e não só pela palavra, e coloca uma IA para ler, resumir, classificar por tema, apontar impacto fiscal e procurar jabutis, tudo mostrado num site aberto, com uma nota de relevância que diz o que é importante hoje e não só o que andou.
A utilidade pública
A primeira utilidade é a de transparência. Quem digita "saúde mental nas escolas" encontra a proposição que fala em "bem-estar psicológico de estudantes", e quem procura uma lei recém-sancionada, como a do spray de pimenta, a encontra. O resumo gerado por IA traduz a proposição para uma linguagem que não exige formação jurídica, o selo de jabuti avisa quando um trecho estranho ao tema pegou carona num projeto (como a taxação de compras internacionais de até 50 dólares que apareceu dentro de um projeto sobre carros mais eficientes), e a análise de impacto fiscal já mostrou, entre as proposições lidas, 1.648 que criam gasto ou renúncia sem dizer de onde vem o dinheiro. A seção de notícias liga o que a imprensa discute esta semana à proposição de que ela fala, a newsletter e as redes levam o resultado para quem não abre o site, e como as páginas de conteúdo são abertas, ser lido e citado por buscadores e por IAs faz parte do objetivo do projeto.
A utilidade para o setor de RIG

Para quem trabalha com Relações Institucionais e Governamentais (RIG), o problema é o mesmo em escala profissional. Estratégia institucional, relações governamentais, inteligência regulatória e articulação qualificada dependem de saber cedo o que está tramitando, em que estágio, o que de fato importa entre dezenas de milhares de proposições e o que veio escondido dentro de outra. É o que as peças acima entregam, na forma de busca por assunto e não por número, de uma nota de relevância que se explica (cada nota guarda os sinais que a produziram, e dá para responder por que algo está em destaque), de um detector de jabutis com os limites de precisão que descrevemos adiante, de notícias ligadas às proposições e de um histórico recarregado e conferido contra a fonte. Esse ganho para o setor é a hipótese de produto que o projeto existe para testar, e ainda não temos dado de uso de profissionais da área para afirmar mais do que isso, o que aparece com os números de audiência no fim do texto.
Este projeto está sendo representado na Associação Brasileira de Relações Institucionais e Governamentais (abre em outra aba) (ABRIG).
Aberto de ponta a ponta
Os dados de origem são públicos, e o site e a newsletter são abertos. Do que construímos por cima, a camada de análise e de relevância é o que o produto acrescenta, e é por isso que este texto conta com tanto detalhe como ela foi feita e onde ela falhou.
Como a palestra e este texto contam a história
A apresentação que montamos, A tramitação do OlhoNaLei (abre em outra aba), imita o rito de um processo legislativo, com cada capítulo (chamado de despacho) seguindo as mesmas cinco etapas, o problema, a tecnologia escolhida, o que deu errado, a solução medida e a lição. Os números são reais e foram tirados do banco de produção, do git, das faturas e dos logs, entre 30 de abril, data do primeiro commit, e 6 de outubro de 2026, e incluem custos, erros e robôs bloqueados.
Este artigo usa a palestra como espinha e acrescenta, dentro dos capítulos certos, os relatos que escrevemos sobre desafios específicos de arquitetura e de dados.
A escala, no dia 6 de outubro, era a seguinte.
- 172.307 proposições das duas casas, das quais 46% são substantivas (projetos, PECs, medidas provisórias, decretos legislativos), e o resto é requerimento, emenda e afins.
- 367.356 trechos de texto com vetor, prontos para a busca por sentido.
- 13.611 proposições lidas por IA, com resumo, temas, jabutis e impacto fiscal.
- 33 tarefas agendadas no próprio banco, que disparam cerca de 54 mil vezes por mês.
- 82.400 PDFs baixados das duas casas, somando 21,3 GB de inteiro teor.
- 2.924 notícias dos últimos 30 dias, todas com vetor para achar a proposição de que falam.
Tudo isso roda com um time pequeno, sem servidor próprio e quase sempre de madrugada, sem ninguém apertando botão.
O painel mostrou sucesso em 66 de 66 dias, e o detector rodou em 16
O detector de jabuti do Olho na Lei era disparado todo dia às 01:40, mas a função que o disparava desistia se houvesse qualquer outra tarefa rodando, e a atualização de textos termina quase todo dia por volta de 01:40. O resultado foi um detector que rodou em 16 de 66 dias, com o painel do agendador mostrando sucesso nos 66, numa corrida decidida por segundos em que nenhum alerta enxergava a perda, porque nada tinha dado erro. Em agosto, o assistente de IA que nos ajudava atribuiu o problema a uma recusa da API, que nós liberamos, mas o pedido nem saía do banco, porque eram duas travas e só soltamos uma, e a correção definitiva, em 5 de outubro, foi apagar quatro linhas.
Esse não foi um caso isolado, e sim o jeito mais comum de o projeto falhar. Em nove capítulos de trabalho, que vão da coleta de dados à publicação automática, a falha que mais nos custou nunca foi a que lança exceção, foi a que devolve menos, um zero, um null, um 200 vazio ou um "succeeded" que só quer dizer que o pedido saiu. Este texto percorre esses capítulos na ordem em que eles aconteceram, com os números que a gente mediu, e mostra o que fizemos em cada um.
A arquitetura separa quem busca o dado de quem mostra o dado
O desenho é fácil de resumir. O dado entra de madrugada, vindo das APIs públicas da Câmara (abre em outra aba) e do Senado (abre em outra aba), e nunca é buscado quando alguém abre o site. A viagem de uma proposição tem nove paradas, que são as fontes, o dado bruto guardado em staging, os metadados na tabela bill, o filtro is_substantive, o texto extraído do PDF (com OCR quando o PDF é imagem), os trechos de cerca de 2.000 caracteres com vetor, a análise por IA em lote, a nota de relevância com 12 sinais e o site em Next.js com ISR. A API em FastAPI e o worker de texto rodam no Railway, nos Estados Unidos, o Postgres com pgvector, o agendador pg_cron e a Edge Function da busca ficam no Supabase em São Paulo, e o site fica na Vercel, também em São Paulo. Os provedores de IA são o Claude, para ler, a Voyage, para os vetores, e o Gemini, para imagens, e a ideia central é a de que o banco está no centro, já que guarda os dados, agenda o trabalho e ainda roda a busca. Com essa separação ninguém que abre o site espera pela API do Congresso, porque todo o trabalho pesado já aconteceu antes, e é esse desenho que faz do capítulo de latência, mais adiante, uma história de geografia.
O dado bruto fica guardado antes de qualquer transformação
Uma decisão que pesou bastante foi separar ingestão e transformação por uma camada de staging. Ela recebe o dado da fonte e o preserva, sempre que possível, na estrutura original, antes de qualquer regra de padronização, integração ou modelagem analítica, e com isso dá para reprocessar sem chamar a API de novo. A camada foi escrita em Python e dividida em três responsabilidades. Os clientes CamaraClient e SenadoClient encapsulam a conversa com cada API (o primeiro foi construído sobre o httpx), o firebase_client.py resolve as credenciais da service account e mantém uma única instância do Firestore criada sob demanda, e o staging.py orquestra as cargas.
As coleções seguem a convenção raw_<fonte>_<domínio>, como raw_camara_votacoes e raw_senado_senadores, e cada documento recebe o metadado _ingerido_em com o timestamp da carga em ISO 8601. Todo campo que o pipeline acrescenta ao registro da fonte leva o prefixo _, o que separa o que é atributo original do que é nosso e preserva a rastreabilidade. O ganho arquitetural é o desacoplamento, já que os mecanismos de acesso às fontes evoluem sem que a transformação e a modelagem relacional dependam da comunicação com as APIs.
Enriquecimento documental, com a chave da própria fonte
Na API da Câmara o perfil de um parlamentar, o histórico, os órgãos e as ocupações declaradas vêm de endpoints diferentes, e isso exige consultas complementares para montar uma representação completa. O que fizemos foi enriquecer o registro da listagem inicial, que passa a carregar nos campos _detalhe, _historico, _orgaos e _ocupacoes o resultado das demais consultas, e gravá-lo no Firestore com o identificador original da fonte como chave, o que dá uma referência estável entre execuções. O que tem volume maior ou granularidade própria (frentes parlamentares, eventos, discursos e as despesas da Cota para o Exercício da Atividade Parlamentar, a CEAP) vai para coleções específicas, ligadas à entidade de origem pelo mesmo id ou por chaves compostas, como {idDeputado}_{ano} nas despesas. Além de evitar concentrar informação heterogênea num documento só, essa separação reduz o risco de o documento principal bater no limite de 1 MiB por documento do Firestore (abre em outra aba).
Upsert determinístico, para que reexecutar não duplique
Como o processamento envolve várias entidades relacionadas, uma falha no meio da carga não podia deixar registros duplicados quando a operação fosse repetida. Por isso as gravações usam .set() com identificadores determinísticos derivados das chaves da fonte, sem o parâmetro merge=True, e isso faz com que o documento seja criado quando não existe e sobrescrito por inteiro quando já existe. A semântica de substituição garante que o estado final reflita apenas a carga mais recente, sem campo residual de execuções anteriores, e é ela que sustenta a idempotência da ingestão. As rotinas são funções independentes por domínio, com unidades de trabalho pequenas (um dia nas votações do Senado, um mês nas da Câmara), o que permite reexecutar uma carga isolada e simplifica achar onde algo falhou, e a coleção ingestion_logs guarda o status de cada unidade, completed ou error.
A cota gratuita acabou e o registro do erro caiu junto
Essa estratégia tem um custo que só apareceu na validação. Coletar os recursos complementares multiplica as gravações, e cada proposição gera cinco, que são o documento principal e os registros de autores, tramitações, temas e votações. Com esse volume, a cota gratuita do Firestore (abre em outra aba), de 20 mil gravações por dia, acabou antes do fim das cargas, e elas pararam com erros 429 RESOURCE_EXHAUSTED. O episódio deixou o trade-off da persistência bem claro, porque embutir os dados complementares no documento principal reduz o número de gravações e aumenta o tamanho dele, enquanto separar em coleções faz o contrário.
O que mais nos ensinou foi uma falha em cascata que tinha passado despercebida. As rotinas de registro de execução gravavam no próprio Firestore, então a tentativa de registrar o erro de cota também falhava e encerrava o processo inteiro. Corrigimos isolando as funções de log em tratamento de exceção próprio, de modo que, quando a gravação no banco falha, o registro vai para o log local da aplicação. Foi um dos poucos erros barulhentos do projeto, e mesmo assim o barulho foi engolido por um log que dependia do mesmo recurso que estava falhando, o que nos deixou com uma regra de desenho, a de que a observabilidade não pode compartilhar o ponto de falha do recurso que ela monitora.
Dados. O Congresso publica tudo, só não publica pronto
O primeiro capítulo do projeto, que foi de abril a julho, é o da coleta, e a frase que mais se repetiu nele foi "carregou sem erro", que quase nunca quis dizer "carregou tudo".
Nem tudo que tramita muda alguma coisa
A primeira decisão de produto que é decisão de engenharia foi a de o que conta como proposição "de verdade". A maior parte do que tramita é requerimento, emenda e pedido de informação, que não criam nem mudam lei (na Câmara, os quatro tipos mais comuns são PL com 50.082, REQ com 32.035, EMC com 17.094 e RIC com 10.818, e só o PL passa no filtro). Uma coluna calculada pelo próprio Postgres, a is_substantive, marca 13 tipos como substantivos (PL, PLP, PEC, MPV, PLV, PLN, PDL, PDC, PRC, PLC, PLS, PRS e RCP), e o site lê só a view bill_substantive, de modo que requerimento não ocupa a tela, não entra na fila de texto e não gasta IA. Por ser uma coluna gerada (GENERATED ALWAYS AS ... STORED), o banco calcula a cada inserção e não existe chance de o código esquecer de preencher, e no total 46% das 172.307 proposições passam no filtro.
O primeiro tropeço foi de identidade. Em 1º de julho descobrimos que o id 163889 era o PL 914/2024 no Senado e a MSC 365/1989 na Câmara, porque cada casa numera do seu jeito e a nossa chave era só o id externo, e a chave passou a ser o par (casa, id), já que a identidade de um registro vem da fonte inteira e não só do número.
Ninguém documenta o limite, então medimos
As APIs do Congresso não dizem quantos pedidos por segundo aceitam, e a gente mediu oferecendo taxas crescentes e contando as respostas 429. Na Câmara, 5 e 10 pedidos por segundo passaram sem erro, a 15 apareceram de 1% a 2% de 429, a 30 cerca de um quarto dos pedidos falhava e a 40 cerca de 60%, e por isso usamos 10. O Senado aceitou 40 por segundo sem nenhum erro e usamos 15, mas ele devolve 400 a qualquer pedido sem User-Agent, com uma mensagem que não explica nada, e o host de documentos aguenta 2 por segundo. Como os hosts das duas casas são independentes, o sistema passou a rodar uma tarefa pesada por casa, em paralelo. Mandar devagar demais desperdiça horas, e mandar rápido demais toma 429 e perde dado.
O cursor que só andava para frente
Para ler só o que é novo, a carga da Câmara guardava um cursor, o maior id já carregado, partindo da premissa de que id maior significa proposição mais nova. Não significa, e o último PL de 2025 tem id 2.622.750, maior que o do primeiro PL de 2026, que é 2.599.887. O PL 1396/2026, de id 2.611.850, caiu na faixa que o cursor considerava "já carregada" e nunca foi carregado, sem erro, sem log e sem alerta, e o filtro de data da API ainda por cima é por tramitação e não por apresentação. Só achamos ao contar banco contra API mês a mês, e março de 2024 tinha 264 proposições no banco contra 571 na API, com 20 dos 20 dias comparados furados, enquanto junho de 2025 tinha 200 contra 468. O histórico foi recarregado em fatias semanais por data de apresentação, num step-runner que avança um passo por vez e sobrevive a quedas, e as substantivas da Câmara foram de 25.810 para 62.614.
Metade do Congresso estava invisível
Até julho, uma matéria do Senado só entrava no banco quando era votada, e em 24 de julho tínhamos 357 matérias do Senado contra 28.850 da Câmara. A Lei 15.474/2026, a do spray de pimenta, não aparecia na busca. Montamos um catálogo por sigla e ano que trouxe 10.915 matérias em 62 segundos, sem falha, e trocamos a métrica para contar texto com conteúdo e não registro, o que nos deu 81% hoje. Esse número tem um teto que não é nosso, porque antes de 2018 a origem simplesmente não publica o documento (2017 tem 3 matérias com texto em 708, enquanto 2018 tem 455 em 739), então o teto real é 81,1% e estamos em 81,0%. O endpoint de textos do Senado também foi descontinuado em fevereiro, e documentar o limite da fonte é o que impede que ele pareça defeito do projeto.
Um PDF cheio de texto pode não ter nenhuma palavra
PDF é formato de impressão, e quando a fonte embutida no arquivo não traz o mapa entre o desenho de cada letra e o caractere correspondente, a extração devolve uma sopa de glifos que passa no teste de quantidade de texto. A busca por "estepe" chegou a devolver um projeto sobre contribuição social, porque o vetor do lixo ficava parecido com qualquer pergunta. A regra nova mede qualidade e não quantidade, e se menos de 80% dos caracteres são imprimíveis o arquivo vai para OCR (Tesseract), com um filtro que descartou 2.785 trechos ilegíveis que já tinham vetor. A Câmara também chegou a servir uma página do Diário Oficial no lugar de 33 medidas provisórias e mensagens, e o "jabuti" com confiança 0,97 da MPV 1374/2026 era um acordo aéreo entre Brasil e Jamaica, o que nos fez pagar IA para ler lixo e responder com convicção, de modo que o detector de jabuti virou, sem querer, um detector de dado ruim. As 36 proposições com fonte inválida ficaram em quarentena, fora da busca e da IA, e os casos viraram um relatório de erros para a Câmara.
Um placar sem ausentes, senadores sem cadastro e quatro dias parados
O mesmo padrão apareceu em três falhas do Senado que a equipe relatou por escrito, e todas passaram pela mesma pergunta de quanto o dado chegava inteiro.
O placar. A sessão 7037 do Senado, que votou o PL 5582/2025, aparecia no nosso placar com Sim 98%. O número correto era 79%, ou seja, 64 votos entre os 81 senadores, e não houve exceção, nem erro 500, nem linha de log em vermelho. O transform da votação gravava votes_absent: 0 fixo, embora a mesma função já classificasse corretamente as siglas de não-voto (P-NRV, AP, LS, MIS, NCom e outras) como NÃO_COMPARECEU. As ausências existiam na tabela vote mas não chegavam ao placar, e com isso o percentual era calculado sobre um denominador menor que o real. Quando medimos, 998 das 1.005 sessões exibiam "Ausente 0", com 20.291 ausências gravadas e invisíveis.
A autoria. O campo UfParlamentar só vem preenchido para quem tem mandato ativo. Para ex-senadores a API omite o campo, nosso modelo exige state_uf, e o validador descartava o registro com um WARNING que ninguém lia. O resultado foi que 83 senadores, entre eles José Sarney e Ronaldo Caiado, tinham autoria registrada e não existiam na tabela de parlamentares. A autoria de medidas provisórias tinha o mesmo problema por outro caminho, já que autoriaIniciativa vem null e a informação real está num fallback (documento.autoria) com outra chave (autor em vez de nome), e com isso o backfill rodaria "com sucesso" sem nunca gravar "Presidência da República" numa MPV, que era justamente o caso que motivou a criação da tabela.
O pipeline parado. O caso mais caro foi operacional. Por 4 dias o pipeline de inteiro teor do Senado não processou nada, e o cron de 15 em 15 minutos reportava SUCESSO o tempo todo, porque havia três bugs independentes. O primeiro era um guard de concorrência global, em que um backfill da Câmara bloqueava a outra casa inteira. O segundo era uma métrica de progresso que só contava content IS NOT NULL, e como parte dos PDFs do Senado é escaneada e fica em ocr-pending com conteúdo nulo, o step-runner lia "sem progresso" e encerrava. O terceiro eram rejeições 409 do single-flight guard guardadas em net._http_response, uma tabela que nenhum painel consultava. Sobre o segundo bug vale um registro, porque o documento de arquitetura dizia que cerca de 70% dos PDFs do Senado precisariam de OCR, e quando alguém finalmente mediu o número foi de cerca de 13%, o que quer dizer que a fração que atrapalhava a métrica nunca tinha sido conferida. Quando medimos a cobertura de texto, o Senado estava em 29,7% contra 94,7% da Câmara, e todos os indicadores estavam verdes.
Como achamos. Nenhum alerta nos avisou. O placar foi visto no frontend, onde uma votação do Senado mostrava "Ausente 0" e um Sim 98% que não fazia sentido para uma Casa de 81 cadeiras, e os senadores sumidos apareceram do mesmo jeito, numa página de proposição que tinha autoria registrada e não mostrava o autor. O pipeline parado veio de outro tipo de observação, uma validação do nosso detector de jabutis com casos que a imprensa já tinha noticiado, ou seja, jabutis que sabíamos que existiam, e dos 10 o detector tinha marcado zero. A primeira suspeita foi o modelo, mas seguindo o funil para trás vimos que quatro desses casos nem tinham análise, porque não havia inteiro teor no banco, e puxando esse fio chegamos ao pipeline parado. Em todos os casos o que denunciou o erro foi a comparação com algo que sabíamos ser verdade, que um placar precisa fechar em 81, que toda autoria aponta para um parlamentar que existe e que toda MPV tem o Executivo como autor, e a lição incômoda é que o log mostra o que o sistema achou estranho, e o dado na tela, colocado diante de quem conhece o domínio, mostra o que o sistema deixou passar.
O que o capítulo deixou
Nenhum desses problemas lançou erro, e todos foram achados comparando o resultado com a origem. A partir daí transformamos as perguntas da investigação em verificação permanente, com um estágio read-only, o verify-backfill, que compara dia a dia quantas proposições as APIs da Câmara e do Senado reportam e quantas temos no banco e aponta os dias com buraco. Toda correção passou a ser validada contra a fonte com contagem antes e depois (as 998 sessões divergentes do placar viraram 0), e todo guard passou a ter um jeito de mostrar o que recusou. A frase que ficou foi a de que "carregou sem erro" não é "carregou tudo", e quando a própria fonte tem um limite, documentá-lo evita que ele pareça falha sua.
Busca. Ninguém procura uma lei com as palavras da lei
O segundo capítulo, de junho a julho, parte de um problema simples de enunciar. A pessoa digita "saúde mental nas escolas" e a proposição fala em "bem-estar psicológico de estudantes", e uma busca por palavra exata não liga uma coisa à outra.
Procurar pedaço de texto não é procurar palavra
A primeira busca do site usava ILIKE '%saúde%', que procura um pedaço literal de texto, e por isso "saude" sem acento não achava "saúde" com acento e "spray pimenta" não achava "spray de pimenta", porque havia um "de" no meio. Trocamos por full-text em português (websearch_to_tsquery('portuguese', ...) sobre um tsvector), que reduz cada texto aos radicais das palavras, sem acento e sem palavras vazias, com um índice GIN que aponta, para cada radical, as proposições que o contêm. "Saúde" passou de 9 para 3.793 resultados na API, em 21 de junho, e "spray pimenta" foi de 0 para 3 no site, em 24 de julho. O full-text, porém, acha palavras e não assuntos, e foi isso que levou ao passo seguinte.
Cada trecho vira um ponto num mapa de sentido
Um embedding é uma lista de 1.024 números, gerada por um modelo de IA (aqui, o voyage-3.5-lite), que representa o sentido de um texto, e textos de assunto parecido ficam próximos nesse espaço mesmo sem nenhuma palavra em comum, com a proximidade medida pelo cosseno. O inteiro teor, que chega em PDF e vai até 80 mil caracteres, é cortado em trechos de cerca de 2.000 caracteres com 200 de sobreposição, porque uma proposição de 80 páginas fala de muitas coisas e um vetor único misturaria todas, e a sobreposição garante que uma frase cortada ao meio ainda apareça inteira no trecho vizinho. Os vetores ficam no próprio Postgres, com pgvector e um índice HNSW, um grafo em camadas que encontra os vizinhos mais próximos sem comparar a pergunta com os 367.356 trechos, e hoje temos vetor para os trechos de texto, para 12.655 resumos gerados por IA e para as 2.924 notícias. O primeiro tropeço foi de custo operacional, porque o plano gratuito da Voyage aceitava 3 requisições por minuto e a ingestão quebrava, o que nos obrigou a pôr retry.
Palavra-chave primeiro, sentido só quando a palavra não basta
O caminho de uma busca é uma regra de custo e de precisão. Se a consulta parece um número de proposição ou de lei, a busca vai direto ao registro, e depois vem a palavra-chave, que é barata e exata para nomes e números e responde em 17,5 ms. Com 3 ou mais resultados a busca termina ali, e só quando a palavra-chave é fraca entra a busca por sentido, que exige gerar o vetor da consulta (ou reaproveitá-lo do cache, o que custa uns 20 ms contra cerca de 1,5 s numa chamada à Voyage). Na busca por sentido rodam dois braços em paralelo, o semântico (HNSW, 400 vizinhos reordenados por cosseno) e o de palavra-chave, combinados por Reciprocal Rank Fusion, em que cada resultado ganha 1/(60 + posição) em cada lista e quem aparece bem nas duas sobe, sem precisar calibrar pesos entre notas de escalas diferentes. No fim, apelidos populares como "PL da Devastação" sobem para o topo por uma busca de trigramas, em 3,9 ms, e a média da busca híbrida inteira ficou em 76,7 ms em 6 de outubro.
O índice existia, e a consulta é que não usava
O capítulo teve dois casos em que o índice estava criado e a consulta não o usava. No primeiro, em 14 de julho, a busca semântica levava 46 segundos, porque o vetor da consulta chegava como texto e o Postgres refazia a conversão de cerca de 7 KB em cada linha comparada, nos cerca de 120 mil trechos, e lia a tabela inteira. A correção foi de uma linha, converter uma vez para uma variável tipada (v := p_embedding::vector(1024)), e o tempo foi para 0,45 s. No segundo, em 28 de julho, a busca por palavra-chave levava 156 ms porque um OR entre dois campos de texto, através de um LEFT JOIN, impedia o planejador de usar os dois índices GIN e ele varria 48.914 proposições. Separamos em dois SELECT unidos por UNION ALL, cada um no seu índice, e foi para 17,5 ms, com 40 vezes menos páginas lidas (de 56.552 para 1.338 buffers). Antes da correção havia 2.008 erros de timeout no Sentry, e a busca híbrida tinha média de 1,85 s e máximo de 7,99 s, encostando no limite de 8 s. Criar o índice é metade do trabalho, e a outra metade é ler o EXPLAIN.
Um robô clicou em todas as ordens possíveis
O vetor de cada consulta fica num cache, e a chave era o texto da consulta, montado juntando os temas escolhidos na ordem dos cliques. Um robô percorreu o seletor de temas em todas as ordens possíveis, e o cache chegou a 143.512 linhas para apenas 3.060 combinações diferentes, com 65.308 chamadas à Voyage num só dia, em 12 de agosto, quando o normal eram 7 por dia, e acerto de 36,3% em 13 de agosto. A correção foi ordenar só os temas, e não o texto livre da pessoa, porque trocar a ordem das palavras muda o sentido, e o acerto foi para 99,0%. O estrago maior foi em disco, porque a tabela de cache chegou a 854 MB e um VACUUM comum não devolveu o espaço, só o VACUUM FULL levou a tabela para 17 MB. As duas lições do capítulo ficaram simples de dizer, a de que toda chave montada por concatenação precisa de ordem estável e a de que todo índice precisa de um EXPLAIN que prove que ele é usado.
Inteligência artificial. Ninguém lê 79 mil proposições, e um modelo lê por uma fração de centavo cada
O terceiro capítulo, de julho a agosto, é sobre fazer a IA caber num orçamento pequeno e sobre o caso mais difícil que pedimos a ela, que foi achar jabutis.
Uma chamada, um formulário que o modelo é obrigado a preencher
Para cada proposição, uma única chamada ao Claude Haiku 4.5, o menor modelo da família, lê o texto (até 80 mil caracteres) e preenche um formulário fixo com resumo, título curto, de 3 a 6 pontos-chave, temas, leis citadas, possíveis jabutis e impacto fiscal. Há três escolhas de custo e de confiabilidade nessa chamada. A primeira é a Batch API, que responde em até 24 horas e custa metade do preço, o que para um trabalho noturno é desconto sem perda, já que ninguém precisa do resumo às 3 da manhã. A segunda é a ferramenta forçada (tool_choice), em que o modelo não conversa e só preenche os campos de uma função com esquema fixo, em vez de a gente pedir "responda em JSON" e torcer. A terceira é a de que o texto é a parte cara, então tudo o que depende dele sai da mesma leitura, uma leitura e sete saídas. O Sonnet, mais forte, fica reservado para o que exige julgamento, como confirmar um jabuti. Até 6 de outubro eram 13.611 proposições analisadas, e entre elas 2.823 criam gasto ou renúncia, das quais 1.648 não dizem de onde vem o dinheiro.
O lote de 5.000 que acabava com o mês em dois dias e meio
O primeiro lote de produção tinha 5.000 proposições por noite, e o teto mensal que tínhamos fixado para a análise acabava em 2,6 dias, o que travava o resto do mês. Baixamos para 150 por noite, e com isso o mês inteiro cabe no orçamento. A troca tem um preço honesto, que é o de a cobertura andar devagar, e com esse ritmo 17% das proposições substantivas têm análise (13.611 de 79.187), e analisar o resto levaria mais de um ano, de modo que a fila prioriza o que virou lei e o que as pessoas visitam.
Houve também o lote órfão. A Batch API é assíncrona, e se o processo morria esperando, por exemplo num deploy, o identificador do lote se perdia, mas a cobrança não, e a rodada seguinte pagava de novo. A correção é a regra mais velha de sistemas distribuídos, gravar o identificador na tabela llm_batch antes de esperar.
O esquema pedia uma lista, e às vezes vinha uma string
Mesmo com a ferramenta forçada, o Haiku às vezes devolvia uma lista (de pontos-chave ou de temas) como um texto único com marcação XML dentro. O código tinha três camadas de defesa, e todas faziam a mesma coisa, que era ignorar o campo e seguir, de modo que um erro visível virou perda silenciosa. Foram 26 dias sem que ninguém notasse, com 1.768 análises afetadas, e o reparo devolveu 10.623 temas à busca. A correção converte o formato e conta cada conversão (coerced_lists), e o problema ainda acontece, de 10 a 24 vezes por lote de 150 em outubro, mas agora aparece no resumo de cada rodada.
Jabuti ou não
Jabuti é o trecho estranho ao tema que pega carona num projeto, e o exemplo clássico é a "taxa das blusinhas", a taxação de compras internacionais de até 50 dólares inserida num projeto sobre carros mais eficientes, o Programa Mover (PL 914/2024). Ela estava no artigo 32, perto do caractere 59 mil, e o nosso teto de texto era 48 mil, que subimos para 80 mil para alcançá-la. O modelo também acusava dois falsos positivos que nos ensinaram o critério, o de uma política de saúde mental nas escolas custeada por 1% da arrecadação das apostas e o de um projeto de proteção ao denunciante com ônibus exibindo o Disque-Denúncia, porque citar outro setor não faz jabuti, e fonte de custeio e divulgação do próprio objeto servem ao objeto. O critério virou um teste de função, o de saber se o trecho serve ao objeto do projeto, e com ele o Sonnet foi de 1 para 3 acertos em 4 casos de teste.
A primeira ideia para achar jabutis parecia elegante. Já tínhamos o vetor de cada trecho, então o trecho que mais se afasta do resto do documento seria o suspeito, e bastaria um corte de similaridade. Com o corte em 0,72 não saiu nenhum suspeito, porque o trecho mais estranho de toda a base estava em 0,7501, e subindo o corte para 0,80 o jabuti real das blusinhas (0,79) entrava, junto com uma enxurrada de documentos longos e normais. O motivo é estatístico, já que o menor valor entre muitos trechos é sempre menor que entre poucos, e por isso um corte fixo mede o tamanho do documento e não o desvio do trecho. Testamos seis métodos semânticos, e nenhum separava jabuti de não-jabuti.
A saída foi trocar o limiar por ordenação e por uma segunda opinião. O primeiro detector é o próprio Haiku, que já lê o texto inteiro na análise de cada noite e aponta suspeitos sem custo extra, e ele cuida de não deixar passar. O segundo pega os 6 trechos mais estranhos de cada proposição, ordenados e sem corte fixo, e pede ao Sonnet que confirme, em lote de 20 por noite, e ele cuida de não acusar à toa. Confirmar suspeitos achou 48 jabutis em 153 proposições (31%), contra 4 em 150 (2,7%) numa varredura às cegas, ou seja, 12 vezes mais achados por proposição examinada, e ainda por cima custando menos por rodada. Os limites vêm sem enfeite, porque a precisão estimada é de cerca de 55%, numa amostra de 9. Hoje 419 proposições têm o selo.
Um detalhe que vale ouro para quem escreve SQL. No Postgres, valores NULL vêm primeiro numa ordenação decrescente, como diz a documentação (abre em outra aba), e a fila "recentes primeiro" nunca funcionou por causa disso, porque 1.557 proposições antigas com o campo nulo furavam a fila, e nenhuma das 20 primeiras era recente até 5 de outubro. Um coalesce(recente, false) resolveu.
Relevância. O fácil de medir era movimento, e o que importava era conteúdo
O quarto capítulo começa com uma cena de 12 de julho, em que a seção "Em destaque" do site era uma fila de renovações de concessão de rádio. A primeira nota de relevância usava oito sinais, todos de procedimento (tramitou, foi votado, avançou de etapa), com meia-vida de 90 dias, e uma renovação de rádio tramita rápido e sem polêmica, e por isso subia, enquanto a PEC do fim da escala 6x1, parada havia meses, decaía para perto de zero. A nota media movimento e não importância.
Entraram três sinais de conteúdo, o tipo da proposição, que é de graça, a importância estimada por IA a partir da ementa e a presença na mídia, que ganhou o maior peso de todos. A importância por IA custa uma fração de centavo por ementa, e a IA só lê a ementa e classifica o alcance, e as médias por classe mostram que a régua faz sentido, com 0,88 para as estruturantes, 0,59 para as setoriais, 0,23 para as rotineiras e 0,04 para as honoríficas. A meia-vida subiu de 90 para 243 dias, e a importância a estica até três vezes, de 243 a 729 dias, para que um projeto importante e parado há meses não suma.
A nota final é uma soma ponderada com decaimento exponencial, sem nenhum modelo de machine learning, e os 12 pesos foram escolhidos e ajustados à mão, o que é simples de explicar e de depurar. Os maiores são mídia em alta (3,5), importância por IA (2,5), pauta-bomba fiscal (2,0), tipo da proposição, voto nominal no plenário e avanço de estágio (1,5 cada), e os menores são coautoria e proposições relacionadas (0,75 cada) e apresentação recente (0,5). O detalhe que mais ajudou foi gravar cada nota junto com os sinais que a produziram, num campo JSON, porque quando alguém pergunta "por que isso está em destaque?" a resposta está no banco. O sinal de mídia tem um cuidado anti-alucinação, já que toda segunda o Sonnet busca na web os projetos em discussão, o Haiku extrai o número de cada um, e o número só vale depois de conferido no banco ou na API da Câmara.
O efeito foi o esperado, com a PEC 221/2019 subindo de 13,6 para 55,0 pontos, e o PDL de rádio mais bem colocado hoje está na posição 12.135 entre 22.066. A lição do capítulo é a de que medir movimento era fácil, porque os dados de tramitação já estavam no banco, e que medir conteúdo exige que alguém, ou algum modelo, leia.
Latência. A busca estava certa, só estava lenta
O quinto capítulo, de julho a agosto, é o de duas causas que nenhum EXPLAIN mostra, a memória e a geografia.
O índice maior que a memória, duas vezes
Um índice de vetores só é rápido se cabe na memória, porque o grafo é percorrido aos saltos e cada salto fora da RAM vira leitura de disco. Em 28 de julho o índice HNSW em float32 tinha 1.304 MB para um cache do Postgres de 256 MB, cinco vezes maior, e toda busca lia disco, com média de 1.852 ms na busca híbrida, máximo de 7.986 ms e 4.347 MB lidos do disco num dia, o que estourou a cota de I/O do Supabase. Em 29 de julho caímos para 69 MB com quantização binária, e em 11 de agosto a carga do histórico dobrou o acervo e o índice voltou a passar do cache, e então a decisão, do dono do projeto, foi pagar por mais memória, e a RAM dobrou até 13 de agosto, porque engenharia também é saber quando o dinheiro resolve melhor do que mais uma semana de otimização. O assistente de IA projetou que o acervo dobraria a cada 13 dias, e era só o backfill terminando, já que o ritmo real é de uns 5 MB de índice por mês.
Um bit por número para achar, o vetor inteiro para ordenar
Na quantização binária cada um dos 1.024 números do vetor vira um único bit, positivo ou negativo, e o vetor cai de 4 KB para 128 bytes, e comparar dois vetores vira contar quantos bits são diferentes (distância de Hamming), o que o processador faz muito rápido. Sozinha, ela erra metade dos resultados, e o truque é usá-la em dois estágios, com o índice binário achando 400 candidatos e só esses 400 sendo reordenados pelo cosseno com o vetor em meia precisão. A gente mediu as quatro opções em 60 consultas reais antes de escolher.
Representação | Recall@20 | Índice |
|---|---|---|
Meia precisão ( | 100% | 438 MB, e não cabe na memória |
512 dimensões | 80% | o menor |
Binário, sem reordenar | 49,8% | 69 MB |
Binário, reordenando 400 candidatos | 99,3% | 69 MB |
O recall@20 diz quantos dos 20 resultados da busca exata a busca rápida encontra, e a opção escolhida acha 99,3% num índice 19 vezes menor, com busca híbrida de 8,4 ms de mediana depois que o índice novo foi construído em 100 segundos. O assistente de IA tinha sugerido cortar para 512 dimensões antes de medir, e isso teria custado 20% do recall. Houve ainda uma armadilha de banco, porque preencher a coluna nova com UPDATE ia a 5 linhas por segundo e levaria 9 horas, enquanto um INSERT numa tabela à parte ia a 3.333 linhas por segundo.
Lenta por geografia, não por SQL
Medimos e a busca levava 1,45 s ponta a ponta, dos quais o Postgres fazia 18 ms, e o resto era viagem. O site e o banco estão em São Paulo, mas a API ficava nos Estados Unidos, porque o Railway não tem região na América do Sul, e com isso cada busca cruzava o equador três vezes, com uns 215 ms por chamada ao banco. A busca foi para uma Edge Function do Supabase, no mesmo datacenter do banco, que só vai aos Estados Unidos para gerar o vetor da consulta, e só quando ele não está no cache, o que acontece em 1% das vezes, e a API antiga ficou como rota de escape.
Duas armadilhas são lições de medição. Medir a distância até o banco pelo hostname deu 3 ms, mas quem respondia era a borda da Cloudflare, e pela porta real do Postgres eram 184 ms. E no deploy a busca por sentido falhava calada e caía na palavra-chave, respondendo 200, porque a chave da Voyage só vale em outro endereço, e hoje a resposta avisa quando está degradada. A lição do capítulo é a de medir antes de otimizar, o recall antes de cortar dimensão e a latência na porta certa.
Crons. Ninguém aperta o botão, e 33 crons apertam
O sexto capítulo, de julho a outubro, é o de operação, e a pergunta do título é o capítulo inteiro, porque automatizar é fácil e saber se a automação fez o trabalho é a parte difícil. De madrugada o banco acorda a API, que acorda o worker, que acorda a IA, e a ordem importa. A importância é calculada antes da relevância, o texto é atualizado antes do detector de jabuti, a ingestão diária roda das 3h às 7h e insiste a cada 10 minutos até conseguir, e os posts são gerados às 7h e publicados ao meio-dia e às 19h, num mostrador em que também roda, todo minuto, o faxineiro de zumbis que vem adiante.
O cron mede se o pedido saiu, não se o trabalho foi feito
O pg_cron roda um SELECT numa função, que decide se dispara (tem fila, o diário já rodou) e faz um net.http_post pelo pg_net, que é assíncrono, enfileira o pedido e termina sem esperar a resposta. A API aceita com 202 ou recusa com 409 quando já há uma tarefa da mesma casa rodando, e o trabalho é registrado na tabela ingestion_run, com um pulso a cada 30 segundos, até gravar o resultado. O ponto cego está no meio disso, porque o pg_cron registra "succeeded" assim que o SELECT termina, e se a API devolve 409 o disparo some sem erro em lugar nenhum. O único lugar em que dá para saber se o trabalho aconteceu é o resultado no banco, e foi isso que guiou o resto do capítulo. O banco agenda tudo porque o primeiro agendador rodava no Railway e pulou um dia depois de uma rajada de deploys, e uma agenda dentro do banco não reinicia com o deploy da API.
Uma corrida decidida por segundos
Essa é a história que abriu o texto. O gatilho do detector de jabuti não disparava se houvesse qualquer tarefa rodando, e a atualização de textos termina por volta de 01:40, o mesmo horário em que o gatilho olhava. O detector rodou em 16 de 66 dias e o pg_cron registrou "succeeded" nos 66, num casamento perfeito com o horário em que a outra tarefa terminava, e nos dias em que ele rodou foi porque a outra tarefa tinha acabado às 01:39. A ironia é que a trava existia para evitar um 409 da API, mas em agosto a API já tinha sido liberada para essa tarefa, e a trava não protegia mais nada e passou a causar a perda que queria evitar. A correção de 5 de outubro foi apagar quatro linhas.
Todo push mata o que está rodando
O registro de tarefas da API fica em memória, e todo push em main redeploya o Railway e mata o processo, mas no banco o run continuava "running" para sempre, e as tarefas seguintes esperavam 45 minutos. A solução foram duas peças, um pulso (heartbeat) a cada 30 segundos, numa thread separada, e um faxineiro (reaper) que roda todo minuto e marca como falho quem está sem pulso há 3 minutos, de modo que vivo ou morto se decide pelo frescor do pulso e não pela idade da tarefa. A espera para destravar a fila depois de uma queda foi de 45 para 3 minutos, e dos 98 zumbis do histórico, 52 em 70 tinham um commit em main nos 20 minutos anteriores. Houve um tropeço dentro da solução, porque a primeira versão do pulso usava upsert, o INSERT violava uma restrição, o erro era engolido e o pulso nunca avançava, e a regra que ficou para o time foi a de conferir o que está rodando antes de dar push. O reaper, vale dizer, responde por 80% dos cerca de 54 mil disparos mensais do pg_cron.
Uma trava global onde a regra era por casa
Para não estourar as APIs, só uma tarefa pesada rodava por vez, mas a trava era global, e uma carga do histórico da Câmara, que levou 4 dias, bloqueou o Senado e a ingestão diária, a única fonte de proposições novas. A ingestão diária olhava 2 dias para trás e ficou 3 dias bloqueada, e só não perdemos dado porque o bloqueio caiu num fim de semana. Passamos a ter uma trava por casa, já que os hosts são independentes, o diário reserva prioridade por 2 horas e insiste a cada 10 minutos, e a janela foi de 2 para 4 dias, e desde 29 de julho nenhum dia ficou sem ingestão diária. Com o efeito acumulado de várias melhorias, a duração média da ingestão diária foi de 271 minutos na semana de 13 de julho para 53 na de 28 de setembro, de quatro horas e meia para menos de uma hora. Essa mesma família de bug, uma trava global onde a regra deveria ser por recurso, apareceu em seis camadas do sistema.
A lição do capítulo é a de que "o cron rodou" e "o trabalho foi feito" são perguntas diferentes, e houve cinco corridas desse tipo no projeto, todas com o painel verde.
Alertas. O mecanismo diz que está tudo bem, e o resultado diz a verdade
O sétimo capítulo, de agosto a outubro, parte da conclusão do anterior. Vigiar se cada uma das 33 tarefas rodou não funcionou, e a saída foi vigiar o resultado, e em vez de 33 tarefas passamos a vigiar 8 perguntas sobre o dado.
Quatro camadas de olho, do erro ao resultado
O monitoramento tem quatro camadas, e só a última pegou as falhas que contamos até aqui.
Camada | O que enxerga | Avisou das corridas de cron? |
|---|---|---|
Erro (Sentry) | Exceções do site e da API, com source maps, e foi ele que mostrou os 2.008 timeouts da busca | Não |
Saúde ( | O site responde se está vivo, e a API responde ok, velho ou degradado, em que "velho" é dado com mais de 36 horas | Não |
Execução ( | Cada tarefa vira uma linha com início, pulso e fim, e o painel admin cruza disparo com run e mostra os 409 | Não |
Resultado (expectativas) | Oito perguntas sobre o dado, com e-mail três vezes por dia quando alguma é violada | Sim |
Nenhuma das três primeiras avisou sobre as corridas do capítulo anterior, porque em nenhuma delas houve erro. A quarta inverte a pergunta, e em vez de "o job rodou?" ela pergunta "o dado novo chegou?".
Cada expectativa diz quando algo aconteceu e quando é tarde
Cada expectativa tem três partes, um nome, uma consulta que diz quando a coisa aconteceu pela última vez e um limite de idade, e se a notícia mais recente tem mais de 6 horas ou o tema em alta mais recente tem mais de 8 dias, o sistema manda e-mail. O detector não precisa saber por que falhou, porque um 409, um orçamento estourado, uma agenda errada, um worker morto e um bug novo dão todos o mesmo sintoma, que é resultado velho. Os limites saem do ciclo de cada coisa, 6 horas para as notícias, 12 para os embeddings de notícia, 36 para a análise por IA e a tramitação, 48 para o texto das proposições, 96 para o rascunho social e 8 dias para os temas em alta e populares (o semanal tem 7 dias de normal, e 8 só acontece se uma semana inteira foi perdida). Cada alerta fica em silêncio por 12 horas, porque alerta que vira ruído ninguém lê, e as consultas moram no corpo da função e não numa tabela, porque SQL guardado e executado com privilégio de dono é porta de injeção, o que significa que adicionar uma expectativa é escrever uma migration.
A rede de segurança que redispara sozinha
O primeiro estágio a ganhar uma rede de segurança foi a descoberta semanal de temas em alta na imprensa, que ficou três semanas muda com o painel verde. Ela primeiro perdeu a corrida do minuto :00, depois bateu no teto de orçamento e depois teve a resposta cortada no limite de tokens e gravou zero. A rede é uma tarefa diária que pergunta se o tema mais novo tem mais de 8 dias, e se tiver dispara a descoberta de novo, e em setembro, depois de duas segundas seguidas com zero, ela recuperou 17 temas em 16 de setembro e 23 em 25 de setembro. Ainda há rodadas que pagam, acham zero e terminam como sucesso, e a causa não está confirmada, de modo que a rede cobre o sintoma e a causa segue aberta.
O alerta é tão bom quanto a coluna que ele lê
Dois alarmes falsos recentes tiveram a mesma raiz. O de tramitação ("há 3 dias") media o último evento no Congresso e não a nossa execução, e no fim de semana do primeiro turno das eleições houve zero tramitações e na segunda 184, e como o Senado tem essa coluna vazia em todas as proposições, o alerta só cobre a Câmara. O de análise por IA ("há 72 horas") lia a data de criação da linha, quando a análise nas duas noites tinha atualizado 150 linhas por noite criadas por outro estágio que não rodou. E houve os acertos, com o alerta de tema em alta parado (17 de agosto), o de rascunho social parado (18 de agosto, que levou à corrida do detector de jabuti) e o de rascunho social de novo em 5 de outubro, que revelou três bugs reais, assunto do capítulo de alcance.
Custos. A maior conta não veio da IA, veio dos robôs
O oitavo capítulo, de julho a setembro, trata de custos reais, sem arredondar para baixo, e o título surpreende porque a IA, que todo mundo acha cara, custou menos do que servir páginas para robôs. Em agosto foram 171 visualizações de página feitas por pessoas em 8 dias, contra 355.823 execuções de função em 24 horas, e a conta de hospedagem daquele mês foi quase o triplo da de IA. Até julho o site cabia no plano gratuito da Vercel, de 4 horas de CPU por mês, e em 28 de julho já tínhamos usado 10h46, e o dono subiu para o plano pago com a meta de voltar ao gratuito, o que não aconteceu. Deixamos os valores em dinheiro de fora deste texto e mantivemos as grandezas que explicam a conta.
Invocação é barata, e paga-se tempo e bytes
A intuição de quem vai usar serverless diz que se paga por requisição, e a nossa fatura diz outra coisa. Até 23 de agosto, as 2,22 milhões de execuções em si foram o item mais barato da fatura, e o resto foi CPU ativa (176 horas), transferência da função para a CDN (59 GB) e memória provisionada (1,2 mil GB-hora), além de observabilidade cobrada à parte. Os três itens grandes são o mesmo fenômeno, muitas páginas distintas, sem cache, cada uma esperando o banco, e a conta fecha de três jeitos, 2,22 milhões de execuções vezes cerca de 27 KB dão cerca de 59 GB, 2,22 milhões vezes cerca de 1 segundo vezes 2 GB dão cerca de 1,2 mil GB-hora, e cerca de 70% do tempo pago era espera por banco e rede. No Fluid Compute a memória é cobrada pelo tempo de relógio, então uma página lenta custa dinheiro mesmo sem fazer nada, e quando o robô sobrecarregava o banco cada página esperava mais e ficava mais cara, de modo que otimizar o banco também reduziu a conta de hospedagem.
Declarar cache não é ter cache
As páginas de proposição declaravam revalidação de 24 horas, mas numa rota com parâmetro o Next.js só liga o ISR se a página também exporta generateStaticParams, e sem ela a rota é dinâmica e o revalidate é ignorado em silêncio. A prova estava no cabeçalho HTTP, com x-vercel-cache: MISS três vezes seguidas e cache-control: private, no-store, e cada visita, quase sempre de robô, gerava a página de novo. Num único dia, 8 de agosto, isso somou 16 horas de CPU e esgotou o crédito do ciclo. O analytics viu 26 visualizações em 2 dias, e os logs, 45 renderizações em 4 minutos, que eram robôs percorrendo as 16.838 URLs do sitemap. A correção é uma função que devolve uma lista vazia, o que não gera nada no build (pré-gerar 78 mil páginas seria inviável) e faz cada página entrar no cache na primeira visita, e o tempo até o primeiro byte foi para 0,18 a 0,21 s com HIT. Dois commits de julho afirmavam "ISR ligado" sem que ninguém tivesse conferido o cabeçalho, e a lição, que também foi erro do assistente, é a de conferir o símbolo da rota no build e o x-vercel-cache.
Três adversários, três eixos de bloqueio
Para achar o culpado precisamos instrumentar a página de busca por algumas horas, porque os logs da Vercel não guardam User-Agent nem IP, e 28 de 28 amostras eram o robô da Meta, que ignorava a regra genérica do robots.txt. A primeira regra foi pelo nome (meta-externalagent), que derrubou o tráfego da busca de 6.827 requisições por hora para 1.170 e, com uma lista de 7 robôs de IA, para 60, mas a Meta voltou em uma semana com outro nome (meta-webindexer, 7.257 por hora). A segunda foi pela classe, uma expressão regular que pega qualquer um que se declare robô (bot|crawl|spider|indexer|meta-|...) e levou a busca a 90 requisições por hora, porque a Meta não escapa trocando de crachá. A terceira foi pelo endereço, contra um scraper paciente disfarçado de Chrome, com referer falso do Google, num datacenter da Alibaba, a 7 pedidos por minuto, abaixo do limite de taxa, que só a faixa de IP denunciava.
O firewall da Vercel avalia as regras em ordem, com o limite de 60 requisições por 60 segundos por IP (desde 18 de agosto), o bloqueio por classe de User-Agent (desde 24 de agosto) e o bloqueio da faixa de IP do datacenter (desde 2 de setembro), e o bloqueio acontece antes de a função rodar, então não gera custo de execução, ao contrário do robots.txt, que é só um pedido educado. Houve três pegadinhas que valem para quem configura firewall, porque o operador inc testava igualdade exata e a regra não pegava ninguém, uma duração de bloqueio de 5 minutos tirava o site inteiro de quem estourava o limite, e limite de taxa se testa com rajada concorrente, já que 75 curl em sequência deram falso negativo. A decisão do dono foi bloquear só a rota de busca, que é ferramenta, porque ser lido e citado por IA nas páginas de conteúdo é objetivo do projeto, e depois do bloqueio de 24 de agosto a semana de hospedagem caiu a cerca de um décimo do pico de meados do mês. No fim de setembro veio uma nova onda de robôs, e a conta subiu de novo.
O sitemap convidava os robôs para 26.507 páginas vazias
A consulta que montava o sitemap conferia se existia uma linha de análise, e não se havia análise nela, e por isso 26.507 das 36.385 URLs anunciadas estavam vazias, sem resumo, tema ou jabuti. Cada uma era uma página gerada e paga quando um robô chegava, e uma escrita de cache a cada revalidação, que chegou a 23% da fatura do segundo ciclo. O filtro reduziu o sitemap em 73%, para 9.878 URLs, de 6,6 MB para 1,8 MB, e as páginas sem análise passaram a pedir noindex, follow, o que se desfaz sozinho quando a análise chega. A decisão de 31 de agosto foi a de não migrar para a Cloudflare, porque a troca custaria semanas de porte do ISR e das imagens de prévia para uma economia pequena, e o custo era uma superfície criada pelo próprio projeto, que o projeto podia desfazer. A lição do capítulo é a de que em hospedagem serverless se paga tempo e bytes e não cliques, e a de que toda URL que você anuncia é um convite pago.
O assistente de IA acelerou tudo e errou com convicção
O último capítulo é sobre o jeito de trabalhar. Dos 571 commits do projeto, 436 foram escritos em par com um assistente de IA (Claude), e por isso o texto precisa dizer que os modelos que o produto chama, o Haiku na análise em lote e o Sonnet na confirmação de jabutis e na escrita dos posts, vêm do mesmo fornecedor do assistente que escreveu parte do código. Ele acelerou muito, e também deu diagnósticos errados com total convicção, todos registrados em commits, tarefas e na memória do projeto.
- Projetou que o acervo dobraria a cada 13 dias, quando era o backfill terminando, e o ritmo real é de uns 5 MB de índice por mês.
- Sugeriu cortar os vetores para 512 dimensões antes de medir, e quando medimos em 60 consultas reais isso custaria 20% do recall, enquanto a quantização binária com reordenação de 400 candidatos teve recall de 99,3% num índice de 69 MB.
- Chamou de robô um tráfego que era um monitor em loop, porque o painel escondia a query string e a análise parou ali.
- Disse que o ISR estava ligado em dois commits, sem conferir o cabeçalho da resposta.
- Atribuiu o detector parado a um 409 da API, que nem saía do banco.
- Afirmou que o cache expulsava o índice da memória, e a medição mostrou 100% de acerto no cache.
- Deu "recentes primeiro" como funcionando numa fila em que o Postgres, por padrão, coloca os NULL primeiro na ordem descendente (abre em outra aba), de modo que nenhuma das 20 primeiras era recente até 5 de outubro.
- Rodou um
grepnuma lista de variáveis que despejou uma chave privada no log local, o que virou regra, a de nunca listar segredos.
O padrão dos erros foi sempre o mesmo, uma explicação plausível aceita antes de uma medição, e o antídoto que funcionou foi pedir o número, seja o plano da consulta, o cabeçalho da resposta ou a comparação com a fonte. Toda vez que o diagnóstico veio acompanhado de uma medição, ele se sustentou. Isso vale também para quem escreve o código sem assistente, porque a diferença é só a velocidade com que a explicação errada chega.
O que levamos para o próximo projeto
Se tivéssemos que resumir em uma frase, seria:
"Validar pelo resultado e não pelo mecanismo foi o que tornou todo o resto detectável", já que quase todos os incidentes aconteceram com tudo verde no painel e o que pegou cada um foi alguém, ou algum alerta, olhando para o dado.
Daí saem os padrões que vamos aplicar desde o primeiro dia nos próximos projetos.
- Validar pelo resultado. O "rodou sem erro" mede o pedido, e quem mede o trabalho é uma pergunta sobre o dado que chegou.
- Conferir contra a fonte. Todo carregamento ganha uma contagem contra a origem, dia a dia, antes de ser dado como completo, porque "carregou sem erro" quase nunca quer dizer "carregou tudo".
- Uma pergunta sobre o dado para cada estágio. Antes de um estágio novo ir ao ar, ele ganha a sua expectativa, com o limite tirado do ciclo dele, e o alerta lê a coluna certa.
- Todo guard mostra o que recusou. Um 409, um
UPDATEde zero linhas ou um WARNING que vai para lugar nenhum são perdas silenciosas, e por isso contamos cada conversão e cada recusa. Erro silencioso é o pior erro. - Observabilidade que não divide o ponto de falha. O registro do erro tem de sobreviver à queda do recurso que ele registra.
- Medir antes de otimizar, e configurar não é ter. EXPLAIN antes do índice, recall antes de cortar dimensão, cabeçalho antes de dizer que o cache está ligado.
- Toda economia tem um gatilho de reversão com dono. Em 1º de agosto paramos de varrer proposições nunca checadas para economizar, combinando voltar atrás se a imprensa noticiasse um jabuti que não pegamos, e o gatilho disparou dois dias depois, mas a varredura só voltou em 24 de agosto porque ninguém estava olhando.
- Pedir o número ao assistente. Diagnóstico sem medição é hipótese, venha de quem vier.
Se você trabalha com dado público, pipelines agendados ou IA aplicada e discorda de algum ponto, ou já passou por um caso em que tudo estava verde e o dado estava errado, queremos ouvir, e a conversa acontece na comunidade da Síntese (abre em outra aba).
Fontes
- Síntese Labs, A tramitação do OlhoNaLei (abre em outra aba), apresentação com os dados reais do projeto, de 30 de abril a 6 de outubro de 2026.
- Google Firebase, Firestore, cotas e limites (abre em outra aba), sobre a cota gratuita de 20.000 gravações por dia e o limite de 1 MiB por documento.
- PostgreSQL, Sorting Rows (ORDER BY) (abre em outra aba), sobre a posição dos valores NULL na ordenação.
- Câmara dos Deputados, Dados Abertos (abre em outra aba), e Senado Federal, API de Dados Abertos (abre em outra aba), as duas fontes do projeto.
Quem escreveu
Escrito por Luis Felipe Nicolau, Erick Mendes, Eduardo Fernandes e Juan Talyson Macêdo.

Luis Felipe Nicolau
Produto e Capacitação
Sócio da Síntese Labs. Escreve sobre os desafios na Engenharia de Software.
LinkedIn
Erick Mendes
Produto e Capacitação
Sócio da Síntese Labs. Escreve sobre a evolução da Inteligência Artificial no Brasil.
LinkedIn
Eduardo Fernandes
Analista de dados
Membro da Comunidade. Especialista em engenharia de dados.
LinkedIn
Juan Talyson Macêdo
Engenheiro de dados
Desenvolvedor do Olho na Lei. Especialista em engenharia de dados e desenvolvimento back-end.
LinkedInContinuar lendo
Engenharia e stack
Como a maneira com que lidamos com orquestração de agentes é única.
Como a Síntese Labs transforma práticas e ferramentas do desenvolvimento agêntico global em soluções próprias, antecipando tendências e aproximando essas tecnologias do mercado brasileiro.
Comunidade e open source
Rodar agentes em paralelo parece simples. A Síntese sabe onde a conta realmente pesa.
O Playmaker transforma seu agente em gerente de tarefas paralelas, deixando o código decidir o que pode rodar junto e validar os resultados. Aberto (MIT), e por enquanto usa as CLIs que você já tem.
Projetos da Síntese
Aprendendo a usar IA na ingestão de dados
Em projetos AI First, nem sempre é necessário colocar IA em tudo.
Tem um problema parecido para resolver?
A Síntese constrói software sob medida e capacita times em desenvolvimento agêntico, com cada etapa observável e rastreável.
Falar com a Síntese