Files
Imago/app/services/ai_vision.py
T
bruno 73a5e401d2
CI / Lint & Format (push) Failing after 12s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped
Rewrite ai_vision.py: simplified single-call OpenRouter, no retries, 180s timeout
v3 rewrite:
- _call_openrouter(): single HTTP call, never raises, always returns dict
- Removed Gemini SDK, _retry_with_backoff, _generate dispatcher
- Image resize (max 1024px JPEG) stays
- Detailed logging: ai.call.start → ai.call.response → ai.call.success/timeout
- All error paths return structured dicts, never propagate exceptions
2026-06-26 07:44:44 -04:00

438 lines
15 KiB
Python

"""
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