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.
This commit is contained in:
+17
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user