First commit
This commit is contained in:
34
api/Dockerfile
Normal file
34
api/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Installation des dépendances système
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
python3-dev \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-fra \
|
||||
tesseract-ocr-eng \
|
||||
libgl1-mesa-glx \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copie et installation des dépendances Python
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copie du code de l'application
|
||||
COPY ./app /app/app
|
||||
COPY ./app/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Variables d'environnement
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Exposition du port
|
||||
EXPOSE 8000
|
||||
|
||||
# Commande d'exécution
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
3
api/app/__init__.py
Normal file
3
api/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Package pour l'API Cheque Scanner
|
||||
"""
|
||||
69
api/app/config.py
Normal file
69
api/app/config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Configuration de l'API Cheque Scanner
|
||||
"""
|
||||
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger les variables d'environnement depuis le fichier .env
|
||||
load_dotenv()
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration de l'application"""
|
||||
|
||||
# Informations de base
|
||||
APP_NAME: str = "Cheque Scanner API"
|
||||
API_VERSION: str = "v1"
|
||||
API_PREFIX: str = f"/api/{API_VERSION}"
|
||||
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
|
||||
|
||||
# Sécurité
|
||||
API_KEY: str = os.getenv("API_KEY", "your-secret-api-key")
|
||||
|
||||
# Configuration du serveur
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# Configuration Redis
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "redis")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}")
|
||||
|
||||
# Configuration des files d'attente
|
||||
QUEUE_NAME: str = os.getenv("QUEUE_NAME", "cheque_processing")
|
||||
HIGH_PRIORITY_QUEUE_NAME: str = os.getenv("HIGH_PRIORITY_QUEUE_NAME", "cheque_processing_high")
|
||||
|
||||
# Configuration du stockage
|
||||
UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER", "/app/data/uploads")
|
||||
RESULT_FOLDER: str = os.getenv("RESULT_FOLDER", "/app/data/results")
|
||||
TEMP_FOLDER: str = os.getenv("TEMP_FOLDER", "/app/data/tmp")
|
||||
MAX_CONTENT_LENGTH: int = int(os.getenv("MAX_CONTENT_LENGTH", "16777216")) # 16MB
|
||||
ALLOWED_EXTENSIONS: set = {"png", "jpg", "jpeg", "gif", "tiff", "pdf"}
|
||||
|
||||
# Configuration du traitement d'image
|
||||
DEFAULT_OCR_LANGUAGE: str = os.getenv("DEFAULT_OCR_LANGUAGE", "eng")
|
||||
ALTERNATIVE_OCR_LANGUAGE: str = os.getenv("ALTERNATIVE_OCR_LANGUAGE", "fra")
|
||||
TESSERACT_DATA_PATH: str = os.getenv("TESSERACT_DATA_PATH", "/usr/share/tesseract-ocr/4.00/tessdata")
|
||||
|
||||
# Délais et timeouts
|
||||
JOB_TIMEOUT: int = int(os.getenv("JOB_TIMEOUT", "300")) # 5 minutes
|
||||
RESULT_TTL: int = int(os.getenv("RESULT_TTL", "86400")) # 24 heures
|
||||
|
||||
# Limites
|
||||
MAX_WORKERS: int = int(os.getenv("MAX_WORKERS", "3"))
|
||||
RATE_LIMIT: int = int(os.getenv("RATE_LIMIT", "100")) # requêtes par heure
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# Instance de configuration globale
|
||||
settings = Settings()
|
||||
|
||||
# Créer les dossiers nécessaires s'ils n'existent pas
|
||||
os.makedirs(settings.UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(settings.RESULT_FOLDER, exist_ok=True)
|
||||
os.makedirs(settings.TEMP_FOLDER, exist_ok=True)
|
||||
45
api/app/dependencies.py
Normal file
45
api/app/dependencies.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Dépendances pour l'API FastAPI
|
||||
"""
|
||||
|
||||
import redis
|
||||
from fastapi import Depends, HTTPException, Header
|
||||
from typing import Optional
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def get_redis_connection():
|
||||
"""
|
||||
Crée et retourne une connexion à Redis
|
||||
"""
|
||||
try:
|
||||
conn = redis.Redis.from_url(settings.REDIS_URL)
|
||||
yield conn
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur de connexion à Redis: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
# Fermer la connexion
|
||||
pass # La connexion est fermée automatiquement
|
||||
|
||||
|
||||
async def verify_api_key(x_api_key: Optional[str] = Header(None)):
|
||||
"""
|
||||
Vérifie que la clé API fournie est valide
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Clé API manquante"
|
||||
)
|
||||
|
||||
if x_api_key != settings.API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Clé API invalide"
|
||||
)
|
||||
|
||||
return x_api_key
|
||||
11
api/app/entrypoint.sh
Normal file
11
api/app/entrypoint.sh
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Attendre que Redis soit disponible
|
||||
echo "Démarrage de l'API..."
|
||||
# Ajouter un petit délai pour s'assurer que Redis a eu le temps de démarrer
|
||||
sleep 5
|
||||
echo "Redis devrait être disponible maintenant!"
|
||||
|
||||
# Exécuter la commande spécifiée
|
||||
exec "$@"
|
||||
321
api/app/main.py
Normal file
321
api/app/main.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
API principale pour le service d'extraction d'informations de chèques
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import shutil
|
||||
from typing import List, Optional
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Header, BackgroundTasks, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from datetime import datetime, timedelta
|
||||
import redis
|
||||
from rq import Queue
|
||||
from rq.job import Job
|
||||
from rq.registry import StartedJobRegistry, FinishedJobRegistry, FailedJobRegistry
|
||||
import logging
|
||||
|
||||
from .config import settings
|
||||
from .schemas import (
|
||||
UploadResponse, JobStatusResponse, JobResult,
|
||||
ExtractionResult, HealthCheck, ErrorResponse, JobStatus
|
||||
)
|
||||
from .dependencies import verify_api_key, get_redis_connection
|
||||
from .tasks import process_cheque_image
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger("cheque_scanner_api")
|
||||
|
||||
# Création de l'application FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="API pour l'extraction d'informations de chèques à partir d'images",
|
||||
version=settings.API_VERSION,
|
||||
docs_url=f"{settings.API_PREFIX}/docs",
|
||||
redoc_url=f"{settings.API_PREFIX}/redoc",
|
||||
openapi_url=f"{settings.API_PREFIX}/openapi.json"
|
||||
)
|
||||
|
||||
# Middleware CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # À ajuster en production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Configuration des fichiers statiques pour accéder aux images de résultats
|
||||
app.mount("/static", StaticFiles(directory=settings.RESULT_FOLDER), name="static")
|
||||
|
||||
# Variable pour stocker le temps de démarrage
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
@app.get(f"{settings.API_PREFIX}/health", response_model=HealthCheck, tags=["Système"])
|
||||
async def health_check(redis_conn: redis.Redis = Depends(get_redis_connection)):
|
||||
"""
|
||||
Vérifie l'état de santé de l'API et des services associés
|
||||
"""
|
||||
# Vérifier la connexion Redis
|
||||
try:
|
||||
redis_conn.ping()
|
||||
redis_status = "ok"
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur Redis: {str(e)}")
|
||||
redis_status = f"error: {str(e)}"
|
||||
|
||||
# Obtenir le nombre de workers actifs
|
||||
registry = StartedJobRegistry(settings.QUEUE_NAME, connection=redis_conn)
|
||||
worker_count = len(registry.get_job_ids())
|
||||
|
||||
# Obtenir la taille de la file d'attente
|
||||
queue = Queue(settings.QUEUE_NAME, connection=redis_conn)
|
||||
queue_size = len(queue.get_job_ids())
|
||||
|
||||
# Calculer le temps de fonctionnement
|
||||
uptime_seconds = time.time() - start_time
|
||||
days, remainder = divmod(uptime_seconds, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
uptime = f"{int(days)}d {int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
|
||||
return HealthCheck(
|
||||
status="ok",
|
||||
version=settings.API_VERSION,
|
||||
redis_status=redis_status,
|
||||
worker_count=worker_count,
|
||||
queue_size=queue_size,
|
||||
uptime=uptime
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
f"{settings.API_PREFIX}/upload",
|
||||
response_model=UploadResponse,
|
||||
tags=["Extraction"],
|
||||
status_code=202
|
||||
)
|
||||
async def upload_image(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
priority: bool = Query(False, description="Traiter avec une priorité élevée"),
|
||||
api_key: str = Depends(verify_api_key),
|
||||
redis_conn: redis.Redis = Depends(get_redis_connection)
|
||||
):
|
||||
"""
|
||||
Télécharge une image de chèque et lance son traitement
|
||||
"""
|
||||
# Vérifier l'extension du fichier
|
||||
file_ext = os.path.splitext(file.filename)[1].lower().lstrip(".")
|
||||
if file_ext not in settings.ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Format de fichier non pris en charge. Formats acceptés: {', '.join(settings.ALLOWED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# Créer un identifiant unique pour la tâche
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
# Créer le chemin de fichier
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{timestamp}_{job_id}.{file_ext}"
|
||||
file_path = os.path.join(settings.UPLOAD_FOLDER, filename)
|
||||
|
||||
# Sauvegarder le fichier
|
||||
try:
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la sauvegarde du fichier: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur lors de la sauvegarde de l'image: {str(e)}"
|
||||
)
|
||||
|
||||
# Déterminer la file d'attente en fonction de la priorité
|
||||
queue_name = settings.HIGH_PRIORITY_QUEUE_NAME if priority else settings.QUEUE_NAME
|
||||
queue = Queue(queue_name, connection=redis_conn)
|
||||
|
||||
# Créer une tâche RQ
|
||||
try:
|
||||
# Utiliser directement le nom complet du module dans le worker
|
||||
queue_job = queue.enqueue(
|
||||
'tasks.process_cheque_image',
|
||||
job_id,
|
||||
file_path,
|
||||
result_ttl=settings.RESULT_TTL,
|
||||
timeout=settings.JOB_TIMEOUT
|
||||
)
|
||||
|
||||
# Stocker les métadonnées dans Redis
|
||||
redis_conn.hset(f"job:{job_id}", mapping={
|
||||
"status": JobStatus.PENDING.value,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"file_path": file_path,
|
||||
"filename": file.filename,
|
||||
"priority": str(priority).lower()
|
||||
})
|
||||
|
||||
logger.info(f"Tâche créée: {job_id} - Fichier: {file.filename}")
|
||||
|
||||
return UploadResponse(
|
||||
job_id=job_id,
|
||||
status=JobStatus.PENDING,
|
||||
message=f"Image en file d'attente pour traitement (file {'prioritaire' if priority else 'standard'})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la création de la tâche: {str(e)}")
|
||||
# Supprimer le fichier en cas d'erreur
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Erreur lors de la création de la tâche: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
f"{settings.API_PREFIX}/status/{{job_id}}",
|
||||
response_model=JobStatusResponse,
|
||||
tags=["Extraction"]
|
||||
)
|
||||
async def get_job_status(
|
||||
job_id: str,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
redis_conn: redis.Redis = Depends(get_redis_connection)
|
||||
):
|
||||
"""
|
||||
Vérifie l'état d'une tâche d'extraction
|
||||
"""
|
||||
# Vérifier si la tâche existe
|
||||
if not redis_conn.exists(f"job:{job_id}"):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Tâche non trouvée: {job_id}"
|
||||
)
|
||||
|
||||
# Récupérer les métadonnées de la tâche
|
||||
job_data = {k.decode(): v.decode() for k, v in redis_conn.hgetall(f"job:{job_id}").items()}
|
||||
|
||||
# Récupérer l'état de la tâche RQ
|
||||
queue_name = settings.HIGH_PRIORITY_QUEUE_NAME if job_data.get("priority") == "true" else settings.QUEUE_NAME
|
||||
queue = Queue(queue_name, connection=redis_conn)
|
||||
|
||||
status = job_data.get("status", JobStatus.PENDING.value)
|
||||
message = job_data.get("message", "En attente de traitement")
|
||||
|
||||
# Vérifier si la tâche est dans différentes files d'attente
|
||||
progress = None
|
||||
queue_position = None
|
||||
|
||||
# Si la tâche est en attente, déterminer sa position dans la file
|
||||
if status == JobStatus.PENDING.value:
|
||||
job_ids = queue.get_job_ids()
|
||||
if job_id in job_ids:
|
||||
queue_position = job_ids.index(job_id) + 1
|
||||
|
||||
# Si la tâche est en cours, récupérer la progression
|
||||
elif status == JobStatus.PROCESSING.value:
|
||||
progress = job_data.get("progress")
|
||||
if progress:
|
||||
progress = int(progress)
|
||||
|
||||
return JobStatusResponse(
|
||||
job_id=job_id,
|
||||
status=JobStatus(status),
|
||||
message=message,
|
||||
created_at=datetime.fromisoformat(job_data.get("created_at")),
|
||||
updated_at=datetime.fromisoformat(job_data.get("updated_at")) if "updated_at" in job_data else None,
|
||||
progress=progress,
|
||||
queue_position=queue_position
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
f"{settings.API_PREFIX}/result/{{job_id}}",
|
||||
response_model=JobResult,
|
||||
tags=["Extraction"]
|
||||
)
|
||||
async def get_job_result(
|
||||
job_id: str,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
redis_conn: redis.Redis = Depends(get_redis_connection)
|
||||
):
|
||||
"""
|
||||
Récupère les résultats d'une tâche d'extraction terminée
|
||||
"""
|
||||
# Vérifier si la tâche existe
|
||||
if not redis_conn.exists(f"job:{job_id}"):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Tâche non trouvée: {job_id}"
|
||||
)
|
||||
|
||||
# Récupérer les métadonnées de la tâche
|
||||
job_data = {k.decode(): v.decode() for k, v in redis_conn.hgetall(f"job:{job_id}").items()}
|
||||
|
||||
# Vérifier si la tâche est terminée
|
||||
status = JobStatus(job_data.get("status", JobStatus.PENDING.value))
|
||||
if status != JobStatus.COMPLETED and status != JobStatus.FAILED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La tâche n'est pas encore terminée. Statut actuel: {status.value}"
|
||||
)
|
||||
|
||||
# Récupérer les résultats d'extraction si disponibles
|
||||
result = None
|
||||
texte_brut = None
|
||||
|
||||
if status == JobStatus.COMPLETED:
|
||||
# Charger les résultats depuis Redis
|
||||
result_data = job_data.get("result")
|
||||
if result_data:
|
||||
result_dict = eval(result_data) # Attention: eval n'est pas sécurisé en production
|
||||
result = ExtractionResult(**result_dict)
|
||||
|
||||
texte_brut = job_data.get("texte_brut")
|
||||
|
||||
return JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
created_at=datetime.fromisoformat(job_data.get("created_at")),
|
||||
completed_at=datetime.fromisoformat(job_data.get("completed_at")) if "completed_at" in job_data else None,
|
||||
image_path=job_data.get("file_path"),
|
||||
result=result,
|
||||
texte_brut=texte_brut,
|
||||
methode=job_data.get("methode", "inconnu"),
|
||||
erreur=job_data.get("erreur") if status == JobStatus.FAILED else None
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Gestionnaire d'exceptions global"""
|
||||
logger.error(f"Exception non gérée: {str(exc)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
message="Erreur interne du serveur",
|
||||
error_code="INTERNAL_SERVER_ERROR",
|
||||
details={"error": str(exc)}
|
||||
).dict()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG
|
||||
)
|
||||
77
api/app/schemas.py
Normal file
77
api/app/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Schémas Pydantic pour la validation et la sérialisation des données
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Statuts possibles pour une tâche d'extraction"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
"""Réponse à une demande d'upload d'image"""
|
||||
job_id: str = Field(..., description="Identifiant unique de la tâche")
|
||||
status: JobStatus = Field(default=JobStatus.PENDING, description="Statut de la tâche")
|
||||
message: str = Field(default="Image en file d'attente pour traitement", description="Message d'information")
|
||||
created_at: datetime = Field(default_factory=datetime.now, description="Date de création de la tâche")
|
||||
|
||||
|
||||
class JobStatusResponse(BaseModel):
|
||||
"""Réponse à une demande de statut de tâche"""
|
||||
job_id: str = Field(..., description="Identifiant unique de la tâche")
|
||||
status: JobStatus = Field(..., description="Statut de la tâche")
|
||||
message: str = Field(..., description="Message d'information")
|
||||
created_at: datetime = Field(..., description="Date de création de la tâche")
|
||||
updated_at: Optional[datetime] = Field(None, description="Date de dernière mise à jour")
|
||||
progress: Optional[int] = Field(None, description="Progression en pourcentage (0-100)")
|
||||
queue_position: Optional[int] = Field(None, description="Position dans la file d'attente")
|
||||
|
||||
|
||||
class ExtractionResult(BaseModel):
|
||||
"""Résultat de l'extraction d'informations d'un chèque"""
|
||||
montant: Optional[str] = Field(None, description="Montant du chèque")
|
||||
date: Optional[str] = Field(None, description="Date du chèque")
|
||||
beneficiaire: Optional[str] = Field(None, description="Bénéficiaire du chèque")
|
||||
numero_cheque: Optional[str] = Field(None, description="Numéro du chèque")
|
||||
qualite_extraction: Optional[str] = Field(None, description="Qualité de l'extraction (échec, faible, moyenne, bonne)")
|
||||
image_zones: Optional[str] = Field(None, description="Chemin vers l'image avec les zones identifiées")
|
||||
|
||||
|
||||
class JobResult(BaseModel):
|
||||
"""Résultat complet d'une tâche d'extraction"""
|
||||
job_id: str = Field(..., description="Identifiant unique de la tâche")
|
||||
status: JobStatus = Field(..., description="Statut de la tâche")
|
||||
created_at: datetime = Field(..., description="Date de création de la tâche")
|
||||
completed_at: Optional[datetime] = Field(None, description="Date de complétion de la tâche")
|
||||
image_path: str = Field(..., description="Chemin de l'image originale")
|
||||
result: Optional[ExtractionResult] = Field(None, description="Résultats de l'extraction")
|
||||
texte_brut: Optional[str] = Field(None, description="Texte brut extrait (si disponible)")
|
||||
methode: str = Field(..., description="Méthode utilisée (ocr ou cv)")
|
||||
erreur: Optional[str] = Field(None, description="Message d'erreur (si échec)")
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
"""Réponse du health check"""
|
||||
status: str = Field(default="ok", description="Statut du service")
|
||||
version: str = Field(..., description="Version de l'API")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="Horodatage de la vérification")
|
||||
redis_status: str = Field(..., description="Statut de la connexion Redis")
|
||||
worker_count: int = Field(..., description="Nombre de workers disponibles")
|
||||
queue_size: int = Field(..., description="Nombre de tâches en attente")
|
||||
uptime: str = Field(..., description="Temps de fonctionnement du service")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Réponse en cas d'erreur"""
|
||||
status: str = Field(default="error", description="Statut de la réponse")
|
||||
message: str = Field(..., description="Message d'erreur")
|
||||
error_code: Optional[str] = Field(None, description="Code d'erreur")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="Détails supplémentaires")
|
||||
228
api/app/tasks.py
Normal file
228
api/app/tasks.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Tâches de traitement pour l'API Cheque Scanner
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
import redis
|
||||
from rq import get_current_job
|
||||
|
||||
# Ajouter le module d'extraction au path
|
||||
sys.path.append('/app/shared')
|
||||
|
||||
# Importer les fonctions d'extraction
|
||||
from extraction import (
|
||||
extraire_infos_cheque,
|
||||
get_tessdata_path
|
||||
)
|
||||
|
||||
from .config import settings
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger("cheque_scanner_tasks")
|
||||
|
||||
|
||||
def update_job_status(job_id, status, message=None, progress=None, result=None, texte_brut=None, erreur=None, methode=None):
|
||||
"""
|
||||
Met à jour le statut d'une tâche dans Redis
|
||||
"""
|
||||
try:
|
||||
# Connexion à Redis
|
||||
redis_conn = redis.Redis.from_url(settings.REDIS_URL)
|
||||
|
||||
# Préparer les données à mettre à jour
|
||||
update_data = {
|
||||
"status": status,
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if message:
|
||||
update_data["message"] = message
|
||||
|
||||
if progress:
|
||||
update_data["progress"] = str(progress)
|
||||
|
||||
if result:
|
||||
update_data["result"] = str(result)
|
||||
|
||||
if texte_brut:
|
||||
update_data["texte_brut"] = texte_brut
|
||||
|
||||
if erreur:
|
||||
update_data["erreur"] = erreur
|
||||
|
||||
if methode:
|
||||
update_data["methode"] = methode
|
||||
|
||||
if status == "completed":
|
||||
update_data["completed_at"] = datetime.now().isoformat()
|
||||
|
||||
# Mettre à jour les données dans Redis
|
||||
redis_conn.hset(f"job:{job_id}", mapping=update_data)
|
||||
|
||||
logger.info(f"Statut de la tâche {job_id} mis à jour: {status}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la mise à jour du statut de la tâche {job_id}: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def process_cheque_image(job_id, file_path):
|
||||
"""
|
||||
Traite une image de chèque pour en extraire les informations
|
||||
|
||||
Args:
|
||||
job_id (str): Identifiant de la tâche
|
||||
file_path (str): Chemin vers l'image à traiter
|
||||
|
||||
Returns:
|
||||
dict: Résultat de l'extraction
|
||||
"""
|
||||
job = get_current_job()
|
||||
logger.info(f"Début du traitement de l'image: {file_path} (Tâche: {job_id})")
|
||||
|
||||
# Mettre à jour le statut
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message="Traitement en cours",
|
||||
progress=10
|
||||
)
|
||||
|
||||
try:
|
||||
# Vérifier que le fichier existe
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"L'image {file_path} n'existe pas")
|
||||
|
||||
# Récupérer le chemin vers tessdata
|
||||
tessdata_path = settings.TESSERACT_DATA_PATH
|
||||
if not os.path.exists(tessdata_path):
|
||||
# Essayer de trouver automatiquement
|
||||
tessdata_path = get_tessdata_path()
|
||||
|
||||
# Mise à jour intermédiaire
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message="Extraction des informations en cours",
|
||||
progress=30
|
||||
)
|
||||
|
||||
# Première tentative avec la langue par défaut
|
||||
try:
|
||||
logger.info(f"Tentative d'extraction avec la langue: {settings.DEFAULT_OCR_LANGUAGE}")
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message=f"Extraction avec langue {settings.DEFAULT_OCR_LANGUAGE}",
|
||||
progress=50
|
||||
)
|
||||
|
||||
infos, texte = extraire_infos_cheque(
|
||||
chemin_image=file_path,
|
||||
methode="ocr",
|
||||
language=settings.DEFAULT_OCR_LANGUAGE,
|
||||
tessdata=tessdata_path
|
||||
)
|
||||
|
||||
methode = "ocr"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Échec de la première tentative: {str(e)}")
|
||||
|
||||
# Deuxième tentative avec la langue alternative
|
||||
try:
|
||||
logger.info(f"Tentative d'extraction avec la langue: {settings.ALTERNATIVE_OCR_LANGUAGE}")
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message=f"Extraction avec langue {settings.ALTERNATIVE_OCR_LANGUAGE}",
|
||||
progress=60
|
||||
)
|
||||
|
||||
infos, texte = extraire_infos_cheque(
|
||||
chemin_image=file_path,
|
||||
methode="ocr",
|
||||
language=settings.ALTERNATIVE_OCR_LANGUAGE,
|
||||
tessdata=tessdata_path
|
||||
)
|
||||
|
||||
methode = "ocr"
|
||||
|
||||
except Exception as e2:
|
||||
logger.warning(f"Échec de la deuxième tentative: {str(e2)}")
|
||||
|
||||
# Troisième tentative avec la méthode CV
|
||||
logger.info("Tentative d'extraction avec la méthode CV (sans OCR)")
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message="Extraction par détection de zones (sans OCR)",
|
||||
progress=70
|
||||
)
|
||||
|
||||
infos, texte = extraire_infos_cheque(
|
||||
chemin_image=file_path,
|
||||
methode="cv"
|
||||
)
|
||||
|
||||
methode = "cv"
|
||||
|
||||
# Mise à jour finale
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="processing",
|
||||
message="Finalisation des résultats",
|
||||
progress=90
|
||||
)
|
||||
|
||||
# Sauvegarder les résultats dans Redis
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="completed",
|
||||
message="Extraction terminée avec succès",
|
||||
progress=100,
|
||||
result=infos,
|
||||
texte_brut=texte,
|
||||
methode=methode
|
||||
)
|
||||
|
||||
logger.info(f"Traitement terminé pour la tâche {job_id}")
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "completed",
|
||||
"result": infos,
|
||||
"methode": methode
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Capturer l'erreur
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(f"Erreur lors du traitement de l'image: {str(e)}\n{error_trace}")
|
||||
|
||||
# Mettre à jour le statut avec l'erreur
|
||||
update_job_status(
|
||||
job_id=job_id,
|
||||
status="failed",
|
||||
message="Échec du traitement",
|
||||
erreur=str(e)
|
||||
)
|
||||
|
||||
# Retourner l'erreur
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
14
api/requirements.txt
Normal file
14
api/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
fastapi==0.100.0
|
||||
uvicorn==0.23.2
|
||||
python-multipart==0.0.6
|
||||
rq==1.15.1
|
||||
redis==4.6.0
|
||||
pydantic==2.3.0
|
||||
pydantic-settings==2.0.3
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.24.1
|
||||
PyMuPDF==1.22.5
|
||||
opencv-python==4.8.0.74
|
||||
numpy==1.24.3
|
||||
pytesseract==0.3.10
|
||||
Pillow==10.0.0
|
||||
Reference in New Issue
Block a user