From 7c38ec459faf07eb254a97d381541177b7e63ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:25:43 +0000 Subject: [PATCH] fix(voix): refuser les lectures Gemini qui sautent des mots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contrôle lettre à lettre acceptait une lecture dès 85 %, alors qu'une lecture complète atteint ~97 % (petites erreurs de Whisper) : une lecture à 86 % (mots sautés) passait sans nouvelle génération. Seuil porté à 92 %. Les mots non entendus sont nommés dans les journaux et l'avertissement, pour savoir ce qui manquait. Le modèle Whisper, déjà dans l'image, est chargé sans interroger Hugging Face (HF_HUB_OFFLINE). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4 --- ffmpeg-service/Dockerfile | 2 ++ ffmpeg-service/app/quality.py | 23 +++++++++++++++++++++-- ffmpeg-service/tests/test_quality.py | 11 +++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ffmpeg-service/Dockerfile b/ffmpeg-service/Dockerfile index 6e466c4..9dac893 100644 --- a/ffmpeg-service/Dockerfile +++ b/ffmpeg-service/Dockerfile @@ -30,6 +30,8 @@ ENV WHISPER_MODEL=${WHISPER_MODEL} \ HOME=/tmp \ XDG_CACHE_HOME=/tmp/.cache RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')" +# Modèle déjà présent : plus aucune requête vers Hugging Face au démarrage +ENV HF_HUB_OFFLINE=1 COPY main.py . COPY app ./app diff --git a/ffmpeg-service/app/quality.py b/ffmpeg-service/app/quality.py index 6fb55b5..d594f19 100644 --- a/ffmpeg-service/app/quality.py +++ b/ffmpeg-service/app/quality.py @@ -9,7 +9,7 @@ pas rogner une syllabe. import asyncio import difflib -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import numpy as np @@ -17,16 +17,21 @@ import numpy as np from . import proc from .align import Word, normalize +MIN_COVERAGE = 0.92 + @dataclass class ReadingCheck: coverage: float # part des mots attendus effectivement entendus extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte ending_ok: bool # la fin du texte est bien lue + missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic) @property def acceptable(self) -> bool: - return self.coverage >= 0.85 and self.extra <= 0.25 and self.ending_ok + # Comparaison lettre à lettre : une lecture complète atteint ~97 % + # (petites erreurs de Whisper) ; sous 92 %, des mots ont été sautés. + return self.coverage >= MIN_COVERAGE and self.extra <= 0.25 and self.ending_ok def describe(self) -> str: parts = [f"{self.coverage:.0%} des mots lus"] @@ -34,6 +39,8 @@ class ReadingCheck: parts.append("fin tronquée") if self.extra > 0.25: parts.append("mots ajoutés") + if self.missing and self.coverage < MIN_COVERAGE: + parts.append("non entendus : " + " ".join(self.missing[:12])) return ", ".join(parts) @@ -59,9 +66,21 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck: coverage=matched / len(expected), extra=max(0, len(heard) - matched) / len(expected), ending_ok=tail_matched >= min(4, len(expected) - tail_start), + missing=_missing_words(expected_text, spoken), ) +def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]: + """Mots attendus sans équivalent proche dans ce qui a été entendu.""" + heard = {normalize(w.text) for w in spoken} + missing = [] + for word in expected_text.split(): + token = normalize(word) + if token and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard): + missing.append(word.strip(".,!?;:…")) + return missing + + # Enveloppe sonore : niveau de la voix toutes les 10 ms FRAME = 0.01 # Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil diff --git a/ffmpeg-service/tests/test_quality.py b/ffmpeg-service/tests/test_quality.py index cf2fdf6..f1b6958 100644 --- a/ffmpeg-service/tests/test_quality.py +++ b/ffmpeg-service/tests/test_quality.py @@ -60,3 +60,14 @@ def test_small_recognition_errors_are_not_missing_words(): heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.") check = check_reading(expected, heard) assert check.acceptable, check + + +def test_skipped_words_are_detected_and_named(): + expected = ( + "Pour Halloween découvrez nos costumes nos bonbons et toute notre décoration effrayante en magasin." + ) + heard = _heard("Pour Halloween découvrez nos costumes et toute notre décoration en magasin.") + check = check_reading(expected, heard) + assert not check.acceptable + assert "bonbons" in check.missing and "effrayante" in check.missing + assert "non entendus" in check.describe()