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 \
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
+21 -2
View File
@@ -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
+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.")
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()