Rewrite ai_vision.py: simplified single-call OpenRouter, no retries, 180s timeout
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

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:
2026-06-26 07:44:44 -04:00
parent cd2a2efbd5
commit 73a5e401d2
+294 -344
View File
@@ -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