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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ JWT_SECRET=change-me-too-long-random-string
JWT_EXPIRE_HOURS=12

AUTO_START_WHISPER=true

# Persistance des sessions (transcripts + claims + métriques)
PERSIST_SESSIONS=true
# URL SQLAlchemy (défaut : fichier SQLite local)
DATABASE_URL=sqlite:///./livefactchecker.db
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ local_settings.py
db.sqlite3
db.sqlite3-journal

# Local SQLite session store
*.db
*.db-journal
*.sqlite3-wal
*.sqlite3-shm

# Flask stuff:
instance/
.webassets-cache
Expand Down
24 changes: 13 additions & 11 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@ La source de vérité reste le code, pas ce fichier (cf. CLAUDE.md racine).
Le produit est aujourd'hui sans état : les claims vivent le temps d'une connexion WS,
rien n'est conservé, un seul utilisateur, vérification figée en français.

- [ ] **Persistance des sessions & claims** : stocker chaque session (transcript + claims vérifiés) en base (SQLite suffit pour commencer) pour pouvoir rejouer, exporter et analyser après coup. Prérequis à la plupart des features ci-dessous.
- [ ] **Export d'une session** : générer un récap (Markdown / PDF / JSON) de tous les claims d'une session — texte, statut, explication, sources, score de confiance.
- [ ] **Historique consultable** : endpoint `/sessions` (liste) + `/sessions/{id}` (détail) pour relire une vérification passée hors live.
- [x] **Persistance des sessions & claims** : SQLAlchemy + SQLite (`app/db/`, modèles `app/models/`). Écriture best-effort au fil de l'eau depuis `session.py` via `app/services/session_store.py` (offload `to_thread`). Le segment porte les mesures de la passe (tokens, latences, `api_calls`, `web_search`) ; tout le reste est dérivé à la lecture. Création **paresseuse** : une session sans aucun transcript ne crée pas de ligne. Réglé par `PERSIST_SESSIONS` (défaut `true`) + `DATABASE_URL`.
- [x] **Export d'une session** : `GET /sessions/{id}/export?format=md|json` (formatteur `app/services/export.py`). PDF non fait (Markdown/JSON seulement).
- [x] **Historique consultable** : `GET /sessions` (liste) + `GET /sessions/{id}` (détail), **gated admin** (`require_admin`).
- [ ] **Dédoublonnage des claims** : un même fait répété sur plusieurs chunks de 5 s crée aujourd'hui des claims distincts. Détecter les quasi-doublons (similarité du `text`) et fusionner / ne pas re-vérifier — économise des appels Anthropic.
- [ ] **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.
- [x] **Stats agrégées par session** : `app/services/stats.py` (`compute_stats`) — ratio par statut, catégorie dominante, confiance moyenne, taux/usage web_search, tokens, latences, rejets, coût € estimé. Exposé dans `/sessions/{id}` et la page admin Sessions. ⚠️ Tarifs `PRICING` dans `stats.py` à vérifier (coût `None` si modèle non tarifé).
- [ ] **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.
- [x] **Niveau de vérification réglable** : le message WS `config` porte un champ `verification_level` (`fast` / `thorough`, défaut `thorough`). `fast` n'offre pas l'outil `web_search` (un seul appel, connaissances internes) ; `thorough` le rend disponible (comportement antérieur). Plumbing `session.py` → `extract_and_verify(web_search=…)`.

## Tests (priorité haute)

Aujourd'hui seul `tests/test_claim_extractor.py` existe. Manquent :
Plusieurs suites existent désormais (`test_claim_extractor`, `test_extract_usage`,
`test_stats`, `test_export`, `test_sessions_route`, `test_session_persistence`,
`test_session_config`, `test_auth_route`…). Restent :

