Mise à jour de la configuration de la base de données pour utiliser PostgreSQL et ajout de Redis dans le fichier .env. Modifications du Dockerfile pour installer les dépendances nécessaires. Amélioration des services de stockage pour supporter les opérations asynchrones avec S3 et le stockage local. Refactorisation des pipelines d'images pour une meilleure gestion des tâches asynchrones. Ajout de la gestion des clés API dans l'authentification. Mise à jour de la documentation et des exemples d'utilisation.
CI / Lint & Format (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

This commit is contained in:
2026-02-24 16:19:18 -05:00
parent cc99fea20a
commit d68deb9c74
20 changed files with 235 additions and 285 deletions
+17 -11
View File
@@ -7,6 +7,7 @@ import logging
import re
import base64
import httpx
import io
from pathlib import Path
from typing import Optional, Tuple
@@ -14,6 +15,7 @@ from google import genai
from google.genai import types
from app.config import settings
from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
@@ -28,8 +30,8 @@ def _get_client() -> genai.Client:
return _client
def _read_image(file_path: str) -> tuple[bytes, str]:
"""Lit l'image en bytes et détecte le media_type."""
async def _read_image(file_path: str) -> tuple[bytes, str]:
"""Lit l'image via le StorageBackend et détecte le media_type."""
path = Path(file_path)
suffix = path.suffix.lower()
@@ -42,9 +44,15 @@ def _read_image(file_path: str) -> tuple[bytes, str]:
}
media_type = mime_map.get(suffix, "image/jpeg")
with open(path, "rb") as f:
data = f.read()
# 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
@@ -141,8 +149,6 @@ async def _generate_openrouter(
"model": settings.OPENROUTER_MODEL,
"messages": messages,
"max_tokens": max_tokens,
# OpenRouter/OpenAI support response_format={"type": "json_object"} pour certains modèles
# On tente le coup si le modèle est compatible, sinon le prompt engineering fait le travail
"response_format": {"type": "json_object"}
}
@@ -237,14 +243,14 @@ async def analyze_image(
}
try:
image_bytes, media_type = _read_image(file_path)
image_bytes, media_type = await _read_image(file_path)
prompt = _build_prompt(ocr_hint, language)
response = await _generate(
prompt=prompt,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS # Ou une config unifiée
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text")
@@ -286,7 +292,7 @@ async def extract_text_with_ai(file_path: str) -> dict:
logger.info("ai.ocr.fallback_start", extra={"file": Path(file_path).name})
try:
image_bytes, media_type = _read_image(file_path)
image_bytes, media_type = await _read_image(file_path)
prompt = """Agis comme un moteur OCR avancé.
Extrais TOUT le texte visible dans cette image.
Retourne UNIQUEMENT un objet JSON :
@@ -399,6 +405,7 @@ Retourne UNIQUEMENT ce JSON :
}}"""
try:
# Pas d'image ici
response = await _generate(
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
@@ -414,4 +421,3 @@ Retourne UNIQUEMENT ce JSON :
logger.error("ai.draft_task.error", extra={"error": str(e)})
return result
+14 -11
View File
@@ -2,16 +2,19 @@
Service d'extraction EXIF — Pillow + piexif
"""
import logging
import io
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
import piexif
from PIL import Image as PILImage
from PIL.ExifTags import TAGS, GPSTAGS
from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
def _dms_to_decimal(dms: tuple, ref: str) -> float | None:
"""Convertit les coordonnées GPS DMS (degrés/minutes/secondes) en décimal."""
@@ -49,10 +52,10 @@ def _safe_str(value: Any) -> str | None:
return str(value)
def extract_exif(file_path: str) -> dict:
async def extract_exif(file_path: str) -> dict:
"""
Extrait toutes les métadonnées EXIF d'une image.
Retourne un dict structuré avec les données parsées.
Supporte Local et S3 via StorageBackend.
"""
result = {
"raw": {},
@@ -73,15 +76,15 @@ def extract_exif(file_path: str) -> dict:
}
try:
path = Path(file_path)
if not path.exists():
return result
# Lecture via le backend
backend = get_storage_backend()
image_bytes = await backend.get_bytes(file_path)
# ── Lecture EXIF brute via piexif ─────────────────────
try:
exif_data = piexif.load(str(path))
exif_data = piexif.load(image_bytes)
except Exception:
# JPEG sans EXIF, PNG, etc.
# Image sans EXIF
return result
raw_dict = {}
@@ -155,7 +158,7 @@ def extract_exif(file_path: str) -> dict:
pass
# ── Dict brut lisible (TAGS humains) ──────────────────
with PILImage.open(path) as img:
with PILImage.open(io.BytesIO(image_bytes)) as img:
raw_exif = img._getexif()
if raw_exif:
for tag_id, val in raw_exif.items():
+9 -7
View File
@@ -2,9 +2,11 @@
Service OCR — extraction de texte via Tesseract
"""
import logging
import io
from pathlib import Path
from PIL import Image as PILImage
from app.config import settings
from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
@@ -35,10 +37,10 @@ def _detect_language(text: str) -> str:
return "fr" if fr_score >= en_score else "en"
def extract_text(file_path: str) -> dict:
async def extract_text(file_path: str) -> dict:
"""
Extrait le texte d'une image via Tesseract OCR.
Retourne un dict avec le texte, la langue et le score de confiance.
Supporte Local et S3 via StorageBackend (lecture en mémoire).
"""
result = {
"text": None,
@@ -54,16 +56,16 @@ def extract_text(file_path: str) -> dict:
logger.warning("ocr.unavailable", extra={"error": str(_ocr_import_error)})
return result
path = Path(file_path)
if not path.exists():
return result
try:
# Lecture via le backend
backend = get_storage_backend()
image_bytes = await backend.get_bytes(file_path)
# Configuration Tesseract
if settings.TESSERACT_CMD:
pytesseract.pytesseract.tesseract_cmd = settings.TESSERACT_CMD
with PILImage.open(path) as img:
with PILImage.open(io.BytesIO(image_bytes)) as img:
# Convertit en RGB si nécessaire
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
+8 -10
View File
@@ -22,12 +22,6 @@ import asyncio
logger = logging.getLogger(__name__)
async def _run_sync_in_thread(func: Any, *args: Any) -> Any:
"""Exécute une fonction synchrone dans un thread pour ne pas bloquer l'event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
async def _publish_event(
redis: Any, image_id: int, event: str, data: dict | None = None
) -> None:
@@ -48,8 +42,8 @@ async def process_image_pipeline(
) -> None:
"""
Pipeline complet de traitement d'une image :
1. Extraction EXIF (sync → thread)
2. OCR — extraction texte (sync → thread)
1. Extraction EXIF (async)
2. OCR — extraction texte (async)
3. Vision AI — description + tags (async)
4. Sauvegarde finale en BDD
@@ -81,7 +75,9 @@ async def process_image_pipeline(
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "exif", "step_num": "1/3"})
t0 = time.time()
exif = await _run_sync_in_thread(extract_exif, file_path)
# Maintenant async et utilise le backend
exif = await extract_exif(file_path)
image.exif_raw = exif.get("raw")
image.exif_make = exif.get("make")
@@ -117,7 +113,9 @@ async def process_image_pipeline(
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ocr", "step_num": "2/3"})
t0 = time.time()
ocr = await _run_sync_in_thread(extract_text, file_path)
# Maintenant async et utilise le backend
ocr = await extract_text(file_path)
# Fallback AI si OCR classique échoue ou ne trouve rien
if not ocr.get("has_text", False):
+47 -44
View File
@@ -4,12 +4,13 @@ Multi-tenant : les fichiers sont isolés par client_id.
"""
import uuid
import logging
import aiofiles
import io
from pathlib import Path
from datetime import datetime, timezone
from PIL import Image as PILImage
from fastapi import UploadFile, HTTPException, status
from app.config import settings
from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
@@ -28,25 +29,10 @@ def _generate_filename(original: str) -> tuple[str, str]:
return f"{uid}{suffix}", uid
def _get_client_upload_path(client_id: str) -> Path:
"""Retourne le répertoire d'upload pour un client donné."""
p = settings.upload_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
def _get_client_thumbnails_path(client_id: str) -> Path:
"""Retourne le répertoire de thumbnails pour un client donné."""
p = settings.thumbnails_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
async def save_upload(file: UploadFile, client_id: str) -> dict:
"""
Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Les fichiers sont stockés dans uploads/{client_id}/ pour l'isolation.
Retourne un dict avec toutes les métadonnées fichier.
Utilise le backend de stockage configuré (Local ou S3).
"""
# ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES:
@@ -65,40 +51,49 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
detail=f"Fichier trop volumineux. Max : {settings.MAX_UPLOAD_SIZE_MB} MB",
)
# ── Nommage et chemins ────────────────────────────────────
# ── Nommage ───────────────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image")
upload_dir = _get_client_upload_path(client_id)
thumb_dir = _get_client_thumbnails_path(client_id)
# 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}"
file_path = upload_dir / filename
thumb_filename = f"thumb_{filename}"
thumb_path = thumb_dir / thumb_filename
backend = get_storage_backend()
# ── Sauvegarde fichier original ───────────────────────────
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
await backend.save(content, rel_file_path, file.content_type)
# ── Dimensions + thumbnail ────────────────────────────────
width, height = None, None
thumb_saved = False
try:
with PILImage.open(file_path) as img:
# 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 (ex: PNG RGBA)
# Convertit en RGB si nécessaire
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(thumb_path, "JPEG", quality=85)
# 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:
# Thumbnail non bloquant
thumb_path = None
logger.warning("Erreur génération thumbnail : %s", e)
return {
"uuid": file_uuid,
"original_name": file.filename,
"filename": filename,
"file_path": str(file_path),
"thumbnail_path": str(thumb_path) if thumb_path else None,
"file_path": rel_file_path,
"thumbnail_path": rel_thumb_path if thumb_saved else None,
"mime_type": file.content_type,
"file_size": len(content),
"width": width,
@@ -109,15 +104,23 @@ 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 du disque."""
for path_str in [file_path, thumbnail_path]:
if path_str:
p = Path(path_str)
if p.exists():
p.unlink()
def get_image_url(filename: str, client_id: str, thumb: bool = False) -> str:
"""Construit l'URL publique d'une image."""
prefix = "thumbnails" if thumb else "uploads"
return f"/static/{prefix}/{client_id}/{filename}"
"""Supprime le fichier original et son thumbnail via le backend."""
import asyncio
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.
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
+28 -1
View File
@@ -44,6 +44,10 @@ class StorageBackend(ABC):
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
@abstractmethod
async def get_bytes(self, path: str) -> bytes:
"""Lit le contenu d'un fichier en bytes."""
class LocalStorage(StorageBackend):
"""Stockage sur disque local avec URLs signées HMAC."""
@@ -101,6 +105,12 @@ class LocalStorage(StorageBackend):
return full.stat().st_size
return 0
async def get_bytes(self, path: str) -> bytes:
"""Lit un fichier local."""
full = self._full_path(path)
async with aiofiles.open(full, "rb") as f:
return await f.read()
def get_absolute_path(self, path: str) -> Path:
"""Retourne le chemin absolu d'un fichier (pour FileResponse)."""
return self._full_path(path)
@@ -139,9 +149,15 @@ class S3Storage(StorageBackend):
)
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Upload vers S3/MinIO."""
"""Upload vers S3/MinIO. Crée le bucket si nécessaire."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
# Vérifier/Créer le bucket
try:
await client.head_bucket(Bucket=self._bucket)
except Exception:
await client.create_bucket(Bucket=self._bucket)
await client.put_object(
Bucket=self._bucket,
Key=self._s3_key(path),
@@ -196,6 +212,17 @@ class S3Storage(StorageBackend):
except Exception:
return 0
async def get_bytes(self, path: str) -> bytes:
"""Télécharge un objet S3/MinIO."""
session = self._get_session()
async with session.client("s3", endpoint_url=self._endpoint_url) as client:
resp = await client.get_object(
Bucket=self._bucket,
Key=self._s3_key(path),
)
async with resp["Body"] as stream:
return await stream.read()
def get_storage_backend() -> StorageBackend:
"""Factory : retourne le backend de stockage configuré (singleton)."""