projeto · 25 de out. de 2026
check.stater
Um pipeline que assiste a um vídeo de política, separa quem fala, acha as afirmações que valem checagem, busca fontes e entrega um relatório, um vídeo com cartões e uma página pública. Do link ao relatório em cerca de um minuto e meio, por uns nove centavos de dólar.
- TypeScript
- Node 24
- TypeSafe Jev
- ElevenLabs Scribe
- OpenRouter
- Tavily
- SQLite
- React
- HyperFrames
- ffmpeg
- Astro
- Cloudflare Workers
Por que fiz isso
Em época de eleição, um corte de entrevista de quatro minutos chega no WhatsApp com cinco números, duas acusações e uma frase de efeito. Checar isso à mão leva uma tarde: assistir, transcrever o trecho, descobrir quem disse o quê, procurar a fonte, ler a fonte. Quando a checagem sai, o corte já rodou.
O check.stater faz a parte mecânica desse trabalho. Ele recebe um link do YouTube e devolve um relatório com cada afirmação checável, quem a disse, em que segundo, e o que as fontes dizem sobre ela. O veredito continua com uma pessoa. O sistema chega até “estas três fontes contradizem isto, e a confiança é baixa nesta aqui”.
Rodei em 11 vídeos reais da campanha presidencial de 2026, entre debates e entrevistas: 69 minutos de fala, 911 frases. A análise de cada um levou em média 96 segundos e custou US$ 0,095.
A regra que organiza tudo
Logo no começo eu separei três tipos de trabalho e não deixei um invadir o outro.
- Um LLM escreve. Ele reescreve a frase falada numa afirmação que dá para entender sem o vídeo e monta a consulta de busca. Nunca decide se algo é verdade.
- O Jev decide. O Jev, da TypeSafe, não gera texto. Ele responde perguntas fechadas com uma probabilidade calibrada, em uns 300 ms. Tem três formas de pergunta: Choice (escolhe uma opção), Score (dá uma nota numa escala) e Noul (probabilidade de uma afirmação ser verdadeira).
- O código junta. Pesos, cortes, contagem de fontes, agrupamento. Tudo que é regra fica em TypeScript, onde dá para ler, testar e mudar sem chamar modelo nenhum.
Na prática, uma chamada ao Jev fica assim. Uma frase, várias perguntas, uma ida à rede:
const res = await client.systemOne({
state: frase.text,
questions: {
tipo: TIPO.build('pt').q, // Choice: factual | opinião | pergunta | ironia | outro
categoria: CATEGORIA.build('pt').q, // Choice: número, fato histórico, citação, lei...
falacia: FALACIA.build('pt').q, // Choice: nenhuma | espantalho | ad hominem...
intensidade: INTENSIDADE.build('pt').q, // Score 0–3
apelo_medo: APELOS.medo, // Noul, um por apelo emocional
apelo_urgencia: APELOS.urgencia,
// ...
},
});
Os apelos emocionais são Noul, um para cada, e não uma Choice, porque uma frase pode ter medo e urgência ao mesmo tempo. A Choice só devolve uma opção.
Essa separação tem um efeito que eu não tinha previsto: quase toda decisão de política do produto (“fala de entrevistador vale menos”, “só pesquisa acima de 0,4”) virou uma constante num arquivo. Mudar a política não exige rodar nada de novo.
carregando o diagrama…
Abaixo, as doze etapas com o que entra, o que sai e onde mora o código.
As 12 etapas
Clique numa etapa. As cores dizem quem decide.
- código
- Jev
- LLM
- serviço
Download
serviçocódigo- entra
- link do YouTube
- sai
- audio.mp3 (mono, 16 kHz, 32 kbps) + metadados
- onde
scripts/youtube/download.ts
- yt-dlp lê título, canal, duração e descrição; a descrição costuma trazer o nome de quem fala.
- ffmpeg reduz o áudio ao mínimo que a transcrição precisa. 15 min cabem bem abaixo do limite de 24 MB por requisição.
- Timeout de conexão fixo: uma rota IPv6 quebrada vira erro em vez de travar a fila.
Do link às frases
A ingestão é a parte menos glamorosa e a que mais quebrou. O yt-dlp baixa o áudio e o ffmpeg converte para mp3 mono de 16 kHz a 32 kbps. É o mínimo que a transcrição precisa, e com isso 15 minutos de vídeo cabem numa requisição só. Tive que fixar um timeout de conexão porque, na minha rede, o IPv6 às vezes abre a conexão e nunca entrega nada. Sem o timeout, a fila inteira travava esperando um download que não vinha.
A transcrição usa o Scribe v2, da ElevenLabs, chamado pelo OpenRouter, com separação de falantes. Ele devolve cada palavra com início, fim e um número de falante. Mandei o arquivo inteiro de uma vez, e não em pedaços, por um motivo simples: se você transcreve dois pedaços separados, o “falante 1” do primeiro não é necessariamente o “falante 1” do segundo. Quinze minutos de áudio voltam em uns dez segundos.
Se a separação de falantes falhar, o pipeline cai para o Whisper large-v3 em pedaços paralelos e segue sem falantes. A análise fica pior, mas sai.
Depois vem o corte em frases. O Intl.Segmenter do próprio JavaScript faz o trabalho pesado, dentro de cada turno de fala, então uma frase nunca começa com uma pessoa e termina com outra. O detalhe que mais melhorou o resultado foi tratar os microturnos: quando o entrevistador solta um “uhum” no meio da resposta (até duas palavras, menos de 1,2 s), aquilo não pode partir a fala do entrevistado em duas. O código absorve e junta os dois lados.
Classificar cada frase
Cada frase passa por uma chamada ao Jev com todas as perguntas juntas: que tipo de frase é, de que categoria, se tem falácia, qual a intensidade, quais apelos emocionais. Seis chamadas em paralelo, umas cem frases por vídeo.
Antes de ligar isso a vídeo real, montei um conjunto de validação em português com frases escritas à mão, metade em texto limpo e metade imitando fala transcrita (“então, tipo, o cara cortou dez por cento, né”). A mesma frase em inglês serve de controle: se o acerto em português caísse muito abaixo do inglês, o problema seria o idioma, e eu trocaria o Jev por um LLM naquela decisão.
O resultado da última rodada: 85% de acerto em português, igual ao inglês. Em texto limpo, 94%. Em fala transcrita, 71%. Essa queda na fala é real, e é o motivo de o relatório mostrar sempre o trecho original.
A confiança do Jev quer dizer alguma coisa?
Acerto por faixa de confiança, conjunto PT-BR, jev-1.13.0.
- confiança ≥ 0,9
- abaixo de 0,9
- limiar de revisão (0,7)
Com confiança ≥ 0,9 o Jev acertou 26 de 27. Abaixo de 0,9 o acerto cai para perto de 60%, com poucas amostras em cada faixa. É por isso que todo o pipeline usa a confiança como portão: abaixo de 0,7, a decisão vai para um humano.
O gráfico acima é o que me convenceu a usar a confiança como portão em todo lugar. Quando o Jev diz 0,9 ou mais, ele quase sempre está certo. Abaixo disso, vira cara ou coroa com viés. Hoje o limiar de revisão é 0,7 para tudo, e esses números sugerem que ele está baixo. Calibrar um limiar por decisão está na lista.
Quem está falando
Este foi o problema mais frequente nos primeiros testes. Num vídeo do Metrópoles, quase todas as frases checadas eram do repórter. Num do SBT, a afirmação mais grave era da entrevistadora (“candidato, o seu nome aparece no Banco…”). Sem saber quem fala, o sistema atribuía ao candidato o que ele não disse.
A separação da transcrição dá números (falante 0, falante 1). Para saber o papel de cada um, eu monto um retrato em palavras e pergunto ao Jev com uma Choice: entrevistado, entrevistador, repórter ou outro.
{
"video": { "titulo": "Lula cobra explicação de Flávio sobre...", "canal": "..." },
"falante": {
"participacao": "fala um terço do tempo do vídeo",
"perguntas": "sete de nove frases são perguntas",
"trechos": ["O senhor pode explicar...", "..."]
}
}
As proporções vão por extenso de propósito. O Jev lê número como texto, os itens da validação foram escritos assim, e eu não quis mudar o formato de algo que já estava medido.
O nome é outra história. Um LLM sugere quem é cada falante a partir do título, da descrição e das falas. O código só aceita o nome se ele estiver escrito em algum desses lugares, e descarta se dois falantes receberem o mesmo. LLM adora completar sobrenome de memória. Aqui ele não pode.
O que vale uma busca
Buscar custa dinheiro e tempo, e a maior parte do que se diz num debate não é checável. A triagem faz três perguntas independentes ao Jev para cada frase candidata:
- dá para conferir isso em fonte pública? (Noul)
- quanto importa para o público saber se é verdade? (Score 0–3)
- quantos sinais de exagero ou distorção a frase tem? (Score 0–3)
As três dimensões vêm do CLEF CheckThat!, um desafio acadêmico de checagem. A nota final é composta no código: verificável vezes uma média ponderada de interesse e exagero, vezes o peso de quem falou. Fala do entrevistador vale metade, porque quase sempre é pergunta ou acusação. Se o entrevistado confirma ou nega, volta a valer inteira, porque a afirmação virou dele também.
Vale gastar uma busca?
Escolha um exemplo ou mexa nas respostas do Jev.
"A Justiça do Trabalho custa 24 bilhões por ano."
- interesse
- exagero
- corte 0,4
0.93 × (0,6 × 3/3 + 0,4 × 2/3) × 1 = 0.81
pesquisar, se couber nas 6 buscas
O orçamento é de seis buscas por vídeo. No total dos 11 vídeos, de 911 frases, 62 foram pesquisadas.
Do que foi dito ao que vale uma busca
Soma dos 11 vídeos.
- frases transcritastudo o que foi dito, cortado em frases911100%
- candidatasfactuais, ou de tipo incerto (confiança < 0,7)48053%
- factuais com certezaChoice do Jev: afirmação factual25728%
- verificáveisNoul "dá para conferir em fonte pública?" ≥ 0,715617%
- pesquisadasacima do corte, por prioridade, até 6 buscas627%
"Candidatas" fica acima de "factuais" de propósito: frase em que o Jev hesitou no tipo também vai para a triagem, e quem decide é a pergunta de verificável. Errar para o lado de olhar mais é barato aqui.
A memória do vídeo
Políticos repetem. Num debate de oito minutos, a mesma acusação aparece três vezes com palavras diferentes, e sem cuidado isso gasta três das seis buscas. Às vezes a pessoa também se contradiz, e isso é notícia.
A memória compara cada afirmação com todas as outras. Para cada par, uma Choice do Jev: é a mesma afirmação, contradiz, está relacionada ou não tem relação. Pensei em filtrar os pares por palavras em comum, para economizar, e desisti: as paráfrases e as contradições são justamente os pares com poucas palavras em comum. “Não devo nada a ninguém” e “aquela dívida de R$ 36 mil” não dividem quase nada.
Comparar tudo com tudo cresce com o quadrado. Com 60 afirmações (o teto) são 1.770 chamadas. Parece muito, mas cada uma custa três centésimos de centavo de dólar e elas rodam em paralelo.
Comparar tudo com tudo
Cada ponto é um vídeo. A linha é n(n−1)/2.
As repetições viram grupos com union-find. A parte delicada foi o veto: o Jev às vezes diz que “10% de desemprego” e “12% de desemprego” são a mesma afirmação, e depois que “12%” e “15%” também são. Sem cuidado, a corrente junta 10% com 15%. O código confere os números de cada lado (com tolerância de 15% e entendendo “40% sabem” como “60% não sabem”) e só junta dois grupos se nenhum par entre eles for vetado.
Contradição tem mais portões, porque um alarme falso aqui é grave. Na validação, as contradições verdadeiras saíram com probabilidade de 0,96 ou mais, e as falsas (citação de terceiro, pergunta seguida de resposta) com uns 0,79. Também percebi que o Jev tem uma leve preferência pela primeira opção da lista. Então a contradição precisa passar duas vezes, a segunda com o par trocado e as opções em ordem inversa, e ainda passar por regras de código.
Quando duas falas se contradizem
O Jev propõe, o código filtra. Tudo que passa vai para revisão humana.
[Entrevistado] "Eu não devo nada a ninguém."
[Entrevistado] "Aquela dívida de R$ 36 mil eu já negociei."
- ✓1ª passada: p(contradiz) ≥ 0.6abaixo disso, nem vale a segunda chamada
- ✓nenhum lado é citação de terceiroquem cita outra pessoa não está se contradizendo
- ✓os anos citados coincidemnúmeros de anos diferentes falam de períodos diferentes
- ✓não é pergunta e a resposta delavira "resposta", e o peso da pergunta muda
- ✓1ª e 2ª passada ≥ 0.92ª passada com o par trocado e as opções em ordem inversa
contradição · vai para revisão
Nos 11 vídeos, a memória achou 31 grupos de repetição, ligou 59 perguntas às respostas e marcou 4 contradições, todas para revisão.
Buscar e ler as fontes
Para cada afirmação escolhida, duas buscas em paralelo. Uma no Google Fact Check, que devolve checagens já publicadas por Lupa, Aos Fatos, Estadão Verifica e outras. A outra na web, pelo Tavily (ou pelo Exa, via OpenRouter), restrita a uma allowlist pública de uns 55 domínios: agências de checagem, governo, imprensa e ciência. A lista é a mesma para qualquer lado político, e fica num arquivo que qualquer um pode ler.
O Jev lê cada trecho retornado e responde duas coisas: isso trata da afirmação? (Noul, a fonte só conta com 0,7 ou mais) e confirma, contradiz ou dá contexto parcial? (Choice). O código conta as posições das fontes usadas e chega num indício.
Cinco fontes, um indício
Clique na posição para trocar. Arraste a relevância.
Afirmação: "A lei exige que um partido tenha cinco parlamentares para ir ao debate."
- planalto.gov.brfonte oficialusada
- aosfatos.orgchecagemusada
- poder360.com.brimprensausada
- folha.uol.com.brimprensadescartada
- g1.globo.comimprensadescartada
O botão “o que aconteceu” reproduz um erro real. O Zema disse que a lei exige cinco parlamentares para um partido ir ao debate, e o próprio texto da lei, no site do Planalto, foi lido como contradizendo. O artigo 46 da Lei 9.504 diz exatamente cinco. O resultado saiu como “fontes em conflito”, com pedido de revisão. Não publicou nada errado, mas mostrou o ponto fraco: o Jev lê só o trecho que o buscador devolveu, não a página inteira, e fonte oficial pesa o mesmo que notícia.
Quanto custa
Quanto custa checar um vídeo
11 vídeos reais de política, 2 a 8 min cada. Passe o mouse numa barra.
- Transcrição
- Jev
- LLM
- Busca web
Custo de API medido em cada rodada, sem cache. A busca web é quase fixa (6 consultas por vídeo); o que cresce com o vídeo é o Jev, puxado pela memória, que compara pares de afirmações.
Metade do custo varia pouco: a transcrição é proporcional à duração e a busca é quase fixa, seis consultas por vídeo. O que cresce é o Jev, e quase tudo dele vem da memória. Num vídeo de 8 minutos com 60 afirmações, a memória sozinha custa uns cinco centavos.
O tempo segue a mesma lógica. A análise leva em média um quarto da duração do vídeo, e os vídeos com mais afirmações são os mais lentos.
O serve: três saídas do mesmo JSON
O relatório JSON guarda todas as decisões, com confiança e custo de cada uma. Tudo que vem depois é leitura desse arquivo.
carregando o diagrama…
O app de curadoria
Um processo Node só, escutando em 127.0.0.1, serve a API, a interface em React e a fila. O estado mora inteiro num SQLite: cada vídeo da pauta tem um status, e o worker pega o próximo dentro de uma transação BEGIN IMMEDIATE, então o mesmo vídeo nunca sai da fila duas vezes.
carregando o diagrama…
Escolhi um vídeo por vez de propósito. Análise e render pesam na CPU e nas mesmas APIs, e paralelizar só trocaria velocidade por erro de limite de requisição.
O vídeo com cartões
O render pega o relatório e a transcrição e monta uma composição do HyperFrames, que é HTML animado com GSAP renderizado quadro a quadro. Legenda palavra por palavra, carimbo quando entra uma afirmação, cartão com as fontes quando ela foi checada.
O truque que tornou isso viável foi não colocar o vídeo original dentro do navegador. O HyperFrames desenha só a camada de cima, num WebM com fundo transparente, e o ffmpeg sobrepõe essa camada ao vídeo original. O Chrome nunca decodifica vídeo, e o render anda a uns 30 quadros por segundo, perto do tempo real. Com mais de um worker do HyperFrames no Windows, cada quadro vira um PNG de 8 MB em disco, o que estoura uns 15 GB por minuto. Fiquei com um worker em streaming direto para o encoder.
A página pública
O exportador enxuga o relatório, traduz os rótulos e guarda cada palavra como [início, fim, falante, palavra], que deixa o JSON pequeno. O Astro gera uma página por vídeo, servida como arquivo estático por um Worker da Cloudflare.
Na página, o player do YouTube toca o vídeo e um script acompanha o tempo dele pela IFrame API: quem está falando, a palavra dita, os carimbos, o placar e os cartões, com busca binária na lista de palavras a cada quadro. Se o leitor pula no vídeo, a fila de cartões é remontada em vez de animar tudo o que ficou para trás.
O que ainda está errado
Prefiro listar do que fingir que não existe.
- Não sei a acurácia de ponta a ponta. Os números de validação vêm de frases escritas à mão. Falta anotar 20 ou 30 vídeos reais com o veredito de um curador e medir contra isso. Sem esse gabarito, cada ajuste é no escuro.
- Revisar demais vira ruído. A maioria dos cartões sai com “confiança baixa”. Se quase tudo pede revisão, o rótulo não ajuda ninguém a priorizar.
- O Jev lê o resumo, não a página. O trecho do buscador às vezes vem truncado ou com lixo. Baixar a página, cortar em passagens e deixar o Jev escolher a melhor é a próxima mudança.
- Número parecido, assunto diferente. Os R$ 24,3 bilhões de supersalários de juízes não são o custo da Justiça do Trabalho, mas um trecho com esse número parece confirmar a afirmação. Falta uma pergunta explícita: “o número da fonte mede a mesma coisa?”.
- Opinião conta como fonte. Uma coluna de opinião da Folha entrou como fonte que confirma. Opinião não devia pesar como evidência.
Nenhum desses é um problema de arquitetura. São perguntas novas ao Jev, que cabem na mesma chamada, ou regras novas no código. Foi para isso que separei as coisas desde o começo.
Stack
| Parte | Escolha | Por quê |
|---|---|---|
| Linguagem | TypeScript no Node 24, rodando com tsx | O mesmo tipo do relatório vai da análise ao app e ao blog |
| Download | yt-dlp + ffmpeg | Funciona com quase qualquer link e reduz o áudio ao mínimo |
| Transcrição | ElevenLabs Scribe v2 (fallback: Whisper large-v3), via OpenRouter | Falantes por palavra, arquivo inteiro numa requisição |
| Decisões | TypeSafe Jev 1.13 | Respostas tipadas com confiança calibrada, ~300 ms, saída grátis |
| Texto | DeepSeek V4.1 Flash, via OpenRouter | Reescrita barata, com temperatura zero e cache |
| Busca | Tavily ou Exa + Google Fact Check | Filtro por domínio e checagens já publicadas |
| Fila e pauta | node:sqlite |
Sem servidor de banco; o estado sobrevive a um restart |
| App | React 19, TanStack Router e Query, Vite | Interface local para a curadoria |
| Vídeo | HyperFrames + GSAP + ffmpeg | Composição em HTML, sobreposta ao vídeo original |
| Blog | Astro em Cloudflare Workers | Estático, uma página por vídeo, player sincronizado |