- [ ] `session.py` : tester `_make_claim`, `_spawn_claims` (skip si < `MIN_WORDS`), le cycle pending → claim/remove_claim. Mocker `extract_and_verify` et le `WebSocket`.
- [ ] `session.py` : `_make_claim`, `_spawn_claims` (skip si < `MIN_WORDS`), le cycle pending → claim/remove_claim. Mocker `extract_and_verify` et le `WebSocket`. (Partiel : `_ensure_persisted` couvert par `test_session_persistence`.)
- [ ] Auth : `/admin/login` (bon mot de passe → JWT, mauvais → 401), expiration du token, `require_admin` qui rejette un token absent/invalide/expiré.
- [ ] Routes admin : un test d'intégration par route via `TestClient`, avec `require_admin` overridé (`app.dependency_overrides`).
- [ ] `extract_and_verify` : le fallback deux-tours (web_search sans `submit_claims` → second appel forcé). Mocker le client Anthropic.
- [ ] `_parse_claims` : statut invalide → `uncertain`, `confidence` clampée 0-10, champ `text` manquant → claim ignoré.
- [ ] Routes admin : un test d'intégration par route via `TestClient`, avec `require_admin` overridé (`app.dependency_overrides`). (Fait pour `/sessions/*` dans `test_sessions_route`.)
- [x] `extract_and_verify` : le fallback deux-tours (web_search sans `submit_claims` → second appel forcé) + accumulation usage/tokens. Couvert par `test_extract_usage` (client Anthropic mocké).
- [x] `_parse_claims` : statut invalide → `uncertain`, `confidence` clampée 0-10, champ `text` manquant → claim ignoré. Couvert par `test_claim_extractor` (`test_unknown_status_falls_back_to_uncertain`, `test_confidence_is_clamped_to_0_10`, `test_entries_without_text_are_dropped`).

## Robustesse & sécurité

Expand All @@ -38,10 +40,10 @@ 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).
- [x] `_active_sessions` est un dict global au niveau module : OK pour un process unique. Limite documentée en tête de `session.py` — ce n'est pas un cache de la DB (il porte des `asyncio.Task` vivants + une `deque` de contexte non persistés) mais l'état runtime des connexions ouvertes, lu seulement par `/admin/ws/status`. Non remplaçable par des requêtes DB (la base ne sait pas « qui est connecté maintenant »). Reste mono-process : ne survit pas à plusieurs workers / un restart.
- [ ] 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é

- [ ] `/admin/logs` lit un buffer en mémoire (`get_logs`) — vérifier qu'il est borné (pas de fuite mémoire sur longue session).
- [x] `/admin/logs` lit un buffer en mémoire (`get_logs`) — déjà borné : `_log_history = deque(maxlen=300)` dans `core/observability.py` évince les entrées les plus anciennes, donc pas de fuite mémoire sur longue session.
- [ ] Exposer des métriques agrégées (claims/min, ratio web_search, latence moyenne transcription + vérification) en plus du statut de session brut.
2 changes: 2 additions & 0 deletions app/api/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def _value_type(value: object) -> ValueType:
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
if isinstance(value, list):
return "list"
return "str"
Expand Down
4 changes: 2 additions & 2 deletions app/api/routers/fact_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
async def fact_check(
req: FactCheckRequest, _admin: str = Depends(require_admin)
) -> FactCheckResponse:
results = await extract_and_verify(req.text, web_search=req.web_search)
result = await extract_and_verify(req.text, web_search=req.web_search)
# Service dicts carry every Claim field except id/timestamp; the response
# model fills those with defaults. This route is diagnostic, not the live
# WS path, so stable ids/timestamps aren't needed here.
claims = [Claim(id="", **r) for r in results]
claims = [Claim(id="", **r) for r in result.claims]
return FactCheckResponse(text=req.text, claims=claims)
111 changes: 111 additions & 0 deletions app/api/routers/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Session history & export (read-only, admin-gated).

Browsing past sessions is an admin capability: there is no per-user notion, so the
whole history is global and sits behind ``require_admin``. A regular user exporting
their *own live* session is a separate, client-side path (built from the in-browser
stores), not these routes.

