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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
133 changes: 74 additions & 59 deletions app/api/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
)

Expand All @@ -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)


Expand Down
9 changes: 6 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions app/core/config_descriptor.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading