Tubarões

O primeiro escolhido é o maior.

Padrão: 4 clássicos

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.

check.stater.dev ↗

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.

O caminho de um vídeo. Em azul o que o Jev decide, em amarelo o que um LLM escreve; o resto é código ou serviço externo.

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.

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.

0%50%100%67%0,0–0,550%0,5–0,760%0,7–0,996%0,9–1,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."

0.93
crítico
moderado
×1
  • 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.

  1. frases transcritastudo o que foi dito, cortado em frases
    911100%
  2. candidatasfactuais, ou de tipo incerto (confiança < 0,7)
    48053%
  3. factuais com certezaChoice do Jev: afirmação factual
    25728%
  4. verificáveisNoul "dá para conferir em fonte pública?" ≥ 0,7
    15617%
  5. pesquisadasacima do corte, por prioridade, até 6 buscas
    627%

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

10203040506001.0002.000teto: 60afirmações →
40
780chamadas ao Jev
US$ 0,024custo estimado
39 scom 6 em paralelo, ~300 ms cada

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

0.97
0.96
  1. ✓1ª passada: p(contradiz) ≥ 0.6abaixo disso, nem vale a segunda chamada
  2. ✓nenhum lado é citação de terceiroquem cita outra pessoa não está se contradizendo
  3. ✓os anos citados coincidemnúmeros de anos diferentes falam de períodos diferentes
  4. ✓não é pergunta e a resposta delavira "resposta", e o peso da pergunta muda
  5. ✓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 oficial
    usada
  • aosfatos.orgchecagem
    usada
  • poder360.com.brimprensa
    usada
  • folha.uol.com.brimprensa
    descartada
  • g1.globo.comimprensa
    descartada
falta contexto3 de 5 fontes usadas

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.

US$ 0,095média por vídeo
US$ 0,0150por minuto de vídeo
42%do custo é o Jev
  • Transcrição
  • Jev
  • LLM
  • Busca web
US$ 0,00US$ 0,07US$ 0,13Lula × Flávio: presosUS$ 0,130Flávio: pandemiaUS$ 0,127Lula × Flávio: VorcaroUS$ 0,125Lula × Flávio: sigiloUS$ 0,120Lula × Flávio: indultoUS$ 0,107Lula: cotasUS$ 0,094Lula × Flávio: palhaçoUS$ 0,088Lula: "preparado p/ mentir"US$ 0,079Cury na CNNUS$ 0,061Zema na CNNUS$ 0,056Renan no SBTUS$ 0,054

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.

Do JSON para quem usa. O app e o render rodam na minha máquina; o blog é estático e roda na borda da Cloudflare.

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.

Os estados de um vídeo na pauta. Se o app cair no meio, o que estava rodando volta para o começo da fila quando ele reinicia.

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