Upgrade configuration and Docker stack to v2.0.0

This commit is contained in:
2026-06-22 14:49:56 -04:00
parent 2f2a5e9b4a
commit 0d2ecbd218
19 changed files with 779 additions and 495 deletions
+85 -47
View File
@@ -1,11 +1,17 @@
"""
Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter
Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter.
Résilience :
- Timeout configurable (AI_REQUEST_TIMEOUT)
- Retry avec backoff exponentiel (AI_MAX_RETRIES)
- Client Gemini non-singleton (recréé si la clé change)
"""
import asyncio
import json
import logging
import re
import base64
import time
import httpx
import io
from pathlib import Path
@@ -19,14 +25,18 @@ from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
# Cache du client Gemini avec TTL (évite de recréer à chaque appel)
_client: Optional[genai.Client] = None
_client_api_key: Optional[str] = None
def _get_client() -> genai.Client:
global _client
if _client is None:
_client = genai.Client(api_key=settings.GEMINI_API_KEY)
"""Retourne un client Gemini. Recrée si la clé API a changé."""
global _client, _client_api_key
current_key = settings.GEMINI_API_KEY
if _client is None or _client_api_key != current_key:
_client = genai.Client(api_key=current_key)
_client_api_key = current_key
return _client
@@ -44,14 +54,7 @@ async def _read_image(file_path: str) -> tuple[bytes, str]:
}
media_type = mime_map.get(suffix, "image/jpeg")
# Utilisation du StorageBackend pour lire l'image
backend = get_storage_backend()
# On ruse un peu car StorageBackend n'a pas de 'read',
# mais on sait qu'en LocalStorage on peut lire en direct
# et en S3Storage on peut passer par les URLs ou aioboto3.
# Pour garder une abstraction propre, on va ajouter une méthode 'get_bytes' au backend.
data = await backend.get_bytes(file_path)
return data, media_type
@@ -76,13 +79,44 @@ def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]:
return prompt_tokens, output_tokens
async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs):
"""Exécute fn avec retry + backoff exponentiel en cas d'échec."""
retries = max_retries if max_retries is not None else settings.AI_MAX_RETRIES
last_error = None
for attempt in range(retries + 1):
try:
return await fn(*args, **kwargs)
except asyncio.TimeoutError:
last_error = "timeout"
wait = 2 ** attempt
logger.warning("ai.retry.timeout", extra={
"attempt": attempt + 1,
"max_retries": retries,
"wait_s": wait,
})
except Exception as e:
last_error = str(e)
wait = 2 ** attempt
logger.warning("ai.retry.error", extra={
"attempt": attempt + 1,
"max_retries": retries,
"wait_s": wait,
"error": str(e),
})
if attempt < retries:
await asyncio.sleep(wait)
raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}")
async def _generate_gemini(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
) -> dict:
"""Appel à Google Gemini via SDK."""
"""Appel à Google Gemini via SDK avec timeout et retry."""
if not settings.GEMINI_API_KEY:
logger.warning("ai.gemini.no_key")
return {"text": None, "usage": (None, None)}
@@ -93,17 +127,22 @@ async def _generate_gemini(
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
contents.append(prompt)
try:
# Le SDK est sync, on le run dans un thread
response = await asyncio.to_thread(
client.models.generate_content,
model=settings.GEMINI_MODEL,
contents=contents,
config=types.GenerateContentConfig(
max_output_tokens=max_tokens,
response_mime_type="application/json",
async def _call():
return await asyncio.wait_for(
asyncio.to_thread(
client.models.generate_content,
model=settings.GEMINI_MODEL,
contents=contents,
config=types.GenerateContentConfig(
max_output_tokens=max_tokens,
response_mime_type="application/json",
),
),
timeout=settings.AI_REQUEST_TIMEOUT,
)
try:
response = await _retry_with_backoff(_call)
usage = _usage_tokens_gemini(response)
return {"text": getattr(response, "text", ""), "usage": usage}
except Exception as e:
@@ -117,7 +156,7 @@ async def _generate_openrouter(
media_type: Optional[str] = None,
max_tokens: int = 1024
) -> dict:
"""Appel à OpenRouter via HTTP."""
"""Appel à OpenRouter via HTTP avec timeout et retry."""
if not settings.OPENROUTER_API_KEY:
logger.warning("ai.openrouter.no_key")
return {"text": None, "usage": (None, None)}
@@ -131,7 +170,6 @@ async def _generate_openrouter(
messages = []
content_payload = []
content_payload.append({"type": "text", "text": prompt})
if image_bytes and media_type:
@@ -152,30 +190,32 @@ async def _generate_openrouter(
"response_format": {"type": "json_object"}
}
async with httpx.AsyncClient() as client:
try:
async def _call():
async with httpx.AsyncClient() as client:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
timeout=60.0
timeout=settings.AI_REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens")
return response.json()
return {"text": text, "usage": (prompt_tokens, output_tokens)}
try:
data = await _retry_with_backoff(_call)
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
except Exception as e:
logger.error("ai.openrouter.error", extra={"error": str(e)})
return {"text": None, "usage": (None, None), "error": str(e)}
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)}
except Exception as e:
logger.error("ai.openrouter.error", extra={"error": str(e)})
return {"text": None, "usage": (None, None), "error": str(e)}
async def _generate(
@@ -187,11 +227,10 @@ async def _generate(
"""Dispatcher vers le bon provider."""
provider = settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider})
if provider == "openrouter":
return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens)
else:
# Default to Gemini
return await _generate_gemini(prompt, image_bytes, media_type, max_tokens)
@@ -264,9 +303,9 @@ async def analyze_image(
result["confidence"] = parsed.get("confidence")
else:
logger.warning("ai.vision.json_parse_failed", extra={"raw": text[:100]})
if response.get("error"):
logger.error("ai.vision.provider_error", extra={"error": response['error']})
logger.error("ai.vision.provider_error", extra={"error": response["error"]})
except Exception as e:
logger.error("ai.vision.unexpected_error", extra={"error": str(e)})
@@ -303,7 +342,7 @@ Retourne UNIQUEMENT un objet JSON :
}
Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
"""
response = await _generate(
prompt=prompt,
image_bytes=image_bytes,
@@ -411,12 +450,11 @@ Retourne UNIQUEMENT ce JSON :
}}"""
try:
# Pas d'image ici
response = await _generate(
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text")
if text:
parsed = _extract_json(text)
+35 -26
View File
@@ -2,6 +2,7 @@
Service de stockage — sauvegarde fichiers, génération thumbnails
Multi-tenant : les fichiers sont isolés par client_id.
"""
import asyncio
import uuid
import logging
import io
@@ -33,6 +34,9 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
"""
Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Utilise le backend de stockage configuré (Local ou S3).
L'ordre est : validation → upload fichier → génération thumbnail → retourne les métadonnées.
L'appelant est responsable de l'insertion BDD et du rollback en cas d'échec.
"""
# ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES:
@@ -53,7 +57,7 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
# ── Nommage ───────────────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image")
# Chemins relatifs par rapport au bucket/base_dir
rel_file_path = f"uploads/{client_id}/{filename}"
rel_thumb_path = f"thumbnails/{client_id}/thumb_{filename}"
@@ -67,26 +71,28 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
width, height = None, None
thumb_saved = False
try:
# On utilise io.BytesIO pour ne pas avoir à écrire sur le disque local
with PILImage.open(io.BytesIO(content)) as img:
width, height = img.size
img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS)
# Convertit en RGB si nécessaire
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Sauvegarde thumbnail dans un buffer
thumb_buffer = io.BytesIO()
img.save(thumb_buffer, "JPEG", quality=85)
thumb_data = thumb_buffer.getvalue()
# Sauvegarde via le backend
await backend.save(thumb_data, rel_thumb_path, "image/jpeg")
thumb_saved = True
except Exception as e:
logger.warning("Erreur génération thumbnail : %s", e)
logger.warning("thumbnail.generation_error", extra={
"file": rel_file_path,
"error": str(e),
})
return {
"uuid": file_uuid,
@@ -103,24 +109,27 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
}
def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""Supprime le fichier original et son thumbnail via le backend."""
import asyncio
async def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""
Supprime le fichier original et son thumbnail via le backend de stockage.
Entièrement asynchrone — les fichiers sont supprimés de façon déterministe.
"""
backend = get_storage_backend()
async def _do_delete():
await backend.delete(file_path)
if thumbnail_path:
await backend.delete(thumbnail_path)
# Note: delete_files est synchrone dans les routers existants,
# mais le backend est async. C'est un risque.
# TODO: Refactorer delete_image pour être full async.
errors: list[str] = []
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_do_delete())
else:
loop.run_until_complete(_do_delete())
except Exception:
pass
await backend.delete(file_path)
except Exception as e:
errors.append(f"original: {e}")
if thumbnail_path:
try:
await backend.delete(thumbnail_path)
except Exception as e:
errors.append(f"thumbnail: {e}")
if errors:
logger.warning("storage.delete_partial", extra={
"file_path": file_path,
"errors": errors,
})