jurix-api / app /main.py
rahim237cm's picture
Claude Opus 5.5
feat(embeddings): voyage-4-large remplace EmbeddingGemma (API Voyage, halfvec 1024)
57c8117
Raw History Blame Contribute Delete
10.5 kB
"""Point d'entrée FastAPI."""
import asyncio
import logging
import re
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import (
admin,
analytics,
articles,
auth,
batch_upload,
categories,
classifier,
comparison,
language,
laws,
ocr,
personas,
rag,
search,
upload,
)
from app.core.config import settings
from app.core.database import close_db
from app.services.search_service import precharger_les_embeddings
logger = logging.getLogger(__name__)
# Les journaux de l'application partaient au niveau INFO vers des loggers sans
# gestionnaire : uvicorn ne configure que les siens, et Python ne laisse passer
# que WARNING par defaut. Le journal d'un appel Mistral ou Groq (raison de fin,
# jetons, duree) n'apparaissait donc nulle part, ni en developpement ni dans
# `docker compose logs api`. propagate reste vrai : la capture de pytest
# continue de les voir.
_journal_app = logging.getLogger("app")
if not _journal_app.handlers:
_sortie = logging.StreamHandler()
_sortie.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
_journal_app.addHandler(_sortie)
_journal_app.setLevel(logging.INFO)
class _MasqueJetonDansURL(logging.Filter):
"""
uvicorn journalise chaque WebSocket avec son URL entiere
(« "WebSocket /api/v1/batch-upload/ws/...?token=..." [accepted] »), par son
journal d'erreurs que --no-access-log ne coupe pas. Le jeton
d'administration finissait en clair dans `docker compose logs api`.
"""
_JETON = re.compile(r"(token=)[^&\s\"]+")
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.args, tuple):
record.args = tuple(
self._JETON.sub(r"\1***", a) if isinstance(a, str) else a for a in record.args
)
return True
logging.getLogger("uvicorn.error").addFilter(_MasqueJetonDansURL())
# Valeur de repli declaree dans config.py. Si elle survit hors developpement,
# les JWT seraient signes avec une clé publiée dans le dépôt.
_DEV_SECRET_KEY = "dev_secret_key_change_in_production_with_openssl_rand_hex_32"
# Cles qu'aucune instance hors developpement ne doit garder : toutes sont
# publiees dans le depot (valeur de developpement, modeles des fichiers
# d'exemple). Un modele oublie dans le .env de production passait le
# garde-fou, qui ne connaissait que la premiere.
_CLES_PUBLIEES = frozenset({
_DEV_SECRET_KEY,
"a-generer",
"generate-a-strong-random-key-here",
"change-me",
"changeme",
"secret",
})
_LONGUEUR_MIN_SECRET = 32
def _secret_key_refusee(cle: str) -> str:
"""La raison de refuser cette SECRET_KEY hors developpement, ou "" si elle convient."""
if cle in _CLES_PUBLIEES:
return "c'est une valeur publiee dans le depot"
if len(cle) < _LONGUEUR_MIN_SECRET:
return f"elle est trop courte ({len(cle)} caracteres, {_LONGUEUR_MIN_SECRET} au moins)"
return ""
# Intervalle de purge : desormais `settings.CACHE_CLEANUP_INTERVAL_S`, lu A
# CHAQUE TOUR de boucle et non a l'import. Une constante de module est figee au
# chargement, donc ni reglable par l'environnement ni modifiable par un test.
#
# Le cache de recherche vit cinq minutes ; passer plus souvent ne libererait
# rien de plus. Effet de bord utile en production : cette boucle touche la base
# regulierement, ce qui empeche un hebergeur gratuit de mettre le projet en
# pause pour inactivite.
STATS_AGGREGATION_INTERVAL_S = 24 * 60 * 60
async def _agreger_les_statistiques() -> None:
"""
Agregation quotidienne des statistiques par persona.
`PersonaService.aggregate_daily_stats` existait, etait testee, et son
docstring annoncait « appelee par une tache Celery chaque nuit ». Il n'y a
jamais eu de Celery dans ce depot : la table `persona_stats` restait vide et
les routes /personas/* qui la lisent rendaient des zeros. Meme motif que la
purge des caches ci-dessous : une promesse de traitement de fond non tenue
est pire qu'aucune promesse.
"""
from app.core.database import AsyncSessionLocal
from app.services.persona_service import PersonaService
while True:
try:
await asyncio.sleep(STATS_AGGREGATION_INTERVAL_S)
async with AsyncSessionLocal() as session:
await PersonaService(session).aggregate_daily_stats()
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"⚠️ Agregation des statistiques impossible: {e}")
async def _purger_les_caches() -> None:
"""Boucle de menage. Ne doit jamais interrompre le service."""
from app.core.database import AsyncSessionLocal
from app.services.postgres_search_service import cleanup_expired_cache
while True:
try:
await asyncio.sleep(settings.CACHE_CLEANUP_INTERVAL_S)
async with AsyncSessionLocal() as session:
supprimees = await cleanup_expired_cache(session)
if supprimees:
logger.info(f"🧹 {supprimees} entrees de cache expirees supprimees")
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"⚠️ Purge des caches impossible: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Cycle de vie de l'application."""
raison = _secret_key_refusee(settings.SECRET_KEY)
if settings.ENVIRONMENT != "development" and raison:
raise RuntimeError(
f"SECRET_KEY refusée alors que ENVIRONMENT={settings.ENVIRONMENT!r} : "
f"{raison}. Générez-en une avec `openssl rand -hex 32` et placez-la "
"dans .env — sinon n'importe qui peut forger un jeton d'administration."
)
logger.info(f"🚀 {settings.APP_NAME} v{settings.VERSION} ({settings.ENVIRONMENT})")
# Fournisseur d'embeddings sonde AVANT d'accepter des requetes : une cle
# absente ou refusee se voit ici, pas a la premiere question. Un echec
# n'empeche pas le demarrage — plein texte, chat et comptes fonctionnent
# sans embeddings — mais il est journalise et la sante le montre. Un echec
# passager (reseau pas encore pret) garde le service : chaque question
# retentera. Hors de la boucle : c'est un appel HTTPS synchrone. Borne a
# 20 s : httpx ne borne pas la resolution DNS, et un DNS muet au
# redemarrage de la VM ne doit pas retenir l'API.
try:
await asyncio.wait_for(asyncio.to_thread(precharger_les_embeddings), timeout=20)
except TimeoutError as e:
logger.warning("⚠️ Sonde du fournisseur d'embeddings sans reponse en 20 s : demarrage sans elle")
# La sante le montre ; la sonde, si elle finit, ecrira son issue.
from app.services.embedding_service import suivi_semantique
suivi_semantique.noter_sonde(e)
# Purge des caches expires, en tache de fond.
#
# `cleanup_expired_cache` existait, etait importee par search_service, et
# n'etait APPELEE NULLE PART. `query_cache` et `embedding_cache` portent une
# colonne `expires_at` que rien n'appliquait : les deux tables grossissaient
# sans fin. Une colonne d'expiration non appliquee est pire que pas de
# colonne du tout — elle laisse croire que le menage est fait.
tache_menage = asyncio.create_task(_purger_les_caches())
tache_stats = asyncio.create_task(_agreger_les_statistiques())
try:
yield
finally:
for tache in (tache_menage, tache_stats):
tache.cancel()
with suppress(asyncio.CancelledError):
await tache
# La connexion gardee vers le fournisseur d'embeddings (Voyage).
from app.services import search_service
fournisseur = getattr(search_service._embedding_service_instance, "provider", None)
fermer = getattr(fournisseur, "fermer", None)
if fermer is not None:
fermer()
# close_db() existait mais n'etait jamais appele : le pool de
# connexions asyncpg n'etait jamais libere a l'arret. Dans le `finally`
# pour qu'il s'execute meme si l'arret vient d'une exception.
await close_db()
app = FastAPI(
title=settings.APP_NAME,
version=settings.VERSION,
debug=settings.DEBUG,
lifespan=lifespan,
)
# CORS — allow localhost in dev, Vercel domain in prod
# Set ALLOWED_ORIGINS in your env for production (comma-separated)
_extra_origins = [o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()]
_cors_origins = [
"http://localhost:5173",
"http://localhost:4173",
"http://127.0.0.1:5173",
] + _extra_origins
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
"""Root endpoint."""
return {
"message": "JuriX API v2.1",
"status": "running",
"version": settings.VERSION
}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy"}
# Include routers with global /api/v1 prefix
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(language.router, prefix="/api/v1/language", tags=["language"])
app.include_router(classifier.router, prefix="/api/v1/classifier", tags=["classifier"])
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
app.include_router(categories.router, prefix="/api/v1/categories", tags=["categories"])
app.include_router(articles.router, prefix="/api/v1/articles", tags=["articles"])
app.include_router(personas.router, prefix="/api/v1/personas", tags=["personas"])
app.include_router(rag.router, prefix="/api/v1/rag", tags=["rag", "chatbot"])
app.include_router(comparison.router, prefix="/api/v1/compare", tags=["compare"])
app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"])
app.include_router(ocr.router, prefix="/api/v1/ocr", tags=["ocr"])
app.include_router(laws.router, prefix="/api/v1/laws", tags=["laws"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(batch_upload.router, prefix="/api/v1/batch-upload", tags=["batch"])
# Trigger reload