Prototype de fact-checking en direct des discours TNT. Rien n’est publié sur X. Les cartes restent formulées « il a été affirmé à telle heure sur telle chaîne ». Le transcript affiche le nom quand le STT l’identifie.
L’interface
Cinq pages, toujours accessibles en haut à gauche.
- Salle — Start / Stop, transcript, affirmations.
- Tableau de bord — archives des directs déjà enregistrés.
- Sources — liste blanche prioritaire pour Gemini.
- Prompts — consignes du preflight et du fact-check, éditables.
- Aide — cette page.
Salle de rédaction
C’est le poste de travail. Deux colonnes : le transcript à gauche, les affirmations à droite. Chaque colonne défile toute seule.
Observateur
La salle montre le même moteur que Philippe pilote par webhook. Start / Stop / Statut dans la barre du haut font la même chose que ses URLs. L’onglet sert aussi à voir transcript, cartes, mails et JSON. Les directs déjà captés restent dans le tableau de bord.
LLM
Gemini fait le preflight et la recherche. Il faut une clé API_GEMINI dans env.local.
Transcript
Chaque ligne est un extrait horodaté. Le flux de Philippe envoie deux pistes :
- CC — sous-titres chaîne (parfois incomplets, parfois absents).
- STT — transcription audio, en général plus utile pour le fact-check.
Le bouton CC affiche ou masque les sous-titres. Le moteur fact-checke le STT ; le CC part en contexte (noms, chiffres), pas comme une 2ᵉ source d’affirmations. Détail du décalage et des délais : Timing.
Chaque ligne STT porte le locuteur s’il est identifié (pastille dans le transcript). unknown devient Animateur : journaliste / plateau. On ne lance le fact-check que si au moins un invité nommé parle dans la fenêtre. Animateur seul = rien, ni preflight ni search.
Tu peux sélectionner des extraits consécutifs (glisser, ou Maj+clic) pour forcer un fact-check manuel, même si le preflight n’a rien sorti — à condition qu’un locuteur y soit reconnu.
Affirmations
Une carte = une affirmation reformulée, puis le verdict et l’explication. En pied : heure, durée d’analyse, tokens in/out, coût.
- Cliquer une carte (hors boutons et liens) fait défiler le transcript jusqu’aux extraits STT. Ça n’ouvre pas les CC.
- JSON : affiche le payload d’export (le même que le POST
/truthet la pièce jointe du mail). Copiable. - Valider / Rejeter : le bouton choisi se remplit (vert ou rouge). L’autre reste cliquable si tu changes d’avis.
- Les pastilles en haut à droite comptent les cartes prêtes et celles encore en analyse. Tant que Gemini travaille sans carte, ça reste un simple « en analyse » — sans chiffre qui clignote.
Direct partagé
On ne pilote pas le tuner. Philippe lance la capture TNT de son côté, puis dit au moteur quand fact-checker. Tous les visiteurs de la salle voient la même session serveur.
- Ouvrir ou fermer l’onglet ne démarre ni n’arrête le fact-check. Start / Stop dans la salle, oui.
- Pas de siège, pas de « tuner occupé ».
- Les émissions déjà enregistrées se relisent depuis le tableau de bord, pas depuis la salle.
Webhook Philippe
Serveur à serveur, token dans WEBHOOK_TOKEN (header X-Webhook-Token, Authorization: Bearer ou ?token=).
POSTouGET /api/hook/start— arme le fact-check. S’il y a un live TNT, le moteur s’y branche tout de suite. Sinon il attend le live, puis s’y colle. JSON optionnel :channel_id,channel_name.POSTouGET /api/hook/stop— coupe le moteur. Un live TNT qui continue n’est plus suivi.GET /api/hook/status—armed,status,message.POST /api/hookavec{"action":"start"|"stop"|"status"}fait la même chose.
Dans la salle, Start / Stop / Statut appellent les mêmes actions sans token : POST /api/session/enable, POST /api/session/disable, GET /api/session/status.
Une fois armé, le suivi du live LEL est automatique (mails + JSON vers /truth). La salle affiche veille tant que personne n’a envoyé Start (bouton ou webhook).
Un redémarrage du serveur (déploiement) désarme le moteur : il faut un nouveau Start.
Livraison
Chaque nouvelle carte (pas une mise à jour) part de deux façons, sans bloquer la salle si ça échoue :
- POST JSON vers
https://stt.leselectronslibres.com/truth(TRUTH_URL). Le code HTTP, 404 compris, est ignoré. - Mail Brevo à
MAIL_TO: uniquement l’encart HTML (comme la carte), plus le JSON en pièce jointefactcheck-{id}.json. Pas de corps texte au-dessus. En live, seulement les 3 premières cartes partent par mail (MAIL_PER_LIVE) : assez pour confirmer que c’est lancé, sans inonder la boîte.
Schéma : lel.media/factcheck v1. Fuseau Europe/Paris. Dedans : affirmation, locuteur, verdict, sources, extraits, tokens, search_enabled / search_used / search_queries, coût. search_queries: 0 veut dire que Gemini n’a pas lancé Search (souvent de la mémoire), pas que le compteur est cassé.
Tableau de bord
Vue d’ensemble des directs persistés : extraits, affirmations, tokens, coût. Ouvre une émission pour voir les cartes. Les heures sont en Europe/Paris.
Les encarts de verdict (Tous, Vrai, Faux…) filtrent la liste en dessous. Tous est sélectionné par défaut.
Seuls les directs TNT sont enregistrés. Un replay d’archive ne crée pas une nouvelle ligne.
Sources
Catalogue des bases que Gemini doit chercher en priorité : INSEE, OCDE, Eurostat, ministères, Parlement, Cour des comptes, data.gouv.fr, Our World in Data, etc.
- Désactiver une source la retire du prompt, sans l’effacer.
- Les sources du catalogue ne se suppriment pas (sinon elles reviendraient au redémarrage). On les désactive.
- Tu peux en ajouter (presse, think tank, etc.). Celles-là, on peut les supprimer.
Ce n’est pas un mur étanche : si aucune source de la liste ne couvre le sujet, Gemini peut élargir. La presse peut contextualiser, elle ne doit pas être la source principale quand une base institutionnelle existe.
Prompts
Page Prompts : les deux consignes envoyées à Gemini. On peut les lire, les modifier, les enregistrer, ou revenir au texte d’origine.
- Preflight — « y a-t-il une affirmation vérifiable ? » Sans search. Variables :
{channel},{when},{transcript}. Le CC et la liste des voix sont injectés même si{captions}/{voices}manquent. - Fact-check — vérifie la phrase reformulée avec Google Search. Variables :
{source_list},{claim},{context},{channel},{when}(+ CC et voix injectés).
Un prompt enregistré est utilisé dès le prochain appel. Si tu supprimes une variable, l’enregistrement est refusé.
La technique
Moteur Python (FastAPI) dans src/factcheck/. UI locale, SQLite par défaut. L’objectif n’est pas un bus de messages : un SSE suffit pour le live.
Qui fait quoi
- Philippe — tuner TNT, CCExtractor, API de transcription, identification vocale, webhook start/stop, réception
/truth, bot X plus tard. - Thierry — ce moteur : buffer, preflight, fact-check, sources, salle, mails.
Le contrat V0 : le moteur renvoie un résultat structuré (affirmation, chaîne, heure, locuteur, verdict, explication, sources, confiance). Philippe allume et coupe via webhook ; le JSON part vers /truth ; la carte part par mail. Toujours rien sur X.
Flux TNT
Base : https://beta.leselectronslibres.com
GET /ui/capture/state— live en cours ou non (running, chaîne).GET /ui/live/stream?replay=100— SSE des extraits. Événementscc,stt(final uniquement),session. On s’y abonne, on ne lance pas/ui/capture. Si le flux se tait (tunnel, timeout), on se reconnecte tout seul.GET /ui/live/frame— JPEG de l’antenne (~10 s). Proxifié en/api/live/frame.GET /ui/speakers— roster des voix que le STT peut nommer (injecté dans les prompts).
Le parseur (ingest.py) tolère plusieurs formes de JSON. Si l’API chaînes est down, l’UI bascule sur une liste figée (BFM, CNEWS, etc.) : le live peut quand même marcher.
Pipeline, de bout en bout
- Start (salle ou webhook Philippe) → statut LEL (
/ui/capture/state) → si live, abonnement SSE → affichage dans la salle. - Buffer local (pas de LLM) — recolle les extraits en fenêtres.
- Locuteur — s’il n’y a que l’animateur /
unknown, on s’arrête (pas de LLM). - Preflight —
gemini-3.5-flash-lite, sans search : 0 à 2 affirmations vérifiables, ou rien. - Dédoublonnage local — on jette les paraphrases déjà vues.
- Fact-check —
gemini-3.8-flash+ Google Search, seulement s’il reste une affirmation. - Carte à l’écran, SQLite si c’est un direct, POST
/truth, mail HTML + JSON joint.
Deux appels LLM peuvent tourner en parallèle (sémaphore). Le transcript continue d’arriver pendant que Gemini travaille. Les durées : Timing.
Timing : de la phrase à la carte
L’affichage du transcript est immédiat. L’attente ne concerne que le déclenchement du LLM. Les durées ci-dessous sont celles du code actuel, pas une promesse de SLA.
Étapes, dans l’ordre
| Étape | Durée | Ce qui se passe |
|---|---|---|
| Tuner / WhisperLive | ~30 s, une fois | Avant le premier extraits. Le hint de la salle. Ensuite le flux est continu. |
| Affichage STT / CC | 0 s | Chaque extraits SSE s’affiche tout de suite. Rien n’est retenu à l’écran. |
| Buffer STT | ~6–15 s de parole | 3 extraits + fin de phrase (ou 280 car.). Filets : 720 car. ou 6 s de silence. Voir Buffer. |
| Hold CC | 0–4 s | On attend le CC de la même plage de parole (start_at–end_at, ±3 s). Le at STT est l’heure de validation WhisperLive, pas l’heure où c’est dit : sans ces champs, on collerait le bandeau de la mauvaise phrase. 0 s si le CC est déjà là ; 4 s max (mesuré : toujours arrivé en ≤ 3 s). |
| Preflight Lite | ~1–4 s | Sans search. Souvent zéro affirmation : on s’arrête là. |
| Fact-check Flash + search | ~8–20 s | Seulement s’il reste une phrase. Deux analyses max en parallèle. C’est en général le plus long. |
De la phrase dite à la carte
| Parcours | Après la fin de la fenêtre STT | Après le mot dit |
|---|---|---|
| CC déjà aligné | ~10–25 s (LLM seul) | ~20–40 s |
| Cas courant | ~15–35 s | ~25–45 s |
| Pire cas (hold 4 s + search lent) | ~20–25 s | ~35–45 s |
Le hold n’ajoute pas 4 s à chaque fois : c’est un plafond. Une sélection manuelle saute le buffer et le hold (le CC voisin est collé sur la même plage de parole).
Où on a de la marge
Réglages dans buffer.py (CAPTION_WAIT, CAPTION_SLACK, seuils du buffer) et le sémaphore LLM dans pipeline.py.
- Buffer plus petit — 2 extraits au lieu de 3, ou moins de 280 car. Plus réactif, plus de fenêtres, plus de risque de miettes au preflight.
- Preflight sans attendre le CC — lancer Lite tout de suite, n’attendre le CC que s’il y a une affirmation. Plus complexe, économise 1–4 s quand le preflight aurait dit non.
- Mesurer pour de vrai — chaque carte a déjà une latence. On n’a pas encore de médiane hold / Lite / search : c’est le prochain indicateur utile au tableau de bord.
- Search — le plus gros morceau une fois le hold réglé. Moins de requêtes, ou un modèle plus court, si le coût / le délai posent problème.
Buffer : recomposer des phrases
Le STT arrive en miettes. On n’envoie pas chaque miette au modèle. TranscriptBuffer agglomère sans inventer de ponctuation :
- minimum 3 extraits (pas 3 phrases dans une seule ligne) ;
- on coupe de préférence en fin de phrase (
. ? ! …) ; - on ne coupe pas si l’extrait commence par une continuation (« mais », « donc », « parce que », « du coup »…) ;
- prêt aussi si le texte dépasse 280 caractères tout en ayant 3 extraits ;
- filets : 720 caractères max, ou 6 secondes sans nouvel extrait ;
- pas de recouvrement entre fenêtres (
overlap = 0) ; - bornes de la fenêtre :
min(start_at)→max(end_at)des extraits STT (à défaut leatde validation). Le CC est collé dans cette plage ±3 s.
Ce n’est pas un parseur syntaxique. C’est un regroupement pour que le preflight voie un bout de discours, pas une miette.
Preflight Flash Lite
Avant tout search, gemini-3.5-flash-lite reçoit la fenêtre et répond à : ce texte est-il fact-checkable ?
Il ne doit sortir que des faits objectivement vérifiables : chiffres, dates, comparaisons mesurables. Il écarte opinions, slogans, promesses, questions de journalistes. Chaque affirmation est reformulée en une phrase autonome (pronoms résolus, STT corrigé via le CC s’il est là, 15–30 mots). C’est cette phrase qui sera vérifiée, pas le brut.
Réponse vide = on s’arrête. Coût typique : 0,30 $ / M tokens in, 2,50 $ / M out. Pas de Google Search à cette étape.
Une sélection manuelle contourne le preflight : on envoie le texte choisi tel quel au fact-check, seulement s’il y a un locuteur reconnu.
Fact-check + search
gemini-3.8-flash avec l’outil Google Search. Le prompt lui donne la liste des sources actives et lui demande de les prioriser.
Gemini n’accepte pas JSON structuré et search en même temps. On laisse le modèle parler, on parse, et s’il dérape on relance un petit appel « réécris ça en JSON strict » (sans search).
Les URL du grounding sont fusionnées avec celles du JSON, les domaines de la liste blanche passent devant.
Tarif intro 3.8 Flash (jusqu’au 31/12/2026, thinking inclus) : 0,75 $ / M in, 3,75 $ / M out.
Dédoublonnage
Même chiffre répété dix fois à l’antenne ≠ dix cartes. ClaimMemory compare les phrases :
- égalité après normalisation (accents, casse) ;
- ou Jaccard des mots ≥ 0,68 ;
- ou mêmes chiffres et Jaccard ≥ 0,45.
La mémoire vit le temps de la session. Un nouveau direct repart de zéro.
Verdicts et relecture
Échelle : vrai, plutôt vrai, mixte, plutôt faux, faux, insuffisant.
Si le verdict est insuffisant ou si la confiance est sous 70 %, la carte passe en « à relire ». Valider / Rejeter est humain, local à la session en cours.
Persistance
SQLite dans data/factcheck.db (ou PostgreSQL si DATABASE_URL). Tables : recordings, segments, checks, data_sources.
Un direct ouvert reste ouvert tant que la session n’est pas coupée (ou le process tué). Relancer uvicorn coupe le live en mémoire et remet le moteur en veille ; les extraits déjà écrits restent. Détail des envois : Livraison.
Coûts affichés
Chaque carte montre tokens in/out, le nombre de requêtes Google Search réellement lancées, et une estimation en dollars (thinking compris + search). Gemini 3 facture chaque requête du modèle, pas l’appel API : une carte peut coûter 2 ou 3 recherches. Tarif : 14 $ / 1 000 requêtes. L’UI affiche ce tarif unitaire ; Google offre 5 000 recherches / mois, donc la facture réelle peut être plus basse en début de période.
Le tableau de bord agrège par émission. Le preflight Lite n’ouvre pas le search : d’où le filtre avant le grounding.
Limites de la V0
- Pas de publication X. Le JSON part déjà vers Philippe, et la carte part aussi par mail. Le nom du locuteur s’affiche dans la salle ; les cartes restent formulées sans « untel a dit ».
- Le STT n’est pas parfait. Le CC est aligné sur la plage de parole (
start_at/end_at), pas sur l’heure de validation WhisperLive (voir Timing). - Le search grounding n’est pas une preuve. Un LLM peut mal lire une source ou inventer une nuance.
- Si le STT n’identifie personne, rien n’est fact-checké — même une affirmation chiffrée de l’animateur.
- Sans Start (salle ou webhook), un live TNT tout seul n’allume pas le fact-check (voir Webhook Philippe).
- Relancer le serveur = perdre la session courante et revenir en veille (pas l’archive déjà écrite).
- Toutes les émissions n’envoient pas de CC : le bouton CC peut rester vide, ce n’est pas forcément une panne.
Identification vocale côté Philippe : le flux STT envoie speaker (nom, ou unknown = animateur). Le roster vient de /ui/speakers. Sans nom d’invité, Gemini n’est pas appelé.