Handlers are plain ``def`` so FastAPI runs them in its threadpool — the sync
SQLAlchemy session from ``get_db`` never blocks the event loop.
"""

from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse, PlainTextResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession

from app.config import settings
from app.db.session import get_db
from app.dependencies import require_admin
from app.models import Session
from app.schemas.history import ClaimOut, SegmentOut, SessionDetail, SessionSummary
from app.services.export import session_to_markdown
from app.services.stats import compute_stats

router = APIRouter(
prefix="/sessions", tags=["sessions"], dependencies=[Depends(require_admin)]
)


def _summary(session: Session, model: str) -> SessionSummary:
stats = compute_stats(session, model)
return SessionSummary(
id=session.id,
started_at=session.started_at,
ended_at=session.ended_at,
active=session.ended_at is None,
client_host=session.client_host,
transcripts_count=stats.transcripts_count,
claims_count=stats.claims_count,
false_count=stats.claims_by_status.get("false", 0),
estimated_cost_usd=stats.estimated_cost_usd,
)


def _detail(session: Session, model: str) -> SessionDetail:
return SessionDetail(
id=session.id,
started_at=session.started_at,
ended_at=session.ended_at,
active=session.ended_at is None,
client_host=session.client_host,
chunks_received=session.chunks_received,
stats=compute_stats(session, model),
# Explicit ORM → schema conversion (segments come ordered by seq).
segments=[SegmentOut.model_validate(s) for s in session.segments],
claims=[
ClaimOut.model_validate(c)
for c in sorted(session.claims, key=lambda c: c.timestamp)
],
)


def _get_or_404(db: DBSession, session_id: str) -> Session:
session = db.get(Session, session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session introuvable")
return session


@router.get("", response_model=list[SessionSummary])
def list_sessions(
db: DBSession = Depends(get_db),
limit: int = Query(default=100, ge=1, le=1000),
) -> list[SessionSummary]:
sessions = (
db.execute(select(Session).order_by(Session.started_at.desc()).limit(limit))
.scalars()
.all()
)
model = settings.ANTHROPIC_MODEL
return [_summary(s, model) for s in sessions]


@router.get("/{session_id}", response_model=SessionDetail)
def get_session(session_id: str, db: DBSession = Depends(get_db)) -> SessionDetail:
return _detail(_get_or_404(db, session_id), settings.ANTHROPIC_MODEL)


@router.get("/{session_id}/export", response_model=None)
def export_session(
session_id: str,
db: DBSession = Depends(get_db),
fmt: Literal["json", "md"] = Query(default="json", alias="format"),
) -> JSONResponse | PlainTextResponse:
detail = _detail(_get_or_404(db, session_id), settings.ANTHROPIC_MODEL)
if fmt == "md":
return PlainTextResponse(
session_to_markdown(detail),
media_type="text/markdown",
headers={
"Content-Disposition": f'attachment; filename="session-{session_id}.md"'
},
)
return JSONResponse(
detail.model_dump(mode="json"),
headers={
"Content-Disposition": f'attachment; filename="session-{session_id}.json"'
},
)
32 changes: 29 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,25 @@ class Settings(BaseSettings):
WHISPER_MODEL: str = "medium"
WHISPER_DEVICE: Literal["cpu", "cuda"] = "cpu"

# Upper bound on a single received audio blob (bytes), on /ws chunks and the
# /admin/whisper/transcribe upload. ~5 s of WebM/Opus is well under this; the
# cap guards against a malformed/oversized blob saturating memory. Default 10 MiB.
# Upper bound on a single received audio frame (bytes), on /ws frames and the
# /admin/whisper/transcribe upload. A /ws PCM frame (~250 ms of 16 kHz mono
# Int16) is a few KiB; the cap guards against a malformed/oversized blob
# saturating memory. Default 10 MiB.
MAX_AUDIO_BYTES: int = 10 * 1024 * 1024

# Voice-activity endpointing for the live /ws stream. The client streams raw
# PCM continuously; the server cuts it into utterances on natural pauses
# (Silero VAD) instead of fixed client-side chunks. See services/audio_endpointer.
# VAD_THRESHOLD: Silero speech probability above which a frame counts as speech.
VAD_THRESHOLD: float = 0.5
# Trailing silence (ms) that closes an utterance and flushes it for transcription.
VAD_SILENCE_FLUSH_MS: int = 700
# Force-flush an utterance once it reaches this length, even without a pause
# (keeps a long monologue from delaying feedback indefinitely).
VAD_MAX_SEGMENT_MS: int = 12000
# Drop a flushed utterance shorter than this (filters out blips/noise).
VAD_MIN_SEGMENT_MS: int = 400

LOG_LEVEL: str = "INFO"

# CORS: origines autorisées pour le front (format JSON dans .env, ex.
Expand All @@ -43,6 +57,18 @@ class Settings(BaseSettings):

AUTO_START_WHISPER: bool = True

# How many preceding utterances are handed to claim extraction as read-only
# context so a sentence that only makes sense after the previous one
# ("Il en est de même de…") can be resolved instead of dropped as unverifiable.
CONTEXT_TURNS: int = 4

# Session persistence. PERSIST_SESSIONS gates *writing* sessions, transcripts
# and verified claims to the DB; the live WS path is unaffected when off. The
# tables are always created at startup so the /sessions read routes work
# regardless. DATABASE_URL is any SQLAlchemy URL (default: a local SQLite file).
PERSIST_SESSIONS: bool = True
DATABASE_URL: str = "sqlite:///./livefactchecker.db"

@field_validator("ANTHROPIC_API_KEY")
@classmethod
def api_key_must_be_set(cls, v: str) -> str:
Expand Down
43 changes: 42 additions & 1 deletion app/core/config_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,27 @@ class ConfigBlock:
id="audio",
title="Audio",
fields=(
ConfigField("MAX_AUDIO_BYTES", "Taille max d'un blob (octets)", "editable"),
ConfigField(
"MAX_AUDIO_BYTES", "Taille max d'une frame (octets)", "editable"
),
),
),
ConfigBlock(
id="vad",
title="Découpage VAD (live)",
fields=(
ConfigField(
"VAD_THRESHOLD", "Seuil de détection de parole (0-1)", "editable"
),
ConfigField(
"VAD_SILENCE_FLUSH_MS", "Silence de fin d'énoncé (ms)", "editable"
),
ConfigField(
"VAD_MAX_SEGMENT_MS", "Longueur max d'un énoncé (ms)", "editable"
),
ConfigField(
"VAD_MIN_SEGMENT_MS", "Longueur min d'un énoncé (ms)", "editable"
),
),
),
ConfigBlock(
Expand All @@ -87,6 +107,27 @@ class ConfigBlock:
title="CORS",
fields=(ConfigField("ALLOWED_ORIGINS", "Origines autorisées", "readonly"),),
),
ConfigBlock(
id="extraction",
title="Extraction & vérification",
fields=(
ConfigField(
"CONTEXT_TURNS",
"Fenêtre de contexte (énoncés précédents)",
"editable",
),
),
),
ConfigBlock(
id="persistence",
title="Persistance des sessions",
# Read-only: both take effect at startup (the DB engine is built once), so
# a runtime change wouldn't apply to the live process.
fields=(
ConfigField("PERSIST_SESSIONS", "Persistance activée", "readonly"),
ConfigField("DATABASE_URL", "URL base de données", "readonly"),
),
),
ConfigBlock(
id="logs",
title="Logs",
Expand Down
6 changes: 6 additions & 0 deletions app/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Database layer: engine, session factory and the declarative base.

Cross-cutting infrastructure (see .claude/rules/architecture.md). ORM models live
in ``app/models/``; this package only owns the connection and the ``Base`` they
inherit from.
"""
11 changes: 11 additions & 0 deletions app/db/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Declarative base shared by every ORM model.

