Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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é

Expand Down
30 changes: 30 additions & 0 deletions app/core/languages.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions app/schemas/claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 68 additions & 3 deletions app/services/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand All @@ -159,21 +202,43 @@ 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

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):
Expand Down
19 changes: 14 additions & 5 deletions app/services/transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions tests/test_session_config.py
Original file line number Diff line number Diff line change
@@ -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"}')
42 changes: 42 additions & 0 deletions tests/test_transcription.py
Original file line number Diff line number Diff line change
@@ -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)
Loading