Estudo de caso
Órbita Notícias: um pipeline de notícias em produção
Como um comparador de notícias virou uma pequena plataforma de dados: 42 fontes, ingestão a cada 5 minutos, agrupamento com qualidade medida e uma camada de serving que sobrevive à queda do worker. Tudo numa VPS de 1 GB.
O problema
Cada veículo enquadra o mesmo fato de um jeito, mas compará-los exige abrir dezenas de sites. O Órbita automatiza isso: coleta todas as manchetes, encontra as que tratam do mesmo acontecimento numa janela de 48 horas e as coloca lado a lado, ordenadas pelo espectro editorial.
Do ponto de vista de dados, isso exige: ingestão contínua de fontes heterogêneas que falham sem aviso, deduplicação e agrupamento de texto, pré-agregação para o serving e uma forma de provar que o agrupamento está certo.
Arquitetura
Duas cadências, ambas executadas por um worker no systemd: ingestão a cada 5 minutos e reagrupamento a cada 15. O processo web nunca calcula agrupamento; ele só lê o cache.
O código segue arquitetura hexagonal: o domain não conhece Flask, SQLite nem spaCy, e um teste reprova quem cruzar a fronteira entre camadas. Trocar SQLite por PostgreSQL é escrever um adaptador novo, não reescrever regras.
Ingestão
- Duas portas de entrada atrás da mesma interface: o RSS do veículo e, quando ele congela sem avisar, o sitemap de notícias do mesmo domínio.
- Schema mínimo e controlado: manchete, descrição (até 240 caracteres), data, veículo e link. Nunca o corpo da matéria. A classificação do veículo vem da configuração, não do XML.
- Validação na borda: link com esquema fora de http/https é descartado na entrada, o que bloqueia XSS armazenado vindo de um feed comprometido.
- Observabilidade por fonte: toda falha vai para
feed_failures.jsonlcom horário, veículo, URL e tipo de erro. Um job diário no GitHub Actions relata a saúde dos feeds a partir do servidor.
Armazenamento e migrações
SQLite, por escolha: um único servidor de 1 GB, um escritor (o worker) e muitos leitores. O caminho do banco vem de uma variável de ambiente, então a suíte de testes usa um banco descartável e uma análise local abre um snapshot de produção sem sobrescrever nada.
As migrações rodam como comando (migrar.py), versionadas, com trava e backup antes de aplicar. Um script de backfill (backfill_eventos.py) reconstrói eventos antigos quando uma regra muda.
Processamento
O agrupamento é dividido em três etapas, e essa divisão é a arquitetura em miniatura:
- Preparar o texto é regra de domínio (a manchete pesa mais que o resumo).
- Similaridade é estatística: TF-IDF + cosseno, atrás de uma porta. É a única etapa que precisa de numpy/scikit-learn, e ela roda só no worker.
- Decidir o que é o mesmo acontecimento volta ao domínio: limiar de similaridade, vizinhos mais próximos mútuos, nome próprio compartilhado e regras específicas (ex.: pesquisas de institutos diferentes nunca se juntam).
O worker também pré-monta as visões pesadas: empacotar o painel de bolhas levava 260 ms por requisição e hoje não custa nada na hora de servir.
Qualidade de dados
Ajustes no limiar eram conferidos no olho. Para responder "a correção quebrou outra coisa?", montei um corpus rotulado: todas as 192 manchetes publicadas numa janela de 30 minutos (43 agrupadas em 15 eventos, 149 únicas), com uma regra de rotulagem escrita.
A avaliação é precision/recall/F1 por par, para que as matérias únicas não dominem a conta. O test_corpus.py reprova se a métrica cair abaixo da linha de base registrada.
| limiar | precision | recall | F1 |
|---|---|---|---|
| 0.35 | 0.943 | 0.623 | 0.750 |
| 0.40 | 0.939 | 0.585 | 0.721 |
| 0.45 | 1.000 | 0.321 | 0.486 |
| 0.50 (produção) | 1.000 | 0.396 | 0.568 |
| 0.60 | 1.000 | 0.208 | 0.344 |
O F1 seria maior em 0,35, e isso sozinho não é motivo para mudar. O limiar de produção foi escolhido para evitar uma fusão errada conhecida e cara, que não está neste corpus. Escolher uma métrica é saber o que ela não enxerga.
Operação
- Degradação graciosa: se o worker cair, o site continua servindo o cache anterior; depois de 45 minutos, o processo web recalcula sozinho.
- Serving: o nginx guarda as respostas por 60 s com stale-while-revalidate e compressão; os arquivos estáticos têm versão no endereço e cache de um ano.
- CI/CD: fluxo feature → develop → release → main com GitHub Actions: lint, 1.130 testes, auditoria de dependências, deploy por SSH, health check que também verifica o worker e rollback automático.
Aprendizados e próximos passos
- Um conjunto rotulado para avaliação vale mais que qualquer intuição sobre limiares, e ele pegou erros nos meus próprios rótulos.
- Pré-computar no worker foi o maior ganho de desempenho, e ainda isolou as falhas.
- Próximos passos: levar o armazenamento para PostgreSQL, orquestrar as cadências com Airflow (retries, backfill, linhagem) e exportar o histórico para Parquet para análise.