Améliorations sur l'utilisation de l'IA pour l'exploitation des PDF, que ce soit la partie cloud ou la partie ollama + montée en version
All checks were successful
All checks were successful
This commit is contained in:
@@ -25,7 +25,12 @@ from app.application.streaming import with_heartbeat
|
||||
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
|
||||
_MAX_SPLIT_DEPTH = 3
|
||||
from app.domain.models import RulesImportResult
|
||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
|
||||
from app.domain.ports import (
|
||||
LLMGenerationTimeout,
|
||||
LLMProvider,
|
||||
LLMProviderError,
|
||||
PdfTextExtractor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,6 +107,29 @@ class _SectionMerger:
|
||||
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
|
||||
|
||||
|
||||
def _coerce_markdown(value: object) -> str:
|
||||
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
||||
|
||||
Malgré la consigne « valeurs = markdown », certains modèles nichent des
|
||||
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
|
||||
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
|
||||
place pour ne perdre aucun contenu.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
content = _coerce_markdown(v)
|
||||
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
|
||||
# elle-même porte le contenu (dérive observée sur certains modèles).
|
||||
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
|
||||
return "\n\n".join(parts)
|
||||
if isinstance(value, list):
|
||||
return "\n\n".join(_coerce_markdown(v) for v in value)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
|
||||
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
|
||||
|
||||
@@ -241,8 +269,24 @@ class ImportRulesUseCase:
|
||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||
"Renvoie maintenant le JSON des sections."
|
||||
)
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
||||
try:
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
||||
except LLMGenerationTimeout:
|
||||
# Le modèle générait mais trop lentement pour réécrire tout le morceau
|
||||
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
|
||||
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
|
||||
if depth >= _MAX_SPLIT_DEPTH:
|
||||
raise
|
||||
left, right = split_in_half(text)
|
||||
if not left or not right:
|
||||
raise
|
||||
logger.info(
|
||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||
return _combine_sections(a, b)
|
||||
sections, truncated = self._parse_sections(raw, index=index)
|
||||
|
||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||
@@ -276,4 +320,4 @@ class ImportRulesUseCase:
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
||||
return {}, False
|
||||
return {str(k): str(v) for k, v in parsed.items()}, recovered
|
||||
return {str(k): _coerce_markdown(v) for k, v in parsed.items()}, recovered
|
||||
|
||||
@@ -38,13 +38,16 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
|
||||
obj = extract_json_object(raw)
|
||||
if obj is not None:
|
||||
try:
|
||||
return json.loads(obj), False
|
||||
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
|
||||
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
|
||||
# mode JSON natif, qui invalidait toute la réponse.
|
||||
return json.loads(obj, strict=False), False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
repaired = repair_truncated_json(raw)
|
||||
if repaired is not None:
|
||||
try:
|
||||
return json.loads(repaired), True
|
||||
return json.loads(repaired, strict=False), True
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None, False
|
||||
|
||||
@@ -14,7 +14,7 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,6 +74,12 @@ async def generate_with_retry(
|
||||
for attempt in range(_ATTEMPTS):
|
||||
try:
|
||||
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
|
||||
except LLMGenerationTimeout:
|
||||
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
|
||||
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
|
||||
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
|
||||
# sait re-découper le morceau en deux pour réduire la sortie.
|
||||
raise
|
||||
except LLMProviderError as exc:
|
||||
last_error = exc
|
||||
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
|
||||
|
||||
@@ -113,3 +113,14 @@ class LLMProviderError(Exception):
|
||||
Définie dans le domaine (pas dans l'infra) pour que les couches
|
||||
supérieures puissent l'attraper sans connaître l'adapter concret.
|
||||
"""
|
||||
|
||||
|
||||
class LLMGenerationTimeout(LLMProviderError):
|
||||
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
|
||||
|
||||
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
|
||||
produisait des tokens mais trop lentement pour la taille de sortie demandée.
|
||||
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
|
||||
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
|
||||
morceau en deux moitiés).
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@ import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,7 +95,7 @@ class GeminiLLMProvider:
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import ou augmentez le timeout."
|
||||
) from exc
|
||||
@@ -130,6 +130,10 @@ class GeminiLLMProvider:
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
||||
# supprime fences ```json et JSON invalide, principale cause de morceaux ignorés.
|
||||
if output_format == "json":
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
|
||||
@@ -20,7 +20,7 @@ import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,7 +101,7 @@ class MistralLLMProvider:
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
@@ -136,6 +136,11 @@ class MistralLLMProvider:
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
|
||||
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
|
||||
# principale cause de morceaux d'import ignorés.
|
||||
if output_format == "json":
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
|
||||
@@ -11,7 +11,7 @@ import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
|
||||
class OllamaLLMProvider:
|
||||
@@ -71,6 +71,22 @@ class OllamaLLMProvider:
|
||||
raise LLMProviderError(
|
||||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||
)
|
||||
except httpx.ConnectTimeout as exc:
|
||||
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
|
||||
# donc le dépasser = génération trop lente pour la sortie demandée
|
||||
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
|
||||
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
|
||||
# morceau en deux moitiés (sortie 2× plus courte) à la place.
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
|
||||
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
|
||||
"modèle plus rapide."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.domain.ports import LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
@@ -113,7 +113,7 @@ class OpenRouterLLMProvider:
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMProviderError(
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.12.0-beta",
|
||||
version="0.12.1-beta",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Reference in New Issue
Block a user