fix(voix): refuser les lectures Gemini qui sautent des mots

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-28 09:25:43 +00:00
1 parent d4c544e467
commit 7c38ec459f
3 files changed
+34 -2

No files matched your search

+2
View File
@@ -30,6 +30,8 @@ ENV WHISPER_MODEL=${WHISPER_MODEL} \
HOME=/tmp \ HOME=/tmp \
XDG_CACHE_HOME=/tmp/.cache XDG_CACHE_HOME=/tmp/.cache
RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')" 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 main.py .
COPY app ./app COPY app ./app
+21 -2
View File
@@ -9,7 +9,7 @@ pas rogner une syllabe.
import asyncio import asyncio
import difflib import difflib
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -17,16 +17,21 @@ import numpy as np
from . import proc from . import proc
from .align import Word, normalize from .align import Word, normalize
MIN_COVERAGE = 0.92
@dataclass @dataclass
class ReadingCheck: class ReadingCheck:
coverage: float # part des mots attendus effectivement entendus coverage: float # part des mots attendus effectivement entendus
extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte
ending_ok: bool # la fin du texte est bien lue ending_ok: bool # la fin du texte est bien lue
missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic)
@property @property
def acceptable(self) -> bool: 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: def describe(self) -> str:
parts = [f"{self.coverage:.0%} des mots lus"] parts = [f"{self.coverage:.0%} des mots lus"]
@@ -34,6 +39,8 @@ class ReadingCheck:
parts.append("fin tronquée") parts.append("fin tronquée")
if self.extra > 0.25: if self.extra > 0.25:
parts.append("mots ajoutés") parts.append("mots ajoutés")
if self.missing and self.coverage < MIN_COVERAGE:
parts.append("non entendus : " + " ".join(self.missing[:12]))
return ", ".join(parts) return ", ".join(parts)
@@ -59,9 +66,21 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
coverage=matched / len(expected), coverage=matched / len(expected),
extra=max(0, len(heard) - matched) / len(expected), extra=max(0, len(heard) - matched) / len(expected),
ending_ok=tail_matched >= min(4, len(expected) - tail_start), 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 # Enveloppe sonore : niveau de la voix toutes les 10 ms
FRAME = 0.01 FRAME = 0.01
# Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil # Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil
+11
View File
@@ -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.") heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.")
check = check_reading(expected, heard) check = check_reading(expected, heard)
assert check.acceptable, check 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()