"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt, quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la relecture humaine reste le seul contenu conservé, dans l'OT (D1). """ import os import tempfile from typing import Protocol class Transcripteur(Protocol): def transcrire(self, audio: bytes, extension: str) -> str: ... class TranscripteurLocal: """faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement, jamais importé en test. Modèle et langue forcée en français : le mélange français/darija du terrain reste un point de vigilance non calibré (pas de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur échantillons réels si la qualité déçoit en recette.""" def __init__(self, modele: str) -> None: from faster_whisper import WhisperModel # import différé (dépendance optionnelle) self._modele = WhisperModel(modele, device="cpu", compute_type="int8") def transcrire(self, audio: bytes, extension: str) -> str: fd, chemin = tempfile.mkstemp(suffix=f".{extension}") try: with os.fdopen(fd, "wb") as f: f.write(audio) segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True) return " ".join(segment.text.strip() for segment in segments).strip() finally: os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel class TranscripteurDeterministe: """Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un texte stable dérivé de la taille du fichier (même interface).""" def transcrire(self, audio: bytes, extension: str) -> str: return f"transcription déterministe ({len(audio)} octets, .{extension})" def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None: """None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) — l'appelant doit alors refuser proprement (503), pas planter.""" if mode == "off": return None if mode == "deterministe": return TranscripteurDeterministe() return TranscripteurLocal(modele)