Improved AI error diagnostics: detailed timeout logging per attempt
CI / Lint & Format (push) Failing after 10s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

- _generate_openrouter: log request start, response time, timeout details
  with httpx.Timeout (connect=15s, rest=AI_REQUEST_TIMEOUT)
- All AI functions: log error_type (exception class name) + truncated error
- _retry_with_backoff: log each attempt with wait time
- Clean rewrite of ai_vision.py
This commit is contained in:
2026-06-25 16:29:18 -04:00
parent eda4faeb78
commit 5654961642
+78 -110
View File
@@ -13,7 +13,6 @@ import re
import base64 import base64
import time import time
import httpx import httpx
import io
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -26,13 +25,12 @@ from app.services.runtime_config import runtime_ai_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache du client Gemini avec TTL (évite de recréer à chaque appel) # Cache du client Gemini avec TTL
_client: Optional[genai.Client] = None _client: Optional[genai.Client] = None
_client_api_key: Optional[str] = None _client_api_key: Optional[str] = None
def _get_client() -> genai.Client: def _get_client() -> genai.Client:
"""Retourne un client Gemini. Recrée si la clé API a changé."""
global _client, _client_api_key global _client, _client_api_key
current_key = settings.GEMINI_API_KEY current_key = settings.GEMINI_API_KEY
if _client is None or _client_api_key != current_key: if _client is None or _client_api_key != current_key:
@@ -42,19 +40,13 @@ def _get_client() -> genai.Client:
async def _read_image(file_path: str) -> tuple[bytes, str]: 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) path = Path(file_path)
suffix = path.suffix.lower() suffix = path.suffix.lower()
mime_map = { mime_map = {
".jpg": "image/jpeg", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
} }
media_type = mime_map.get(suffix, "image/jpeg") media_type = mime_map.get(suffix, "image/jpeg")
backend = get_storage_backend() backend = get_storage_backend()
data = await backend.get_bytes(file_path) data = await backend.get_bytes(file_path)
return data, media_type return data, media_type
@@ -81,10 +73,8 @@ def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]:
async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs): async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs):
"""Exécute fn avec retry + backoff exponentiel en cas d'échec."""
retries = max_retries if max_retries is not None else settings.AI_MAX_RETRIES retries = max_retries if max_retries is not None else settings.AI_MAX_RETRIES
last_error = None last_error = None
for attempt in range(retries + 1): for attempt in range(retries + 1):
try: try:
return await fn(*args, **kwargs) return await fn(*args, **kwargs)
@@ -92,25 +82,24 @@ async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs):
last_error = "timeout" last_error = "timeout"
wait = 2 ** attempt wait = 2 ** attempt
logger.warning("ai.retry.timeout", extra={ logger.warning("ai.retry.timeout", extra={
"attempt": attempt + 1, "attempt": attempt + 1, "max_retries": retries, "wait_s": wait,
"max_retries": retries,
"wait_s": wait,
}) })
except Exception as e: except Exception as e:
last_error = str(e) last_error = str(e)[:200]
wait = 2 ** attempt wait = 2 ** attempt
logger.warning("ai.retry.error", extra={ logger.warning("ai.retry.error", extra={
"attempt": attempt + 1, "attempt": attempt + 1, "max_retries": retries,
"max_retries": retries, "wait_s": wait, "error": str(e)[:200],
"wait_s": wait,
"error": str(e),
}) })
if attempt < retries: if attempt < retries:
await asyncio.sleep(wait) await asyncio.sleep(wait)
raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}") raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}")
# ─────────────────────────────────────────────────────────────
# Google Gemini
# ─────────────────────────────────────────────────────────────
async def _generate_gemini( async def _generate_gemini(
prompt: str, prompt: str,
image_bytes: Optional[bytes] = None, image_bytes: Optional[bytes] = None,
@@ -118,9 +107,7 @@ async def _generate_gemini(
max_tokens: int = 1024, max_tokens: int = 1024,
model_override: Optional[str] = None, model_override: Optional[str] = None,
) -> dict: ) -> dict:
"""Appel à Google Gemini via SDK avec timeout et retry."""
if not settings.GEMINI_API_KEY: if not settings.GEMINI_API_KEY:
logger.warning("ai.gemini.no_key")
return {"text": None, "usage": (None, None)} return {"text": None, "usage": (None, None)}
client = _get_client() client = _get_client()
@@ -150,10 +137,16 @@ async def _generate_gemini(
usage = _usage_tokens_gemini(response) usage = _usage_tokens_gemini(response)
return {"text": getattr(response, "text", ""), "usage": usage} return {"text": getattr(response, "text", ""), "usage": usage}
except Exception as e: except Exception as e:
logger.error("ai.gemini.error", extra={"error": str(e)}) logger.error("ai.gemini.error", extra={
return {"text": None, "usage": (None, None), "error": str(e)} "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( async def _generate_openrouter(
prompt: str, prompt: str,
image_bytes: Optional[bytes] = None, image_bytes: Optional[bytes] = None,
@@ -161,9 +154,7 @@ async def _generate_openrouter(
max_tokens: int = 1024, max_tokens: int = 1024,
model_override: Optional[str] = None, model_override: Optional[str] = None,
) -> dict: ) -> dict:
"""Appel à OpenRouter via HTTP avec timeout et retry."""
if not settings.OPENROUTER_API_KEY: if not settings.OPENROUTER_API_KEY:
logger.warning("ai.openrouter.no_key")
return {"text": None, "usage": (None, None)} return {"text": None, "usage": (None, None)}
model = model_override or runtime_ai_config.model or settings.OPENROUTER_MODEL model = model_override or runtime_ai_config.model or settings.OPENROUTER_MODEL
@@ -171,23 +162,18 @@ async def _generate_openrouter(
headers = { headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
"HTTP-Referer": settings.HOST, "HTTP-Referer": "imago-pipeline",
"X-Title": settings.APP_NAME, "X-Title": settings.APP_NAME,
} }
messages = [] messages = []
content_payload = [] content_payload = [{"type": "text", "text": prompt}]
content_payload.append({"type": "text", "text": prompt})
if image_bytes and media_type: if image_bytes and media_type:
b64_img = base64.b64encode(image_bytes).decode("utf-8") b64_img = base64.b64encode(image_bytes).decode("utf-8")
content_payload.append({ content_payload.append({
"type": "image_url", "type": "image_url",
"image_url": { "image_url": {"url": f"data:{media_type};base64,{b64_img}"}
"url": f"data:{media_type};base64,{b64_img}"
}
}) })
messages.append({"role": "user", "content": content_payload}) messages.append({"role": "user", "content": content_payload})
payload = { payload = {
@@ -197,34 +183,58 @@ async def _generate_openrouter(
"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,
})
async def _call(): async def _call():
async with httpx.AsyncClient() as client: t0 = time.time()
response = await client.post( try:
"https://openrouter.ai/api/v1/chat/completions", async with httpx.AsyncClient(
json=payload, timeout=httpx.Timeout(timeout_secs, connect=15.0)
headers=headers, ) as client:
timeout=settings.AI_REQUEST_TIMEOUT, response = await client.post(
) "https://openrouter.ai/api/v1/chat/completions",
response.raise_for_status() json=payload,
return response.json() 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
try: try:
data = await _retry_with_backoff(_call) data = await _retry_with_backoff(_call)
text = "" text = ""
if "choices" in data and len(data["choices"]) > 0: if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"] text = data["choices"][0]["message"]["content"]
usage_data = data.get("usage", {}) usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens") prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens") output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)} return {"text": text, "usage": (prompt_tokens, output_tokens)}
except Exception as e: except Exception as e:
logger.error("ai.openrouter.error", extra={"error": str(e)}) logger.error("ai.openrouter.final_error", extra={
return {"text": None, "usage": (None, None), "error": str(e)} "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( async def _generate(
prompt: str, prompt: str,
image_bytes: Optional[bytes] = None, image_bytes: Optional[bytes] = None,
@@ -233,7 +243,6 @@ async def _generate(
provider_override: Optional[str] = None, provider_override: Optional[str] = None,
model_override: Optional[str] = None, model_override: Optional[str] = None,
) -> dict: ) -> dict:
"""Dispatcher vers le bon provider, avec override optionnel par client."""
provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER.lower() provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider}) logger.info("ai.generate", extra={"provider": provider})
@@ -249,6 +258,10 @@ async def _generate(
) )
# ─────────────────────────────────────────────────────────────
# Fonctions publiques
# ─────────────────────────────────────────────────────────────
def _build_prompt(ocr_hint: Optional[str], language: str) -> str: def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
ocr_section = "" ocr_section = ""
if ocr_hint and len(ocr_hint.strip()) > 5: if ocr_hint and len(ocr_hint.strip()) > 5:
@@ -258,7 +271,6 @@ Texte détecté dans l'image par OCR (utilise-le pour enrichir ta réponse) :
{ocr_hint[:500]} {ocr_hint[:500]}
\"\"\" \"\"\"
""" """
return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs : return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs :
{{ {{
@@ -283,10 +295,6 @@ async def analyze_image(
provider_override: Optional[str] = None, provider_override: Optional[str] = None,
model_override: Optional[str] = None, model_override: Optional[str] = None,
) -> dict: ) -> dict:
"""
Envoie l'image à l'AI pour analyse (Description + Tags).
Supporte l'override par client pour le fournisseur/modèle.
"""
if not settings.AI_ENABLED: if not settings.AI_ENABLED:
return {} return {}
@@ -297,12 +305,8 @@ async def analyze_image(
) )
result = { result = {
"description": None, "description": None, "tags": [], "confidence": None,
"tags": [], "model": active_model, "prompt_tokens": None, "output_tokens": None,
"confidence": None,
"model": active_model,
"prompt_tokens": None,
"output_tokens": None,
} }
try: try:
@@ -310,12 +314,9 @@ async def analyze_image(
prompt = _build_prompt(ocr_hint, language) prompt = _build_prompt(ocr_hint, language)
response = await _generate( response = await _generate(
prompt=prompt, prompt=prompt, image_bytes=image_bytes, media_type=media_type,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS, max_tokens=settings.GEMINI_MAX_TOKENS,
provider_override=provider_override, provider_override=provider_override, model_override=model_override,
model_override=model_override,
) )
text = response.get("text") text = response.get("text")
@@ -340,17 +341,10 @@ async def analyze_image(
async def extract_text_with_ai(file_path: str) -> dict: async def extract_text_with_ai(file_path: str) -> dict:
"""
Utilise l'AI comme fallback OCR.
"""
result = { result = {
"text": None, "text": None, "has_text": False, "language": "unknown",
"has_text": False, "confidence": 0.0, "method": f"ai-{settings.AI_PROVIDER}"
"language": "unknown",
"confidence": 0.0,
"method": f"ai-{settings.AI_PROVIDER}"
} }
if not settings.AI_ENABLED: if not settings.AI_ENABLED:
return result return result
@@ -368,11 +362,8 @@ Retourne UNIQUEMENT un objet JSON :
} }
Si aucun texte n'est visible, retourne : {"text": "", "has_text": false} Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
""" """
response = await _generate( response = await _generate(
prompt=prompt, prompt=prompt, image_bytes=image_bytes, media_type=media_type,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=1024 max_tokens=1024
) )
@@ -390,7 +381,6 @@ Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
logger.warning("ai.ocr.json_parse_failed") logger.warning("ai.ocr.json_parse_failed")
else: else:
logger.info("ai.ocr.empty_response") logger.info("ai.ocr.empty_response")
except Exception as e: except Exception as e:
logger.error("ai.ocr.error", extra={"error": str(e)}) logger.error("ai.ocr.error", extra={"error": str(e)})
@@ -398,15 +388,10 @@ Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
async def summarize_url(url: str, content: str, language: str = "français") -> dict: async def summarize_url(url: str, content: str, language: str = "français") -> dict:
"""Génère un résumé et des tags pour un contenu web."""
result = { result = {
"summary": "", "summary": "", "tags": [], "model": settings.AI_PROVIDER,
"tags": [], "prompt_tokens": None, "output_tokens": None,
"model": settings.AI_PROVIDER,
"prompt_tokens": None,
"output_tokens": None,
} }
if not settings.AI_ENABLED: if not settings.AI_ENABLED:
return result return result
@@ -426,11 +411,7 @@ Retourne UNIQUEMENT ce JSON :
}}""" }}"""
try: try:
response = await _generate( response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS)
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text") text = response.get("text")
if text: if text:
parsed = _extract_json(text) parsed = _extract_json(text)
@@ -438,7 +419,6 @@ Retourne UNIQUEMENT ce JSON :
result["summary"] = parsed.get("summary", "") result["summary"] = parsed.get("summary", "")
result["tags"] = parsed.get("tags", []) result["tags"] = parsed.get("tags", [])
result["prompt_tokens"], result["output_tokens"] = response.get("usage") result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e: except Exception as e:
logger.error("ai.summarize_url.error", extra={"error": str(e)}) logger.error("ai.summarize_url.error", extra={"error": str(e)})
@@ -446,18 +426,11 @@ Retourne UNIQUEMENT ce JSON :
async def draft_task(description: str, context: Optional[str], language: str = "français") -> dict: async def draft_task(description: str, context: Optional[str], language: str = "français") -> dict:
"""Génère une tâche structurée à partir d'une description."""
result = { result = {
"title": "", "title": "", "description": "", "steps": [],
"description": "", "estimated_time": None, "priority": None,
"steps": [], "model": settings.AI_PROVIDER, "prompt_tokens": None, "output_tokens": None,
"estimated_time": None,
"priority": None,
"model": settings.AI_PROVIDER,
"prompt_tokens": None,
"output_tokens": None,
} }
if not settings.AI_ENABLED: if not settings.AI_ENABLED:
return result return result
@@ -476,18 +449,13 @@ Retourne UNIQUEMENT ce JSON :
}}""" }}"""
try: try:
response = await _generate( response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS)
prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS
)
text = response.get("text") text = response.get("text")
if text: if text:
parsed = _extract_json(text) parsed = _extract_json(text)
if parsed: if parsed:
result.update(parsed) result.update(parsed)
result["prompt_tokens"], result["output_tokens"] = response.get("usage") result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e: except Exception as e:
logger.error("ai.draft_task.error", extra={"error": str(e)}) logger.error("ai.draft_task.error", extra={"error": str(e)})