""" Service AI Vision — analyse d'images via OpenRouter. 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 base64 import json import logging import re import time from pathlib import Path from typing import Optional import httpx from app.config import settings from app.services.storage_backend import get_storage_backend from app.services.runtime_config import runtime_ai_config logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────── # Lecture et redimensionnement d'image # ───────────────────────────────────────────────────────────── async def _read_image(file_path: str) -> tuple[bytes, str]: """Lit et redimensionne l'image pour l'API AI.""" backend = get_storage_backend() data = await backend.get_bytes(file_path) 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) 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(file_path).name, "original_bytes": original_size, "resized_bytes": len(data), "dimensions": f"{img.width}x{img.height}", }, ) return data, "image/jpeg" 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", } return data, mime_map.get(suffix, "image/jpeg") # ───────────────────────────────────────────────────────────── # Appel unique à OpenRouter # ───────────────────────────────────────────────────────────── async def _call_openrouter( *, model: str, prompt: str, image_bytes: Optional[bytes] = None, media_type: str = "image/jpeg", max_tokens: int = 1024, timeout_secs: int = 180, ) -> dict: """ 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"} # 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}"}, }) payload = { "model": model, "messages": [{"role": "user", "content": content}], "max_tokens": max_tokens, "response_format": {"type": "json_object"}, } headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "imago", "X-Title": settings.APP_NAME, } 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: 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].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: 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), } # ───────────────────────────────────────────────────────────── # 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'\nTexte détecté dans l\'image par OCR : """{ocr_hint[:300]}"""' ) return f"""Analyse cette image et retourne UNIQUEMENT un objet JSON: {{ "description": "Description détaillée en {language}, 2-4 phrases.", "tags": ["tag1", "tag2", "tag3", ...], "confidence": 0.95 }} 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, pas de markdown.""" # ───────────────────────────────────────────────────────────── # Fonction principale — analyse d'image # ───────────────────────────────────────────────────────────── async def analyze_image( file_path: str, ocr_hint: Optional[str] = None, language: str = "français", provider_override: Optional[str] = None, model_override: Optional[str] = None, ) -> dict: """ Analyse une image avec l'AI (description + tags). 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": model, "prompt_tokens": None, "output_tokens": None, } # Lire et redimensionner l'image try: image_bytes, media_type = await _read_image(file_path) except Exception as 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-openrouter", } if not settings.AI_ENABLED: return result model = runtime_ai_config.model or settings.OPENROUTER_MODEL try: image_bytes, media_type = await _read_image(file_path) except Exception: return result 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) result["language"] = parsed.get("language", "unknown") result["confidence"] = parsed.get("confidence", 0.0) return result async def summarize_url(url: str, content: str, language: str = "français") -> dict: """Résumé AI d'une URL.""" result = {"summary": "", "tags": [], "model": "openrouter", "prompt_tokens": None, "output_tokens": None} if not settings.AI_ENABLED: return result 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"] = 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: """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 = 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"] = response.get("prompt_tokens") result["output_tokens"] = response.get("completion_tokens") return result