diff --git a/app/services/ai_vision.py b/app/services/ai_vision.py index 16069b8..ccf98ac 100644 --- a/app/services/ai_vision.py +++ b/app/services/ai_vision.py @@ -1,23 +1,21 @@ """ -Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter. +Service AI Vision — analyse d'images via OpenRouter. -Résilience : -- Timeout configurable (AI_REQUEST_TIMEOUT) -- Retry avec backoff exponentiel (AI_MAX_RETRIES) -- Client Gemini non-singleton (recréé si la clé change) +Design simplifié (v3): +- Un seul appel HTTP, pas de retry (ARQ gère les retries au niveau job) +- Timeout généreux (180s) pour les modèles de vision lents +- Logging détaillé à chaque étape pour diagnostic +- Redimensionnement Pillow avant envoi (max 1024px JPEG) """ -import asyncio +import base64 import json import logging import re -import base64 import time -import httpx from pathlib import Path -from typing import Optional, Tuple +from typing import Optional -from google import genai -from google.genai import types +import httpx from app.config import settings from app.services.storage_backend import get_storage_backend @@ -25,293 +23,237 @@ from app.services.runtime_config import runtime_ai_config logger = logging.getLogger(__name__) -# Cache du client Gemini avec TTL -_client: Optional[genai.Client] = None -_client_api_key: Optional[str] = None - - -def _get_client() -> genai.Client: - 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 - +# ───────────────────────────────────────────────────────────── +# Lecture et redimensionnement d'image +# ───────────────────────────────────────────────────────────── async def _read_image(file_path: str) -> tuple[bytes, str]: - """Lit l'image via le StorageBackend et la redimensionne pour l'AI (max 1024px).""" - path = Path(file_path) - suffix = path.suffix.lower() + """Lit et redimensionne l'image pour l'API AI.""" backend = get_storage_backend() data = await backend.get_bytes(file_path) - # Redimensionner pour éviter les timeouts AI sur les grandes images try: from PIL import Image as PILImage import io as pil_io + original_size = len(data) img = PILImage.open(pil_io.BytesIO(data)) max_dim = 1024 + if img.width > max_dim or img.height > max_dim: img.thumbnail((max_dim, max_dim), PILImage.Resampling.LANCZOS) - buf = pil_io.BytesIO() if img.mode in ("RGBA", "P"): img = img.convert("RGB") + buf = pil_io.BytesIO() img.save(buf, format="JPEG", quality=85) data = buf.getvalue() - logger.info("ai.image_resized", extra={ - "file": path.name, - "original_bytes": original_size, - "resized_bytes": len(data), - }) + logger.info( + "ai.image_resized", + extra={ + "file": Path(file_path).name, + "original_bytes": original_size, + "resized_bytes": len(data), + "dimensions": f"{img.width}x{img.height}", + }, + ) return data, "image/jpeg" - except Exception: - pass # Si Pillow échoue, on utilise l'image originale + except Exception as e: + logger.warning("ai.image_resize_failed", extra={"error": str(e)[:100]}) + # Fallback: image originale + suffix = Path(file_path).suffix.lower() mime_map = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", } - media_type = mime_map.get(suffix, "image/jpeg") - return data, media_type - - -def _extract_json(text: str) -> Optional[dict]: - cleaned = re.sub(r"```json\s*|```\s*", "", (text or "")).strip() - json_match = re.search(r"\{.*\}", cleaned, re.DOTALL) - if not json_match: - return None - try: - return json.loads(json_match.group()) - except json.JSONDecodeError: - return None - - -def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]: - usage = getattr(response, "usage_metadata", None) - if not usage: - return None, None - prompt_tokens = getattr(usage, "prompt_token_count", None) - output_tokens = getattr(usage, "candidates_token_count", None) - return prompt_tokens, output_tokens - - -async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs): - 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)[:200] - wait = 2 ** attempt - logger.warning("ai.retry.error", extra={ - "attempt": attempt + 1, "max_retries": retries, - "wait_s": wait, "error": str(e)[:200], - }) - if attempt < retries: - await asyncio.sleep(wait) - raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}") + return data, mime_map.get(suffix, "image/jpeg") # ───────────────────────────────────────────────────────────── -# Google Gemini +# Appel unique à OpenRouter # ───────────────────────────────────────────────────────────── -async def _generate_gemini( +async def _call_openrouter( + *, + model: str, prompt: str, image_bytes: Optional[bytes] = None, - media_type: Optional[str] = None, + media_type: str = "image/jpeg", max_tokens: int = 1024, - model_override: Optional[str] = None, + timeout_secs: int = 180, ) -> dict: - if not settings.GEMINI_API_KEY: - return {"text": None, "usage": (None, None)} + """ + Appel unique à OpenRouter Chat Completions API. + Retourne toujours un dict, jamais ne lève d'exception. + """ + api_key = settings.OPENROUTER_API_KEY + if not api_key: + return {"ok": False, "error": "OPENROUTER_API_KEY non configurée"} - client = _get_client() - contents = [] - if image_bytes and media_type: - contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type)) - contents.append(prompt) - - model = model_override or runtime_ai_config.model or settings.GEMINI_MODEL - - async def _call(): - return await asyncio.wait_for( - asyncio.to_thread( - client.models.generate_content, - model=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: - logger.error("ai.gemini.error", extra={ - "model": model, "error_type": type(e).__name__, "error": str(e)[:300], - }) - return {"text": None, "usage": (None, None), "error": str(e)[:300]} - - -# ───────────────────────────────────────────────────────────── -# OpenRouter -# ───────────────────────────────────────────────────────────── - -async def _generate_openrouter( - prompt: str, - image_bytes: Optional[bytes] = None, - media_type: Optional[str] = None, - max_tokens: int = 1024, - model_override: Optional[str] = None, -) -> dict: - if not settings.OPENROUTER_API_KEY: - return {"text": None, "usage": (None, None)} - - model = model_override or runtime_ai_config.model or settings.OPENROUTER_MODEL - - headers = { - "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", - "Content-Type": "application/json", - "HTTP-Referer": "imago-pipeline", - "X-Title": settings.APP_NAME, - } - - messages = [] - content_payload = [{"type": "text", "text": prompt}] - if image_bytes and media_type: - b64_img = base64.b64encode(image_bytes).decode("utf-8") - content_payload.append({ + # Construire le payload + content = [{"type": "text", "text": prompt}] + if image_bytes: + b64 = base64.b64encode(image_bytes).decode() + content.append({ "type": "image_url", - "image_url": {"url": f"data:{media_type};base64,{b64_img}"} + "image_url": {"url": f"data:{media_type};base64,{b64}"}, }) - messages.append({"role": "user", "content": content_payload}) payload = { "model": model, - "messages": messages, + "messages": [{"role": "user", "content": content}], "max_tokens": max_tokens, - "response_format": {"type": "json_object"} + "response_format": {"type": "json_object"}, } - timeout_secs = settings.AI_REQUEST_TIMEOUT - logger.info("ai.openrouter.request", extra={ - "model": model, "timeout_s": timeout_secs, - "image_bytes": len(image_bytes) if image_bytes else 0, - }) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "imago", + "X-Title": settings.APP_NAME, + } - async def _call(): - t0 = time.time() - try: - async with httpx.AsyncClient( - timeout=httpx.Timeout(timeout_secs, connect=15.0) - ) as client: - response = await client.post( - "https://openrouter.ai/api/v1/chat/completions", - json=payload, - headers=headers, - ) - elapsed = time.time() - t0 - logger.info("ai.openrouter.response", extra={ - "status": response.status_code, "elapsed_s": round(elapsed, 1), - }) - response.raise_for_status() - return response.json() - except httpx.TimeoutException as e: - elapsed = time.time() - t0 - logger.error("ai.openrouter.timeout", extra={ - "elapsed_s": round(elapsed, 1), - "timeout_setting": timeout_secs, - "detail": str(e), - }) - raise + t_start = time.monotonic() + logger.info( + "ai.call.start", + extra={ + "model": model, + "timeout_s": timeout_secs, + "image_bytes": len(image_bytes) if image_bytes else 0, + }, + ) try: - data = await _retry_with_backoff(_call) + async with httpx.AsyncClient( + timeout=httpx.Timeout(timeout_secs, connect=15.0) + ) as client: + response = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + json=payload, + headers=headers, + ) + + elapsed = time.monotonic() - t_start + logger.info( + "ai.call.response", + extra={ + "status": response.status_code, + "elapsed_s": round(elapsed, 1), + }, + ) + + if response.status_code != 200: + error_body = response.text[:500] + logger.error( + "ai.call.http_error", + extra={ + "status": response.status_code, + "elapsed_s": round(elapsed, 1), + "body": error_body, + }, + ) + return { + "ok": False, + "error": f"HTTP {response.status_code}: {error_body}", + "elapsed_s": round(elapsed, 1), + } + + data = response.json() + + # Extraire le texte de la réponse 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 {"text": text, "usage": (prompt_tokens, output_tokens)} + text = data["choices"][0].get("message", {}).get("content", "") + + # Usage tokens + usage = data.get("usage", {}) + prompt_tokens = usage.get("prompt_tokens") + completion_tokens = usage.get("completion_tokens") + + logger.info( + "ai.call.success", + extra={ + "model": model, + "elapsed_s": round(elapsed, 1), + "response_chars": len(text), + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + }, + ) + + return { + "ok": True, + "text": text, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "elapsed_s": round(elapsed, 1), + } + + except httpx.TimeoutException as e: + elapsed = time.monotonic() - t_start + logger.error( + "ai.call.timeout", + extra={ + "model": model, + "elapsed_s": round(elapsed, 1), + "timeout_setting": timeout_secs, + "error": str(e)[:200], + }, + ) + return { + "ok": False, + "error": f"Timeout après {round(elapsed,1)}s (limite: {timeout_secs}s)", + "elapsed_s": round(elapsed, 1), + } + except Exception as e: - logger.error("ai.openrouter.final_error", extra={ - "model": model, "error_type": type(e).__name__, "error": str(e)[:300], - }) - return {"text": None, "usage": (None, None), "error": str(e)[:300]} - - -# ───────────────────────────────────────────────────────────── -# Dispatcher -# ───────────────────────────────────────────────────────────── - -async def _generate( - prompt: str, - image_bytes: Optional[bytes] = None, - media_type: Optional[str] = None, - max_tokens: int = 1024, - provider_override: Optional[str] = None, - model_override: Optional[str] = None, -) -> dict: - provider = provider_override or runtime_ai_config.provider or 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, - model_override=model_override, - ) - else: - return await _generate_gemini( - prompt, image_bytes, media_type, max_tokens, - model_override=model_override, + elapsed = time.monotonic() - t_start + logger.error( + "ai.call.exception", + extra={ + "model": model, + "elapsed_s": round(elapsed, 1), + "exception_type": type(e).__name__, + "error": str(e)[:300], + }, ) + return { + "ok": False, + "error": f"{type(e).__name__}: {str(e)[:200]}", + "elapsed_s": round(elapsed, 1), + } # ───────────────────────────────────────────────────────────── -# Fonctions publiques +# Prompt builder # ───────────────────────────────────────────────────────────── def _build_prompt(ocr_hint: Optional[str], language: str) -> str: ocr_section = "" if ocr_hint and len(ocr_hint.strip()) > 5: - ocr_section = f""" -Texte détecté dans l'image par OCR (utilise-le pour enrichir ta réponse) : -\"\"\" -{ocr_hint[:500]} -\"\"\" -""" - return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs : + ocr_section = ( + f'\nTexte détecté dans l\'image par OCR : """{ocr_hint[:300]}"""' + ) + + return f"""Analyse cette image et retourne UNIQUEMENT un objet JSON: {{ - "description": "Description complète et détaillée en {language}, 2-4 phrases. Décris le sujet principal, le contexte, les couleurs, l'ambiance.", - "tags": ["tag1", "tag2", "tag3"], + "description": "Description détaillée en {language}, 2-4 phrases.", + "tags": ["tag1", "tag2", "tag3", ...], "confidence": 0.95 }} -Règles pour les tags : -- Entre {settings.AI_TAGS_MIN} et {settings.AI_TAGS_MAX} tags -- En minuscules, sans espaces (utiliser des tirets si nécessaire) -- Couvrir : sujet principal, type d'image, couleurs dominantes, style, contexte -- Exemples : portrait, paysage, architecture, nature, nourriture, texte, document, animal, sport, technologie, intérieur, extérieur +Règles: +- {settings.AI_TAGS_MIN} à {settings.AI_TAGS_MAX} tags en minuscules, sans espaces +- Décris le sujet, contexte, couleurs, ambiance {ocr_section} -Réponds UNIQUEMENT avec le JSON, sans texte avant ou après, sans balises markdown.""" +Réponds UNIQUEMENT avec le JSON, pas de markdown.""" + + +# ───────────────────────────────────────────────────────────── +# Fonction principale — analyse d'image +# ───────────────────────────────────────────────────────────── async def analyze_image( file_path: str, @@ -320,168 +262,176 @@ async def analyze_image( provider_override: Optional[str] = None, model_override: Optional[str] = None, ) -> dict: - if not settings.AI_ENABLED: - return {} + """ + Analyse une image avec l'AI (description + tags). - active_provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER - active_model = model_override or runtime_ai_config.model or ( - settings.OPENROUTER_MODEL if active_provider == "openrouter" - else settings.GEMINI_MODEL + Priorité du modèle: + 1. model_override (paramètre explicite) + 2. runtime_ai_config.model (override admin live) + 3. settings.OPENROUTER_MODEL (.env) + """ + if not settings.AI_ENABLED: + return {"description": None, "tags": [], "confidence": None, "model": None} + + model = ( + model_override + or runtime_ai_config.model + or settings.OPENROUTER_MODEL ) + # Résultat par défaut result = { - "description": None, "tags": [], "confidence": None, - "model": active_model, "prompt_tokens": None, "output_tokens": None, + "description": None, + "tags": [], + "confidence": None, + "model": model, + "prompt_tokens": None, + "output_tokens": None, } + # Lire et redimensionner l'image try: 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, - provider_override=provider_override, model_override=model_override, - ) - - text = response.get("text") - result["prompt_tokens"], result["output_tokens"] = response.get("usage") - - if text: - parsed = _extract_json(text) - if parsed: - result["description"] = parsed.get("description") - result["tags"] = parsed.get("tags", []) - 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"]}) - except Exception as e: - logger.error("ai.vision.unexpected_error", extra={"error": str(e)}) + logger.error("ai.read_image_failed", extra={"file": file_path, "error": str(e)}) + return result + + # Construire le prompt + prompt = _build_prompt(ocr_hint, language) + + # Appeler l'API + response = await _call_openrouter( + model=model, + prompt=prompt, + image_bytes=image_bytes, + media_type=media_type, + max_tokens=settings.GEMINI_MAX_TOKENS, + timeout_secs=settings.AI_REQUEST_TIMEOUT, + ) + + if not response.get("ok"): + logger.error( + "ai.analysis_failed", + extra={"model": model, "error": response.get("error", "unknown")}, + ) + return result + + # Parser la réponse JSON + text = response.get("text", "") + result["prompt_tokens"] = response.get("prompt_tokens") + result["output_tokens"] = response.get("completion_tokens") + + if text: + parsed = _extract_json(text) + if parsed: + result["description"] = parsed.get("description") + result["tags"] = parsed.get("tags", []) + result["confidence"] = parsed.get("confidence") return result +def _extract_json(text: str) -> Optional[dict]: + """Extrait un objet JSON d'une réponse textuelle.""" + cleaned = re.sub(r"```json\s*|```\s*", "", text).strip() + match = re.search(r"\{.*\}", cleaned, re.DOTALL) + if not match: + return None + try: + return json.loads(match.group()) + except json.JSONDecodeError: + return None + + +# ───────────────────────────────────────────────────────────── +# Fonctions auxiliaires (OCR fallback, résumé, tâches) +# ───────────────────────────────────────────────────────────── + async def extract_text_with_ai(file_path: str) -> dict: + """OCR fallback via AI.""" result = { "text": None, "has_text": False, "language": "unknown", - "confidence": 0.0, "method": f"ai-{settings.AI_PROVIDER}" + "confidence": 0.0, "method": f"ai-openrouter", } if not settings.AI_ENABLED: return result - logger.info("ai.ocr.fallback_start", extra={"file": Path(file_path).name}) + model = runtime_ai_config.model or settings.OPENROUTER_MODEL try: 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 : -{ - "text": "Le texte complet extrait ici...", - "language": "fr" (code langue ISO 2 lettres, ex: fr, en, es), - "confidence": 0.9 (estimation confiance 0.0 à 1.0) -} -Si aucun texte n'est visible, retourne : {"text": "", "has_text": false} -""" - response = await _generate( - prompt=prompt, image_bytes=image_bytes, media_type=media_type, - max_tokens=1024 - ) + except Exception: + return result - text = response.get("text") + response = await _call_openrouter( + model=model, + prompt="""Extrais TOUT le texte visible dans cette image. +Retourne UNIQUEMENT un objet JSON: +{"text": "texte extrait...", "language": "fr", "confidence": 0.9} +Si aucun texte: {"text": "", "has_text": false}""", + image_bytes=image_bytes, + media_type=media_type, + max_tokens=1024, + ) + + if response.get("ok"): + text = response.get("text", "") if text: parsed = _extract_json(text) if parsed: extracted = parsed.get("text", "").strip() result["text"] = extracted - result["has_text"] = bool(extracted) or parsed.get("has_text", False) + result["has_text"] = bool(extracted) result["language"] = parsed.get("language", "unknown") result["confidence"] = parsed.get("confidence", 0.0) - logger.info("ai.ocr.success", extra={"chars": len(extracted)}) - else: - logger.warning("ai.ocr.json_parse_failed") - else: - logger.info("ai.ocr.empty_response") - except Exception as e: - logger.error("ai.ocr.error", extra={"error": str(e)}) return result async def summarize_url(url: str, content: str, language: str = "français") -> dict: - result = { - "summary": "", "tags": [], "model": settings.AI_PROVIDER, - "prompt_tokens": None, "output_tokens": None, - } + """Résumé AI d'une URL.""" + result = {"summary": "", "tags": [], "model": "openrouter", "prompt_tokens": None, "output_tokens": None} if not settings.AI_ENABLED: return result - prompt = f"""Tu reçois le contenu d'une page web. Génère un résumé et des tags en {language}. - -URL : {url} - -Contenu : -\"\"\" -{content[:3000]} -\"\"\" - -Retourne UNIQUEMENT ce JSON : -{{ - "summary": "Résumé clair en 3-5 phrases en {language}", - "tags": ["tag1", "tag2", "tag3"] -}}""" - - try: - response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS) - text = response.get("text") + model = runtime_ai_config.model or settings.OPENROUTER_MODEL + response = await _call_openrouter( + model=model, + prompt=f"""URL: {url}\n\nContenu:\n\"\"\"\n{content[:3000]}\n\"\"\"\n\nRésume en {language} et retourne UNIQUEMENT:\n{{"summary": "...", "tags": ["..."]}}""", + max_tokens=settings.GEMINI_MAX_TOKENS, + ) + if response.get("ok"): + text = response.get("text", "") if text: parsed = _extract_json(text) if parsed: result["summary"] = parsed.get("summary", "") result["tags"] = parsed.get("tags", []) - result["prompt_tokens"], result["output_tokens"] = response.get("usage") - except Exception as e: - logger.error("ai.summarize_url.error", extra={"error": str(e)}) - + result["prompt_tokens"] = response.get("prompt_tokens") + result["output_tokens"] = response.get("completion_tokens") return result async def draft_task(description: str, context: Optional[str], language: str = "français") -> dict: - result = { - "title": "", "description": "", "steps": [], - "estimated_time": None, "priority": None, - "model": settings.AI_PROVIDER, "prompt_tokens": None, "output_tokens": None, - } + """Génération AI d'une tâche.""" + result = {"title": "", "description": "", "steps": [], "estimated_time": None, "priority": None, + "model": "openrouter", "prompt_tokens": None, "output_tokens": None} if not settings.AI_ENABLED: return result - ctx_section = f"\nContexte : {context}" if context else "" - prompt = f"""Tu es un assistant de gestion de tâches. Génère une tâche structurée en {language}. - -Description : {description}{ctx_section} - -Retourne UNIQUEMENT ce JSON : -{{ - "title": "Titre court et actionnable", - "description": "Description complète de la tâche", - "steps": ["Étape 1", "Étape 2", "Étape 3"], - "estimated_time": "30 minutes", - "priority": "haute|moyenne|basse" -}}""" - - try: - response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS) - text = response.get("text") + ctx = f"\nContexte: {context}" if context else "" + model = runtime_ai_config.model or settings.OPENROUTER_MODEL + response = await _call_openrouter( + model=model, + prompt=f"Crée une tâche structurée en {language}.\nDescription: {description}{ctx}\n\nRetourne UNIQUEMENT:\n{{'title':'...','description':'...','steps':['...'],'estimated_time':'...','priority':'...'}}", + max_tokens=settings.GEMINI_MAX_TOKENS, + ) + if response.get("ok"): + text = response.get("text", "") if text: parsed = _extract_json(text) if parsed: result.update(parsed) - result["prompt_tokens"], result["output_tokens"] = response.get("usage") - except Exception as e: - logger.error("ai.draft_task.error", extra={"error": str(e)}) - + result["prompt_tokens"] = response.get("prompt_tokens") + result["output_tokens"] = response.get("completion_tokens") return result