diff --git a/TODO.md b/TODO.md index 170d2c6..f40adac 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,7 @@ rien n'est conservé, un seul utilisateur, vérification figée en français. - [ ] **Cache de vérification** : mémoriser le résultat d'un claim déjà vérifié (clé = texte normalisé) pour ne pas repayer un appel sur une affirmation identique. - [ ] **Webhook / notification sur claim "false"** : pousser une alerte (webhook configurable) quand un fait est démenti, pour intégration externe (overlay OBS, Slack…). - [ ] **Stats agrégées par session** : ratio vrai/faux/incertain, catégorie dominante, taux de recours au web — exposé en fin de session et dans l'admin. -- [ ] **Multilingue** : rendre `language` (transcription) + le prompt/catégories configurables par session au lieu du français figé. +- [ ] **Multilingue (prompt Claude)** : la transcription tourne toujours en auto-détection ; la langue choisie par session sert de *filtre* (les chunks d'une autre langue sont ignorés, voir `core/languages.py` + `ConfigMessage` + le filtre dans `session.py`). Reste à adapter `SYSTEM_PROMPT` et l'enum de catégories de `claim_extractor.py` à la langue de la session — actuellement figés FR, donc les claims sortent en français même pour un audio non francophone. - [ ] **Niveau de vérification réglable** : exposer côté contrat un mode « rapide » (connaissances internes seules) vs « approfondi » (web_search systématique), au lieu du `web_search=auto` actuel. ## Tests (priorité haute) @@ -39,7 +39,7 @@ Aujourd'hui seul `tests/test_claim_extractor.py` existe. Manquent : ## Architecture & dette - [ ] `_active_sessions` est un dict global au niveau module : OK pour un process unique, mais à documenter comme limite (ne survit pas à plusieurs workers / un restart). -- [ ] Transcription figée en français (`language="fr"`) + prompt/catégories FR : si le multilingue est visé un jour, le rendre configurable. +- [ ] Transcription : toujours en auto-détection ; la langue par session filtre les chunks (cf. ci-dessus). Le prompt/catégories de fact-checking restent figés FR — voir la ligne « Multilingue (prompt Claude) ». ## Observabilité diff --git a/app/core/languages.py b/app/core/languages.py new file mode 100644 index 0000000..df795aa --- /dev/null +++ b/app/core/languages.py @@ -0,0 +1,30 @@ +"""Whisper transcription language codes — the validation source of truth. + +faster-whisper ships the canonical set of supported ISO codes in its tokenizer. +We expose it here so the WebSocket config schema can validate a requested +language without re-deriving the list. The frontend mirrors these codes. +""" + +from faster_whisper.tokenizer import _LANGUAGE_CODES + +# Sentinel meaning "let Whisper auto-detect the language per chunk". Kept distinct +# from a real ISO code so callers can branch on it explicitly. +AUTO_LANGUAGE = "auto" + +# Frozen copy so callers can't mutate faster-whisper's internal set. +SUPPORTED_LANGUAGE_CODES: frozenset[str] = frozenset(_LANGUAGE_CODES) + + +def normalize_language(language: str | None) -> str | None: + """Map a requested language to a value for ``WhisperModel.transcribe``. + + Returns ``None`` for auto-detection (the ``"auto"`` sentinel, an empty value, + or an unknown code), and the code itself when it is a supported ISO language. + Unknown codes fall back to auto rather than raising — a bad client value + shouldn't break a live session. + """ + if not language or language == AUTO_LANGUAGE: + return None + if language in SUPPORTED_LANGUAGE_CODES: + return language + return None diff --git a/app/schemas/claim.py b/app/schemas/claim.py index 083edf6..9cfd234 100644 --- a/app/schemas/claim.py +++ b/app/schemas/claim.py @@ -36,8 +36,23 @@ class Claim(ClaimBase): class TranscriptMessage(BaseModel): type: str = "transcript" text: str + # The language Whisper detected for this chunk and its probability. + # Always present: transcription always runs in auto-detect mode. + language: str + language_probability: float class ClaimMessage(BaseModel): type: str = "claim" claim: Claim + + +class ConfigMessage(BaseModel): + """Client → server session config (sent as a text frame on the socket). + + ``language`` is the ``"auto"`` sentinel or an ISO code; it is normalized + against the supported set when applied (an unknown code falls back to auto). + """ + + type: str = "config" + language: str diff --git a/app/services/session.py b/app/services/session.py index 9955f64..be7cff8 100644 --- a/app/services/session.py +++ b/app/services/session.py @@ -6,15 +6,18 @@ """ import asyncio +import json import logging import time import uuid from fastapi import WebSocket +from pydantic import ValidationError from starlette.websockets import WebSocketDisconnect from app.config import settings -from app.schemas.claim import Claim, VerificationStatus +from app.core.languages import normalize_language +from app.schemas.claim import Claim, ConfigMessage, VerificationStatus from app.services.claim_extractor import MIN_WORDS, extract_and_verify from app.services.transcription import transcribe_chunk @@ -36,6 +39,7 @@ def get_sessions_status() -> dict: "claims_spawned": s["claims_spawned"], "active_tasks": len(s["_tasks"]), "last_transcript": s["last_transcript"], + "language": s["language"] or "auto", "idle_s": round(now - s["last_activity"]), } for s in _active_sessions.values() @@ -106,6 +110,27 @@ def _spawn_claims( task.add_done_callback(background_tasks.discard) +def parse_config_language(raw: str) -> str | None: + """Extract a normalized transcription language from a client config frame. + + ``raw`` is the text payload of a WebSocket frame. Returns the language to use + (``None`` for auto-detect, or a supported ISO code), or raises ``ValueError`` + if the frame isn't a valid ``config`` message — the caller logs and ignores + it rather than dropping the session. + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"invalid JSON: {e}") from e + if not isinstance(data, dict) or data.get("type") != "config": + raise ValueError("not a config message") + try: + msg = ConfigMessage.model_validate(data) + except ValidationError as e: + raise ValueError(f"invalid config message: {e}") from e + return normalize_language(msg.language) + + async def run_session(ws: WebSocket): """Drive one WebSocket connection: receive chunks, transcribe, fact-check. @@ -131,6 +156,9 @@ async def run_session(ws: WebSocket): "claims_spawned": 0, "_tasks": background_tasks, "last_transcript": "", + # Transcription language: None = auto-detect (the default until the + # client sends a config frame), or a forced ISO code. + "language": None, "last_activity": time.time(), } _active_sessions[session_id] = session_info @@ -142,6 +170,21 @@ async def run_session(ws: WebSocket): if message.get("type") == "websocket.disconnect": break + # Text frames carry session config (e.g. the chosen language); binary + # frames carry audio. A malformed config is logged and ignored. + config = message.get("text") + if config is not None: + try: + session_info["language"] = parse_config_language(config) + logger.info( + "WS session %s language set to %s", + session_id[:8], + session_info["language"] or "auto", + ) + except ValueError as e: + logger.warning("Ignoring config frame: %s", e) + continue + audio = message.get("bytes") if not audio: continue @@ -159,8 +202,12 @@ async def run_session(ws: WebSocket): session_info["chunks_received"] += 1 session_info["last_activity"] = time.time() + # Always transcribe in auto-detect: forcing a language would make + # Whisper translate a mismatched chunk instead of transcribing it. try: - transcript = await loop.run_in_executor(None, transcribe_chunk, audio) + transcript, detected_lang, detected_prob = await loop.run_in_executor( + None, transcribe_chunk, audio + ) except Exception as e: logger.error("Transcription error: %s", e) continue @@ -168,12 +215,30 @@ async def run_session(ws: WebSocket): if not transcript: continue + # The chosen language is a filter, not a forced transcription target: + # drop a chunk whose detected language doesn't match (None = accept all). + language = session_info["language"] + if language is not None and detected_lang != language: + logger.info( + "Skipping chunk: detected %s, session filter is %s", + detected_lang, + language, + ) + continue + session_info["transcripts"] += 1 session_info["last_transcript"] = transcript[:120] session_info["last_activity"] = time.time() logger.info(transcript) - await ws.send_json({"type": "transcript", "text": transcript}) + await ws.send_json( + { + "type": "transcript", + "text": transcript, + "language": detected_lang, + "language_probability": detected_prob, + } + ) _spawn_claims(ws, transcript, background_tasks, session_info) except (WebSocketDisconnect, RuntimeError): diff --git a/app/services/transcription.py b/app/services/transcription.py index 7a65ed0..ab3a496 100644 --- a/app/services/transcription.py +++ b/app/services/transcription.py @@ -74,19 +74,28 @@ def transcribe_with_detail(audio: bytes) -> dict: return {"error": str(e), "text": "", "segments": []} -def transcribe_chunk(audio: bytes) -> str: +def transcribe_chunk(audio: bytes) -> tuple[str, str, float]: """Transcribe a self-contained audio chunk into plain text. Accepts raw encoded audio bytes (e.g. a complete WebM/Opus blob, decoded via ffmpeg). Synchronous and CPU-bound — call it from a thread pool. + + Always auto-detects the language: forcing a non-matching language makes + Whisper translate/hallucinate into that language rather than transcribe + phonetically (there's no "transcribe verbatim" knob). The detected language + is returned so the caller can filter on it instead. + + Returns ``(text, detected_language, detected_probability)``; on error the + text is empty and the language fields are ``("", 0.0)``. """ try: - segments, _ = _get_model().transcribe( + segments, info = _get_model().transcribe( io.BytesIO(audio), - language="fr", + language=None, vad_filter=True, ) - return "".join(seg.text for seg in segments).strip() + text = "".join(seg.text for seg in segments).strip() + return text, info.language, round(info.language_probability, 3) except Exception as e: logger.error("Whisper transcription error: %s", e) - return "" + return "", "", 0.0 diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 0000000..9fb98e8 --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,21 @@ +"""Tests for transcription language normalization.""" + +from app.core.languages import AUTO_LANGUAGE, normalize_language + + +def test_auto_sentinel_maps_to_none() -> None: + assert normalize_language(AUTO_LANGUAGE) is None + + +def test_empty_or_missing_maps_to_none() -> None: + assert normalize_language("") is None + assert normalize_language(None) is None + + +def test_known_code_passes_through() -> None: + assert normalize_language("fr") == "fr" + assert normalize_language("en") == "en" + + +def test_unknown_code_falls_back_to_auto() -> None: + assert normalize_language("klingon") is None diff --git a/tests/test_session_config.py b/tests/test_session_config.py new file mode 100644 index 0000000..32cec28 --- /dev/null +++ b/tests/test_session_config.py @@ -0,0 +1,32 @@ +"""Tests for the WebSocket config-frame parsing in session.py.""" + +import pytest + +from app.services.session import parse_config_language + + +def test_forced_language_is_normalized() -> None: + assert parse_config_language('{"type": "config", "language": "en"}') == "en" + + +def test_auto_maps_to_none() -> None: + assert parse_config_language('{"type": "config", "language": "auto"}') is None + + +def test_unknown_language_falls_back_to_auto() -> None: + assert parse_config_language('{"type": "config", "language": "klingon"}') is None + + +def test_invalid_json_raises() -> None: + with pytest.raises(ValueError): + parse_config_language("not json") + + +def test_wrong_type_raises() -> None: + with pytest.raises(ValueError): + parse_config_language('{"type": "transcript", "text": "hi"}') + + +def test_missing_language_raises() -> None: + with pytest.raises(ValueError): + parse_config_language('{"type": "config"}') diff --git a/tests/test_transcription.py b/tests/test_transcription.py new file mode 100644 index 0000000..d02694c --- /dev/null +++ b/tests/test_transcription.py @@ -0,0 +1,42 @@ +"""Tests for transcribe_chunk, with the Whisper model mocked. + +No real audio or model: we patch ``_get_model`` so the test stays offline and +fast, and assert on what gets passed to ``.transcribe`` and what comes back. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.services import transcription + + +@pytest.fixture +def mock_model(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Patch _get_model to return a model whose .transcribe is controllable.""" + model = MagicMock() + segments = [SimpleNamespace(text="Hello "), SimpleNamespace(text="world.")] + info = SimpleNamespace(language="en", language_probability=0.987) + model.transcribe.return_value = (iter(segments), info) + monkeypatch.setattr(transcription, "_get_model", lambda: model) + return model + + +def test_always_auto_detects_and_reports(mock_model: MagicMock) -> None: + text, lang, prob = transcription.transcribe_chunk(b"audio") + + # Transcription always runs in auto mode (language=None) so a mismatched + # chunk is never translated; the detected language is surfaced for filtering. + assert mock_model.transcribe.call_args.kwargs["language"] is None + assert text == "Hello world." + assert lang == "en" + assert prob == 0.987 + + +def test_error_returns_empty_triple(monkeypatch: pytest.MonkeyPatch) -> None: + model = MagicMock() + model.transcribe.side_effect = RuntimeError("boom") + monkeypatch.setattr(transcription, "_get_model", lambda: model) + + assert transcription.transcribe_chunk(b"audio") == ("", "", 0.0)