# 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.

Por Luis Felipe Nicolau, Erick Mendes,  Eduardo Fernandes, Juan Talyson Macêdo · 31 de outubro de 2026 · Projetos da Síntese, Desafios de implementação, Engenharia e stack · 52 min de leitura

Publicado em https://sinteselabs.com/artigos/o-desafio-de-desenvolver-servicos-govtech

---

#### Resumo

- O [Olho na Lei](https://olhonalei.com) é 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

![logo-abrig](https://sinteselabs.com/payload-api/midia/file/logo-abrig.webp)

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](https://abrig.org.br/) (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](https://www.olhonalei.com/palestra), 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](https://dadosabertos.camara.leg.br/swagger/api.html) e do [Senado](https://legis.senado.leg.br/dadosabertos/), 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](https://firebase.google.com/docs/firestore/quotas).

### 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](https://firebase.google.com/docs/firestore/quotas), 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](https://www.postgresql.org/docs/current/queries-order.html), 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 (`halfvec`) | 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 (`/health`) | 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 (`ingestion_run`) | 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](https://www.postgresql.org/docs/current/queries-order.html), de modo que nenhuma das 20 primeiras era recente até 5 de outubro.
- **Rodou um **`grep`** numa 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 `UPDATE` de 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](https://sinteselabs.com/comunidade).

---

**Fontes**

- Síntese Labs, [A tramitação do OlhoNaLei](https://www.olhonalei.com/palestra), apresentação com os dados reais do projeto, de 30 de abril a 6 de outubro de 2026.
- Google Firebase, [Firestore, cotas e limites](https://firebase.google.com/docs/firestore/quotas), sobre a cota gratuita de 20.000 gravações por dia e o limite de 1 MiB por documento.
- PostgreSQL, [Sorting Rows (ORDER BY)](https://www.postgresql.org/docs/current/queries-order.html), sobre a posição dos valores NULL na ordenação.
- Câmara dos Deputados, [Dados Abertos](https://dadosabertos.camara.leg.br/swagger/api.html), e Senado Federal, [API de Dados Abertos](https://legis.senado.leg.br/dadosabertos/), as duas fontes do projeto.

---

Síntese Labs · https://sinteselabs.com/artigos/o-desafio-de-desenvolver-servicos-govtech

