diff --git a/.env.example b/.env.example index 8ee34e2..92a58d4 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,5 @@ ADMIN_PASSWORD=change-me # Secret de signature JWT (générer une chaîne aléatoire longue) JWT_SECRET=change-me-too-long-random-string JWT_EXPIRE_HOURS=12 + +AUTO_START_WHISPER=true diff --git a/app/api/routers/admin.py b/app/api/routers/admin.py index 62f5d50..40a4dd9 100644 --- a/app/api/routers/admin.py +++ b/app/api/routers/admin.py @@ -8,28 +8,28 @@ import asyncio import logging import sys +from typing import Any import psutil from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from pydantic import ValidationError from app.config import settings +from app.core.config_descriptor import BLOCKS, ConfigField, field_by_key from app.core.observability import get_logs, uptime_seconds from app.dependencies import require_admin from app.schemas.admin import ( AdminHealthResponse, - AnthropicInfo, - ConfigEditable, - ConfigOptions, + ConfigBlockOut, + ConfigFieldValue, ConfigPatch, ConfigPatchResponse, - ConfigReadonly, ConfigResponse, - HealthConfig, LogEntry, LogsResponse, MemoryInfo, PromptResponse, - WhisperInfo, + ValueType, WsStatusResponse, ) from app.schemas.fact_check import ModelTestRequest, ModelTestResponse @@ -48,12 +48,37 @@ prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)] ) -_EDITABLE_MODELS = [ - "claude-haiku-4-5-20251001", - "claude-sonnet-4-6", - "claude-opus-4-8", -] -_VALID_LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + +def _apply_log_level(value: str) -> None: + logging.getLogger("app").setLevel(value) + + +def _value_type(value: object) -> ValueType: + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, list): + return "list" + return "str" + + +def _serialize_field(field: ConfigField) -> ConfigFieldValue: + raw = getattr(settings, field.key) + if field.kind == "secret_status": + # Raw secret never leaves the server — only whether it is set. + return ConfigFieldValue( + key=field.key, label=field.label, kind=field.kind, configured=bool(raw) + ) + + return ConfigFieldValue( + key=field.key, + label=field.label, + kind=field.kind, + value=raw, + options=list(field.options) if field.options else None, + value_type=_value_type(raw), + ) @router.get("/health", response_model=AdminHealthResponse) @@ -70,24 +95,8 @@ async def admin_health() -> AdminHealthResponse: return AdminHealthResponse( uptime_seconds=uptime_seconds(), - whisper=WhisperInfo( - model=settings.WHISPER_MODEL, - device=settings.WHISPER_DEVICE, - loaded=is_model_loaded(), - ), - anthropic=AnthropicInfo( - model=settings.ANTHROPIC_MODEL, - api_key_set=bool(settings.ANTHROPIC_API_KEY), - api_key_hint=f"...{settings.ANTHROPIC_API_KEY[-4:]}" - if settings.ANTHROPIC_API_KEY - else "", - ), - config=HealthConfig( - log_level=settings.LOG_LEVEL, - jwt_expire_hours=settings.JWT_EXPIRE_HOURS, - max_claims_per_chunk=settings.MAX_CLAIMS_PER_CHUNK, - ), python_version=sys.version.split()[0], + whisper_loaded=is_model_loaded(), memory=memory, ) @@ -104,47 +113,53 @@ async def admin_prompt() -> PromptResponse: ) -@router.get("/config", response_model=ConfigResponse) +@router.get( + "/config", + response_model=ConfigResponse, + summary="Configuration actuelle du système", +) async def admin_config() -> ConfigResponse: + blocks = [ + ConfigBlockOut( + id=block.id, + title=block.title, + fields=[_serialize_field(f) for f in block.fields], + ) + for block in BLOCKS + ] + return ConfigResponse( - editable=ConfigEditable( - anthropic_model=settings.ANTHROPIC_MODEL, - log_level=settings.LOG_LEVEL, - ), - readonly=ConfigReadonly( - whisper_model=settings.WHISPER_MODEL, - whisper_device=settings.WHISPER_DEVICE, - jwt_expire_hours=settings.JWT_EXPIRE_HOURS, - max_claims_per_chunk=settings.MAX_CLAIMS_PER_CHUNK, - ), - options=ConfigOptions( - models=_EDITABLE_MODELS, - log_levels=_VALID_LOG_LEVELS, - ), + blocks=blocks, note="Les modifications sont perdues au redémarrage (--reload actif).", ) +# Side effects to run after a successful PATCH, beyond setting the attribute. +_EDITABLE_SIDE_EFFECTS = {"LOG_LEVEL": _apply_log_level} + + @router.patch("/config", response_model=ConfigPatchResponse) async def patch_config(patch: ConfigPatch) -> ConfigPatchResponse: - changed: dict[str, str] = {} - if patch.anthropic_model is not None: - if patch.anthropic_model not in _EDITABLE_MODELS: + changed: dict[str, Any] = {} + for key, value in patch.updates.items(): + field = field_by_key(key) + if field is None or field.kind != "editable": + raise HTTPException(status_code=422, detail=f"Champ non modifiable : {key}") + if field.options is not None and value not in field.options: raise HTTPException( - status_code=422, - detail=f"Modèle inconnu : {patch.anthropic_model}", + status_code=422, detail=f"Valeur invalide pour {key} : {value!r}" ) - settings.ANTHROPIC_MODEL = patch.anthropic_model - changed["anthropic_model"] = patch.anthropic_model - if patch.log_level is not None: - lvl = patch.log_level.upper() - if lvl not in _VALID_LOG_LEVELS: + try: + # validate_assignment on Settings coerces & validates the new value. + setattr(settings, key, value) + except ValidationError as exc: raise HTTPException( - status_code=422, detail=f"Niveau inconnu : {patch.log_level}" - ) - settings.LOG_LEVEL = lvl - logging.getLogger("app").setLevel(lvl) - changed["log_level"] = lvl + status_code=422, detail=f"Valeur invalide pour {key}" + ) from exc + side_effect = _EDITABLE_SIDE_EFFECTS.get(key) + if side_effect is not None: + side_effect(getattr(settings, key)) + changed[key] = getattr(settings, key) return ConfigPatchResponse(changed=changed) diff --git a/app/config.py b/app/config.py index f184e95..0e70af8 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,10 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore" + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + validate_assignment=True, ) # Anthropic @@ -17,8 +20,6 @@ class Settings(BaseSettings): WHISPER_MODEL: str = "medium" WHISPER_DEVICE: Literal["cpu", "cuda"] = "cpu" - MAX_CLAIMS_PER_CHUNK: int = 5 - # 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. @@ -40,6 +41,8 @@ class Settings(BaseSettings): LOGIN_RATE_LIMIT_ATTEMPTS: int = 5 LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = 300 + AUTO_START_WHISPER: bool = True + @field_validator("ANTHROPIC_API_KEY") @classmethod def api_key_must_be_set(cls, v: str) -> str: diff --git a/app/core/config_descriptor.py b/app/core/config_descriptor.py new file mode 100644 index 0000000..8e3c8b6 --- /dev/null +++ b/app/core/config_descriptor.py @@ -0,0 +1,112 @@ +"""Static description of the config blocks shown on the admin System page. + +Single source of truth for the ``/admin/config`` contract: thematic blocks → fields, +each field carrying its ``kind`` (read-only / editable / secret-status) and, for an +editable enum, the closed list of allowed ``options``. ``key`` is the *exact* name of +a ``Settings`` attribute; the route reads live values by that key and the front renders +the blocks generically. + +A completeness test (``tests/test_config_descriptor.py``) asserts the described keys +equal ``set(Settings.model_fields)`` — so adding, renaming or removing a Settings field +turns it red until this descriptor is updated. Fail-closed, by design. +""" + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Literal + +FieldKind = Literal["readonly", "editable", "secret_status"] + + +@dataclass(frozen=True) +class ConfigField: + key: str # exact Settings attribute name + label: str + kind: FieldKind + options: tuple[str, ...] | None = None # closed choice for an editable enum + + +@dataclass(frozen=True) +class ConfigBlock: + id: str + title: str + fields: tuple[ConfigField, ...] + + +_ANTHROPIC_MODELS = ( + "claude-haiku-4-5-20251001", + "claude-sonnet-4-6", + "claude-opus-4-8", +) + +_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + + +BLOCKS: tuple[ConfigBlock, ...] = ( + ConfigBlock( + id="anthropic", + title="API Anthropic", + fields=( + ConfigField("ANTHROPIC_API_KEY", "Clé API", "secret_status"), + ConfigField("ANTHROPIC_MODEL", "Modèle", "editable", _ANTHROPIC_MODELS), + ), + ), + ConfigBlock( + id="whisper", + title="Whisper", + fields=( + ConfigField("WHISPER_MODEL", "Modèle", "readonly"), + ConfigField("WHISPER_DEVICE", "Device", "readonly"), + ConfigField("AUTO_START_WHISPER", "Démarrage automatique", "readonly"), + ), + ), + ConfigBlock( + id="audio", + title="Audio", + fields=( + ConfigField("MAX_AUDIO_BYTES", "Taille max d'un blob (octets)", "editable"), + ), + ), + ConfigBlock( + id="auth", + title="Auth & Sécurité", + fields=( + ConfigField("ADMIN_PASSWORD", "Mot de passe admin", "secret_status"), + ConfigField("JWT_SECRET", "Secret JWT", "secret_status"), + ConfigField("JWT_EXPIRE_HOURS", "Expiration JWT (h)", "readonly"), + ConfigField( + "LOGIN_RATE_LIMIT_ATTEMPTS", "Tentatives login max", "readonly" + ), + ConfigField( + "LOGIN_RATE_LIMIT_WINDOW_SECONDS", "Fenêtre login (s)", "readonly" + ), + ), + ), + ConfigBlock( + id="cors", + title="CORS", + fields=(ConfigField("ALLOWED_ORIGINS", "Origines autorisées", "readonly"),), + ), + ConfigBlock( + id="logs", + title="Logs", + fields=(ConfigField("LOG_LEVEL", "Niveau de log", "editable", _LOG_LEVELS),), + ), +) + + +def all_fields() -> Iterator[ConfigField]: + for block in BLOCKS: + yield from block.fields + + +def field_by_key(key: str) -> ConfigField | None: + return next((f for f in all_fields() if f.key == key), None) + + +# Every key described above. Cross-checked against Settings.model_fields by the test. +DESCRIBED_KEYS = frozenset(f.key for f in all_fields()) + +# Secret-status fields: described (so the page shows "configured"/"missing") but their +# raw value must never be serialised. Used by the serialiser and asserted by the tests. +SECRET_KEYS = frozenset(f.key for f in all_fields() if f.kind == "secret_status") diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 3b49843..4123cfc 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -5,40 +5,22 @@ diagnostic-only and not part of a stable contract. """ -from typing import Any +from typing import Any, Literal from pydantic import BaseModel -class WhisperInfo(BaseModel): - model: str - device: str - loaded: bool - - -class AnthropicInfo(BaseModel): - model: str - api_key_set: bool - api_key_hint: str - - -class HealthConfig(BaseModel): - log_level: str - jwt_expire_hours: int - max_claims_per_chunk: int - - class MemoryInfo(BaseModel): rss_mb: float vms_mb: float class AdminHealthResponse(BaseModel): + """Pure runtime health — static config lives in the descriptor (/admin/config).""" + uptime_seconds: int - whisper: WhisperInfo - anthropic: AnthropicInfo - config: HealthConfig python_version: str + whisper_loaded: bool memory: MemoryInfo | None = None @@ -51,37 +33,39 @@ class PromptResponse(BaseModel): model: str -class ConfigPatch(BaseModel): - anthropic_model: str | None = None - log_level: str | None = None - +FieldKind = Literal["readonly", "editable", "secret_status"] +ValueType = Literal["str", "int", "bool", "list"] -class ConfigEditable(BaseModel): - anthropic_model: str - log_level: str +class ConfigFieldValue(BaseModel): + """One config field rendered on the System page, driven by the descriptor.""" -class ConfigReadonly(BaseModel): - whisper_model: str - whisper_device: str - jwt_expire_hours: int - max_claims_per_chunk: int + key: str + label: str + kind: FieldKind + value: Any | None = None # None for secret_status (raw value never serialised) + configured: bool | None = None # set only for secret_status + options: list[str] | None = None # closed choice for an editable enum + value_type: ValueType | None = None # drives the editable control on the front -class ConfigOptions(BaseModel): - models: list[str] - log_levels: list[str] +class ConfigBlockOut(BaseModel): + id: str + title: str + fields: list[ConfigFieldValue] class ConfigResponse(BaseModel): - editable: ConfigEditable - readonly: ConfigReadonly - options: ConfigOptions + blocks: list[ConfigBlockOut] note: str +class ConfigPatch(BaseModel): + updates: dict[str, Any] # {settings key: new value}, editable fields only + + class ConfigPatchResponse(BaseModel): - changed: dict[str, str] + changed: dict[str, Any] class LogEntry(BaseModel): diff --git a/app/services/transcription.py b/app/services/transcription.py index e0aa7db..7a65ed0 100644 --- a/app/services/transcription.py +++ b/app/services/transcription.py @@ -12,7 +12,8 @@ def preload_model() -> None: - _get_model() + if settings.AUTO_START_WHISPER: + _get_model() def _get_model() -> WhisperModel: @@ -76,17 +77,12 @@ def transcribe_with_detail(audio: bytes) -> dict: def transcribe_chunk(audio: bytes) -> str: """Transcribe a self-contained audio chunk into plain text. - Accepts either raw encoded audio bytes (e.g. a complete WebM/Opus blob, - decoded via ffmpeg) or a float32 PCM array. Synchronous and CPU-bound — - call it from a thread pool. + 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. """ - model = _get_model() - # faster-whisper accepts a path, a file-like object, or a float32 ndarray. - # Encoded bytes (a WebM/Opus blob) must be wrapped so ffmpeg can decode them. - source = io.BytesIO(audio) if isinstance(audio, bytes) else audio try: - segments, _ = model.transcribe( - source, + segments, _ = _get_model().transcribe( + io.BytesIO(audio), language="fr", vad_filter=True, ) diff --git a/tests/test_config_descriptor.py b/tests/test_config_descriptor.py new file mode 100644 index 0000000..9f9cbd0 --- /dev/null +++ b/tests/test_config_descriptor.py @@ -0,0 +1,100 @@ +"""Tests for the config descriptor and the descriptor-driven /admin/config routes. + +The completeness test is the safety net: it pins the descriptor to ``Settings`` so a +field added (or renamed/removed) without updating the descriptor turns this red. The +rest cover the contract the System page relies on — secrets never serialised, generic +editable PATCH with type coercion and rejection of non-editable / invalid values. + +``require_admin`` is overridden to run offline (cf. test_audio_size_limit.py). +""" + +import logging + +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings, settings +from app.core.config_descriptor import DESCRIBED_KEYS, SECRET_KEYS +from app.dependencies import require_admin +from app.main import app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def _bypass_admin_auth(): + app.dependency_overrides[require_admin] = lambda: None + yield + app.dependency_overrides.pop(require_admin, None) + + +def test_descriptor_covers_every_settings_field() -> None: + # `==` (not subset) catches BOTH a new undescribed field AND a stale descriptor key. + assert set(Settings.model_fields) == DESCRIBED_KEYS + + +def test_config_endpoint_returns_all_described_fields() -> None: + resp = client.get("/admin/config") + assert resp.status_code == 200 + returned = {f["key"] for b in resp.json()["blocks"] for f in b["fields"]} + assert returned == DESCRIBED_KEYS + + +def test_secrets_never_serialised() -> None: + resp = client.get("/admin/config") + body = resp.text + fields = {f["key"]: f for b in resp.json()["blocks"] for f in b["fields"]} + for key in SECRET_KEYS: + raw = str(getattr(settings, key)) + if raw: + assert raw not in body # raw secret value must never appear in the payload + assert fields[key]["value"] is None + assert isinstance(fields[key]["configured"], bool) + + +def test_patch_editable_int_coerces_string() -> None: + original = settings.MAX_AUDIO_BYTES + try: + resp = client.patch( + "/admin/config", json={"updates": {"MAX_AUDIO_BYTES": "2048"}} + ) + assert resp.status_code == 200 + assert settings.MAX_AUDIO_BYTES == 2048 # coerced str -> int by Pydantic + finally: + settings.MAX_AUDIO_BYTES = original + + +def test_patch_rejects_non_editable_field() -> None: + resp = client.patch("/admin/config", json={"updates": {"WHISPER_MODEL": "large"}}) + assert resp.status_code == 422 + assert settings.WHISPER_MODEL != "large" + + +def test_patch_rejects_value_outside_options() -> None: + resp = client.patch("/admin/config", json={"updates": {"ANTHROPIC_MODEL": "gpt-4"}}) + assert resp.status_code == 422 + assert settings.ANTHROPIC_MODEL != "gpt-4" + + +def test_patch_rejects_uncoercible_value() -> None: + original = settings.MAX_AUDIO_BYTES + try: + resp = client.patch( + "/admin/config", json={"updates": {"MAX_AUDIO_BYTES": "not-an-int"}} + ) + assert resp.status_code == 422 + assert original == settings.MAX_AUDIO_BYTES + finally: + settings.MAX_AUDIO_BYTES = original + + +def test_patch_log_level_applies_side_effect() -> None: + original = settings.LOG_LEVEL + try: + resp = client.patch("/admin/config", json={"updates": {"LOG_LEVEL": "DEBUG"}}) + assert resp.status_code == 200 + assert settings.LOG_LEVEL == "DEBUG" + assert logging.getLogger("app").level == logging.DEBUG + finally: + settings.LOG_LEVEL = original + logging.getLogger("app").setLevel(original)