Inteligência de audiência para uma emissora de TV
Um chat que responde às perguntas da equipe de programação a partir dos relatórios de audiência da própria emissora, com a página citada
Contexto
Uma emissora de TV aberta produz relatórios de audiência todo dia: curva minuto a minuto por programa, grade diária com o share de cada emissora, ranking dos cinco mais vistos, relatório mensal por praça, metas, social listening, jogos de futebol. Milhares de páginas em PDF, em mais ou menos uma dúzia de famílias de relatório, e crescendo. A equipe de programação e a diretoria querem fazer perguntas em português (“e se a gente lançasse um reality de culinária às 22h numa das praças?”) e receber uma resposta baseada só nesses relatórios, com a página de onde ela saiu. Os analistas mantêm a base atualizada subindo relatórios novos; nada mais é manual. As restrições são reais: um servidor Linux da própria emissora, Docker Compose, orçamento fixo, uma demo no fim de cada mês e a conta do LLM paga pelo cliente.
Meu papel
Sou o único engenheiro do projeto, sob contrato desde agosto de 2026. Escrevi a spec de design e as três decisões de arquitetura antes da primeira linha de código, e estou construindo na ordem definida pela spec.
As decisões: a fila de ingestão é a própria tabela de documentos no Postgres (status, tentativas, um timestamp de lock, capturada com um único SKIP LOCKED), porque Temporal ou Celery trariam containers a mais e um vocabulário que ninguém na emissora conhece, para um único tipo de job; a extração é híbrida por página, porque RAG puro não calcula média de centenas de linhas e treze parsers escritos à mão quebrariam na primeira mudança de layout; e toda chamada de modelo (chat, extração, classificação e embeddings) passa por um proxy LiteLLM, então o app não guarda chave de fornecedor nenhuma e trocar de modelo é mexer num YAML.
Construído até aqui: o schema (uma tabela fato de métricas em formato longo atrás de uma view, embeddings de página sob um índice HNSW), contas e autenticação por cookie, documentos deduplicados por hash com uma CLI de importação em lote, render e extração de texto das páginas com timeout, os agentes de cabeçalho e de digest com validação determinística da saída e escalada para um modelo mais forte, e a suíte de avaliação da extração. Os próximos passos: o pipeline inteiro, o agente de chat com as quatro ferramentas e o app em Vue.
- 01 Relatórios entram
- upload pelo app web
- importação em lote pela CLI
- deduplicados por SHA-256
- 02 Fila a própria tabela de documentos, sem broker
- status, tentativas, timestamp de lock
- capturado com uma única instrução SELECT … FOR UPDATE SKIP LOCKED
- 03 Worker um documento por vez, uma transação por resultado
- render das páginas: pdftoppm e pdftotext, com timeout
- agente de cabeçalho na página 1: família, praça, programa, período
- agente de digest em toda página: resumo mais linhas de métrica
- validação: descarta e conta, repete com os motivos, escala para um modelo mais forte
- 04 Base PostgreSQL + pgvector
- metrics tabela fato em formato longo atrás de uma view só de leitura
- pages resumo e embedding, índice HNSW, busca filtrada
- 05 Agente de chat PydanticAI, quatro ferramentas, stream SSE para o app em Vue 3
- search_pages
- query_metrics
- read_page
- list_programs
A parte difícil
O contrato entre o modelo e o banco. O modelo lê uma página (imagem e texto) e propõe um resumo mais linhas de métrica. Ninguém confia nele. A validação determinística roda como validador de saída do agente: aliases normalizados, valores limitados por tipo de métrica, campos obrigatórios por granularidade, todo valor precisa constar no texto da página, duplicatas descartadas pela chave natural. Linha que falha é descartada e contada, nunca remendada. Se cai linha demais, o agente tenta de novo recebendo as linhas rejeitadas e os motivos; se ainda falha, a página vai para um modelo mais forte, e se isso falha o documento termina “parcial”, com a página marcada. A suíte de avaliação mede precisão e recall por página numa amostra de trinta páginas reais conferidas à mão e projeta o custo do acervo inteiro; os dois gates estão fixos no código, não calibrados para passar.
A segunda: o mesmo fato aparece em cinco famílias de relatório, com targets diferentes. O share de um programa num dia está no relatório de minuto, na grade diária, no top cinco, na atualização do mês e no relatório mensal. Um agregado que não filtra família, target e granularidade está errado parecendo certo. É por isso que a tabela de métricas é em formato longo (uma linha por número) e a ferramenta de SQL que o agente recebe é só de leitura, em cima de uma única view, com uma role no banco que não enxerga mais nada.
Ferramenta para cliente (em desenvolvimento, captura em preparação)
Resultado
Em andamento. O primeiro marco, a ingestão, está quase fechado; o chat e o app web são o segundo e o terceiro. Tudo roda num servidor só, com um compose só, e esse é o ponto.
Projeto feito sob contrato para um cliente (descrito aqui sem o nome do cliente, seus dados ou detalhes de infraestrutura).