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
This commit is contained in:
+294
-344
@@ -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 :
|
Design simplifié (v3):
|
||||||
- Timeout configurable (AI_REQUEST_TIMEOUT)
|
- Un seul appel HTTP, pas de retry (ARQ gère les retries au niveau job)
|
||||||
- Retry avec backoff exponentiel (AI_MAX_RETRIES)
|
- Timeout généreux (180s) pour les modèles de vision lents
|
||||||
- Client Gemini non-singleton (recréé si la clé change)
|
- Logging détaillé à chaque étape pour diagnostic
|
||||||
|
- Redimensionnement Pillow avant envoi (max 1024px JPEG)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import base64
|
|
||||||
import time
|
import time
|
||||||
import httpx
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
from google import genai
|
import httpx
|
||||||
from google.genai import types
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.storage_backend import get_storage_backend
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Cache du client Gemini avec TTL
|
# ─────────────────────────────────────────────────────────────
|
||||||
_client: Optional[genai.Client] = None
|
# Lecture et redimensionnement d'image
|
||||||
_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
|
|
||||||
|
|
||||||
|
|
||||||
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 la redimensionne pour l'AI (max 1024px)."""
|
"""Lit et redimensionne l'image pour l'API AI."""
|
||||||
path = Path(file_path)
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
backend = get_storage_backend()
|
backend = get_storage_backend()
|
||||||
data = await backend.get_bytes(file_path)
|
data = await backend.get_bytes(file_path)
|
||||||
|
|
||||||
# Redimensionner pour éviter les timeouts AI sur les grandes images
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
import io as pil_io
|
import io as pil_io
|
||||||
|
|
||||||
original_size = len(data)
|
original_size = len(data)
|
||||||
img = PILImage.open(pil_io.BytesIO(data))
|
img = PILImage.open(pil_io.BytesIO(data))
|
||||||
max_dim = 1024
|
max_dim = 1024
|
||||||
|
|
||||||
if img.width > max_dim or img.height > max_dim:
|
if img.width > max_dim or img.height > max_dim:
|
||||||
img.thumbnail((max_dim, max_dim), PILImage.Resampling.LANCZOS)
|
img.thumbnail((max_dim, max_dim), PILImage.Resampling.LANCZOS)
|
||||||
buf = pil_io.BytesIO()
|
|
||||||
if img.mode in ("RGBA", "P"):
|
if img.mode in ("RGBA", "P"):
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
buf = pil_io.BytesIO()
|
||||||
img.save(buf, format="JPEG", quality=85)
|
img.save(buf, format="JPEG", quality=85)
|
||||||
data = buf.getvalue()
|
data = buf.getvalue()
|
||||||
logger.info("ai.image_resized", extra={
|
logger.info(
|
||||||
"file": path.name,
|
"ai.image_resized",
|
||||||
"original_bytes": original_size,
|
extra={
|
||||||
"resized_bytes": len(data),
|
"file": Path(file_path).name,
|
||||||
})
|
"original_bytes": original_size,
|
||||||
|
"resized_bytes": len(data),
|
||||||
|
"dimensions": f"{img.width}x{img.height}",
|
||||||
|
},
|
||||||
|
)
|
||||||
return data, "image/jpeg"
|
return data, "image/jpeg"
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Si Pillow échoue, on utilise l'image originale
|
logger.warning("ai.image_resize_failed", extra={"error": str(e)[:100]})
|
||||||
|
|
||||||
|
# Fallback: image originale
|
||||||
|
suffix = Path(file_path).suffix.lower()
|
||||||
mime_map = {
|
mime_map = {
|
||||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
".jpg": "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")
|
return data, 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}")
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Google Gemini
|
# Appel unique à OpenRouter
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _generate_gemini(
|
async def _call_openrouter(
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
image_bytes: Optional[bytes] = None,
|
image_bytes: Optional[bytes] = None,
|
||||||
media_type: Optional[str] = None,
|
media_type: str = "image/jpeg",
|
||||||
max_tokens: int = 1024,
|
max_tokens: int = 1024,
|
||||||
model_override: Optional[str] = None,
|
timeout_secs: int = 180,
|
||||||
) -> dict:
|
) -> 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()
|
# Construire le payload
|
||||||
contents = []
|
content = [{"type": "text", "text": prompt}]
|
||||||
if image_bytes and media_type:
|
if image_bytes:
|
||||||
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
contents.append(prompt)
|
content.append({
|
||||||
|
|
||||||
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({
|
|
||||||
"type": "image_url",
|
"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 = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": [{"role": "user", "content": content}],
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"response_format": {"type": "json_object"}
|
"response_format": {"type": "json_object"},
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout_secs = settings.AI_REQUEST_TIMEOUT
|
headers = {
|
||||||
logger.info("ai.openrouter.request", extra={
|
"Authorization": f"Bearer {api_key}",
|
||||||
"model": model, "timeout_s": timeout_secs,
|
"Content-Type": "application/json",
|
||||||
"image_bytes": len(image_bytes) if image_bytes else 0,
|
"HTTP-Referer": "imago",
|
||||||
})
|
"X-Title": settings.APP_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
async def _call():
|
t_start = time.monotonic()
|
||||||
t0 = time.time()
|
logger.info(
|
||||||
try:
|
"ai.call.start",
|
||||||
async with httpx.AsyncClient(
|
extra={
|
||||||
timeout=httpx.Timeout(timeout_secs, connect=15.0)
|
"model": model,
|
||||||
) as client:
|
"timeout_s": timeout_secs,
|
||||||
response = await client.post(
|
"image_bytes": len(image_bytes) if image_bytes else 0,
|
||||||
"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
|
|
||||||
|
|
||||||
try:
|
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 = ""
|
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].get("message", {}).get("content", "")
|
||||||
usage_data = data.get("usage", {})
|
|
||||||
prompt_tokens = usage_data.get("prompt_tokens")
|
# Usage tokens
|
||||||
output_tokens = usage_data.get("completion_tokens")
|
usage = data.get("usage", {})
|
||||||
return {"text": text, "usage": (prompt_tokens, output_tokens)}
|
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:
|
except Exception as e:
|
||||||
logger.error("ai.openrouter.final_error", extra={
|
elapsed = time.monotonic() - t_start
|
||||||
"model": model, "error_type": type(e).__name__, "error": str(e)[:300],
|
logger.error(
|
||||||
})
|
"ai.call.exception",
|
||||||
return {"text": None, "usage": (None, None), "error": str(e)[:300]}
|
extra={
|
||||||
|
"model": model,
|
||||||
|
"elapsed_s": round(elapsed, 1),
|
||||||
# ─────────────────────────────────────────────────────────────
|
"exception_type": type(e).__name__,
|
||||||
# Dispatcher
|
"error": str(e)[:300],
|
||||||
# ─────────────────────────────────────────────────────────────
|
},
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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:
|
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:
|
||||||
ocr_section = f"""
|
ocr_section = (
|
||||||
Texte détecté dans l'image par OCR (utilise-le pour enrichir ta réponse) :
|
f'\nTexte détecté dans l\'image par OCR : """{ocr_hint[:300]}"""'
|
||||||
\"\"\"
|
)
|
||||||
{ocr_hint[:500]}
|
|
||||||
\"\"\"
|
return f"""Analyse cette image et retourne UNIQUEMENT un objet JSON:
|
||||||
"""
|
|
||||||
return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs :
|
|
||||||
|
|
||||||
{{
|
{{
|
||||||
"description": "Description complète et détaillée en {language}, 2-4 phrases. Décris le sujet principal, le contexte, les couleurs, l'ambiance.",
|
"description": "Description détaillée en {language}, 2-4 phrases.",
|
||||||
"tags": ["tag1", "tag2", "tag3"],
|
"tags": ["tag1", "tag2", "tag3", ...],
|
||||||
"confidence": 0.95
|
"confidence": 0.95
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Règles pour les tags :
|
Règles:
|
||||||
- Entre {settings.AI_TAGS_MIN} et {settings.AI_TAGS_MAX} tags
|
- {settings.AI_TAGS_MIN} à {settings.AI_TAGS_MAX} tags en minuscules, sans espaces
|
||||||
- En minuscules, sans espaces (utiliser des tirets si nécessaire)
|
- Décris le sujet, contexte, couleurs, ambiance
|
||||||
- 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
|
|
||||||
{ocr_section}
|
{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(
|
async def analyze_image(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@@ -320,168 +262,176 @@ 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:
|
||||||
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
|
Priorité du modèle:
|
||||||
active_model = model_override or runtime_ai_config.model or (
|
1. model_override (paramètre explicite)
|
||||||
settings.OPENROUTER_MODEL if active_provider == "openrouter"
|
2. runtime_ai_config.model (override admin live)
|
||||||
else settings.GEMINI_MODEL
|
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 = {
|
result = {
|
||||||
"description": None, "tags": [], "confidence": None,
|
"description": None,
|
||||||
"model": active_model, "prompt_tokens": None, "output_tokens": None,
|
"tags": [],
|
||||||
|
"confidence": None,
|
||||||
|
"model": model,
|
||||||
|
"prompt_tokens": None,
|
||||||
|
"output_tokens": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Lire et redimensionner l'image
|
||||||
try:
|
try:
|
||||||
image_bytes, media_type = await _read_image(file_path)
|
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:
|
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
|
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:
|
async def extract_text_with_ai(file_path: str) -> dict:
|
||||||
|
"""OCR fallback via AI."""
|
||||||
result = {
|
result = {
|
||||||
"text": None, "has_text": False, "language": "unknown",
|
"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:
|
if not settings.AI_ENABLED:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
logger.info("ai.ocr.fallback_start", extra={"file": Path(file_path).name})
|
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
|
||||||
|
|
||||||
try:
|
try:
|
||||||
image_bytes, media_type = await _read_image(file_path)
|
image_bytes, media_type = await _read_image(file_path)
|
||||||
prompt = """Agis comme un moteur OCR avancé.
|
except Exception:
|
||||||
Extrais TOUT le texte visible dans cette image.
|
return result
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
if text:
|
||||||
parsed = _extract_json(text)
|
parsed = _extract_json(text)
|
||||||
if parsed:
|
if parsed:
|
||||||
extracted = parsed.get("text", "").strip()
|
extracted = parsed.get("text", "").strip()
|
||||||
result["text"] = extracted
|
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["language"] = parsed.get("language", "unknown")
|
||||||
result["confidence"] = parsed.get("confidence", 0.0)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
result = {
|
"""Résumé AI d'une URL."""
|
||||||
"summary": "", "tags": [], "model": settings.AI_PROVIDER,
|
result = {"summary": "", "tags": [], "model": "openrouter", "prompt_tokens": None, "output_tokens": None}
|
||||||
"prompt_tokens": None, "output_tokens": None,
|
|
||||||
}
|
|
||||||
if not settings.AI_ENABLED:
|
if not settings.AI_ENABLED:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
prompt = f"""Tu reçois le contenu d'une page web. Génère un résumé et des tags en {language}.
|
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
|
||||||
|
response = await _call_openrouter(
|
||||||
URL : {url}
|
model=model,
|
||||||
|
prompt=f"""URL: {url}\n\nContenu:\n\"\"\"\n{content[:3000]}\n\"\"\"\n\nRésume en {language} et retourne UNIQUEMENT:\n{{"summary": "...", "tags": ["..."]}}""",
|
||||||
Contenu :
|
max_tokens=settings.GEMINI_MAX_TOKENS,
|
||||||
\"\"\"
|
)
|
||||||
{content[:3000]}
|
if response.get("ok"):
|
||||||
\"\"\"
|
text = response.get("text", "")
|
||||||
|
|
||||||
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")
|
|
||||||
if text:
|
if text:
|
||||||
parsed = _extract_json(text)
|
parsed = _extract_json(text)
|
||||||
if parsed:
|
if parsed:
|
||||||
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"] = response.get("prompt_tokens")
|
||||||
except Exception as e:
|
result["output_tokens"] = response.get("completion_tokens")
|
||||||
logger.error("ai.summarize_url.error", extra={"error": str(e)})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
result = {
|
"""Génération AI d'une tâche."""
|
||||||
"title": "", "description": "", "steps": [],
|
result = {"title": "", "description": "", "steps": [], "estimated_time": None, "priority": None,
|
||||||
"estimated_time": None, "priority": None,
|
"model": "openrouter", "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
|
||||||
|
|
||||||
ctx_section = f"\nContexte : {context}" if context else ""
|
ctx = 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}.
|
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
|
||||||
|
response = await _call_openrouter(
|
||||||
Description : {description}{ctx_section}
|
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':'...'}}",
|
||||||
Retourne UNIQUEMENT ce JSON :
|
max_tokens=settings.GEMINI_MAX_TOKENS,
|
||||||
{{
|
)
|
||||||
"title": "Titre court et actionnable",
|
if response.get("ok"):
|
||||||
"description": "Description complète de la tâche",
|
text = response.get("text", "")
|
||||||
"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")
|
|
||||||
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"] = response.get("prompt_tokens")
|
||||||
except Exception as e:
|
result["output_tokens"] = response.get("completion_tokens")
|
||||||
logger.error("ai.draft_task.error", extra={"error": str(e)})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user