Kept in its own module so ``app.db.session`` (engine/init) and the models can both
import it without a circular dependency.
"""

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
pass
46 changes: 46 additions & 0 deletions app/db/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""SQLAlchemy engine, session factory and schema bootstrap.

The app is async but persistence uses the *sync* SQLAlchemy API: writes on the WS
path are offloaded to a thread (``asyncio.to_thread``, like transcription), and the
read routes are plain ``def`` handlers that FastAPI runs in its threadpool. This
keeps the dependency surface minimal (no aiosqlite) and matches the existing
"offload blocking work to a thread" pattern.
"""

from collections.abc import Iterator

from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from app.config import settings
from app.db.base import Base

# SQLite refuses cross-thread connection reuse by default; we hand connections to
# threadpool workers, so disable that guard. SQLAlchemy still gives each Session
# its own connection from the pool, so this stays safe.
_connect_args = (
{"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
)

engine = create_engine(settings.DATABASE_URL, connect_args=_connect_args)

# expire_on_commit=False lets a committed ORM object still be read (its attributes
# stay populated) after the transaction closes — convenient for short write helpers.
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)


def init_db() -> None:
"""Create any missing tables. Idempotent; called once at startup."""
# Import the models so they register on Base.metadata before create_all.
from app import models # noqa: F401

Base.metadata.create_all(bind=engine)


def get_db() -> Iterator[Session]:
"""FastAPI dependency yielding a DB session, closed in a finally."""
db = SessionLocal()
try:
yield db
finally:
db.close()
Loading
Loading