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
Build & Push Images / build (brain) (push) Successful in 1m43s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m46s

This commit is contained in:
2026-06-11 01:31:24 +02:00
parent cff2ceb0b9
commit a1f3b9b796
21 changed files with 287 additions and 48 deletions

View File

@@ -25,7 +25,12 @@ from app.application.streaming import with_heartbeat
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou). # 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3 _MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult 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__) logger = logging.getLogger(__name__)
@@ -102,6 +107,29 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()} 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]: 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é). """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" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections." "Renvoie maintenant le JSON des sections."
) )
try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) 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) sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -276,4 +320,4 @@ class ImportRulesUseCase:
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False 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

View File

@@ -38,13 +38,16 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
obj = extract_json_object(raw) obj = extract_json_object(raw)
if obj is not None: if obj is not None:
try: 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: except json.JSONDecodeError:
pass pass
repaired = repair_truncated_json(raw) repaired = repair_truncated_json(raw)
if repaired is not None: if repaired is not None:
try: try:
return json.loads(repaired), True return json.loads(repaired, strict=False), True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
return None, False return None, False

View File

@@ -14,7 +14,7 @@ import asyncio
import logging import logging
import re import re
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -74,6 +74,12 @@ async def generate_with_retry(
for attempt in range(_ATTEMPTS): for attempt in range(_ATTEMPTS):
try: try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature) 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: except LLMProviderError as exc:
last_error = exc last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite # Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite

View File

@@ -113,3 +113,14 @@ class LLMProviderError(Exception):
Définie dans le domaine (pas dans l'infra) pour que les couches Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret. 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).
"""

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,7 +95,7 @@ class GeminiLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: 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 " 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." "taille des morceaux d'import ou augmentez le timeout."
) from exc ) from exc
@@ -130,6 +130,10 @@ class GeminiLLMProvider:
} }
if temperature is not None: if temperature is not None:
body["temperature"] = temperature 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: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,7 +101,7 @@ class MistralLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: 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 " 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." "taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc ) from exc
@@ -136,6 +136,11 @@ class MistralLLMProvider:
} }
if temperature is not None: if temperature is not None:
body["temperature"] = temperature 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: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:

View File

@@ -11,7 +11,7 @@ import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
class OllamaLLMProvider: class OllamaLLMProvider:
@@ -71,6 +71,22 @@ class OllamaLLMProvider:
raise LLMProviderError( raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}" 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: except httpx.HTTPError as exc:
raise LLMProviderError( raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}" f"Erreur lors de l'appel à Ollama : {exc}"

View File

@@ -22,7 +22,7 @@ from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
logger = logging.getLogger(__name__) 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" _API_URL = "https://openrouter.ai/api/v1/chat/completions"
@@ -113,7 +113,7 @@ class OpenRouterLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: 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 " 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." "taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc ) from exc

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", 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__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.12.0-beta</version> <version>0.12.1-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -64,9 +64,11 @@ public class CampaignImportService {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError); campaignPdfImporter.importCampaignStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
} }
/** /**

View File

@@ -39,9 +39,11 @@ public class GameSystemService {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress, java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
Runnable onHeartbeat,
java.util.function.Consumer<RulesImportResult> onDone, java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) { java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError); rulesPdfImporter.importRulesStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
} }
/** /**

View File

@@ -16,6 +16,9 @@ public interface CampaignPdfImporter {
* l'avancement au fil de l'eau, puis la proposition finale. * l'avancement au fil de l'eau, puis la proposition finale.
* *
* @param onProgress invoqué à chaque étape (extraction, puis par morceau). * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté). * @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue. * @param onError invoqué si l'extraction/structuration échoue.
*/ */
@@ -23,6 +26,7 @@ public interface CampaignPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -27,6 +27,9 @@ public interface RulesPdfImporter {
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}). * d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
* *
* @param onProgress invoqué à chaque étape (extraction, puis par morceau). * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onDone invoqué une fois avec le résultat final. * @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue. * @param onError invoqué si l'extraction/structuration échoue.
*/ */
@@ -34,6 +37,7 @@ public interface RulesPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -60,6 +60,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -83,7 +84,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
flux flux
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError))
.blockLast(); .blockLast();
if (!terminated[0]) { if (!terminated[0]) {
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(
@@ -107,12 +109,19 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = sse.event();
String data = sse.data() == null ? "" : sse.data(); String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
onHeartbeat.run();
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(

View File

@@ -114,6 +114,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -139,7 +140,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
flux flux
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onDone, onError))
.blockLast(); .blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale. // Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) { if (!terminated[0]) {
@@ -165,12 +167,20 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = sse.event();
String data = sse.data() == null ? "" : sse.data(); String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
// resté silencieux pendant tout le traitement du morceau.
onHeartbeat.run();
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new RulesImportException( onError.accept(new RulesImportException(

View File

@@ -10,6 +10,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -70,6 +71,18 @@ public class GlobalExceptionHandler {
)); ));
} }
/**
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
* Ce n'est PAS une erreur serveur -> pas de log ERROR + stack trace (bruit),
* et aucune reponse a renvoyer (le canal est mort).
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleClientDisconnected(HttpServletRequest request, AsyncRequestNotUsableException ex) {
log.debug("Client deconnecte pendant la reponse asynchrone sur {} {} : {}",
request.getMethod(), request.getRequestURI(), ex.getMessage());
}
/** /**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec * Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable * un log ERROR explicite (path + stack trace) et un body JSON debuggable

View File

@@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène. * REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
@@ -54,33 +55,55 @@ public class CampaignImportController {
@RequestParam("file") MultipartFile file) throws IOException { @RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide."); sendError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter; return emitter;
} }
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true));
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
try { try {
campaignImportService.importStructureStreaming( campaignImportService.importStructureStreaming(
bytes, filename, bytes, filename,
progress -> sendEvent(emitter, "progress", progress), progress -> sendEvent(emitter, clientGone, "progress", progress),
() -> sendHeartbeat(emitter, clientGone),
proposal -> { proposal -> {
sendEvent(emitter, "done", proposal); sendEvent(emitter, clientGone, "done", proposal);
emitter.complete(); emitter.complete();
}, },
error -> { error -> {
if (clientGone.get()) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import campagne (stream) échoué : {}", error.getMessage()); log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, error.getMessage()); sendError(emitter, clientGone, error.getMessage());
}); });
} catch (ClientGoneException e) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
} catch (Exception e) { } catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage()); log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, e.getMessage()); sendError(emitter, clientGone, e.getMessage());
} }
}); });
return emitter; return emitter;
} }
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply( public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId, @PathVariable String campaignId,
@@ -96,23 +119,52 @@ public class CampaignImportController {
// --- Helpers SSE --------------------------------------------------------- // --- Helpers SSE ---------------------------------------------------------
private void sendEvent(SseEmitter emitter, String eventName, Object payload) { private void sendEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try { try {
emitter.send(SseEmitter.event().name(eventName).data( emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) { } catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti — on interrompt le pipeline amont au lieu de rejouer l'échec.
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
throw new ClientGoneException(e);
} }
} }
private void sendError(SseEmitter emitter, String message) { /**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
private void sendError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
try { try {
emitter.send(SseEmitter.event().name("error").data( emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of( objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")), "message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON)); MediaType.APPLICATION_JSON));
emitter.complete(); emitter.complete();
} catch (IOException e) { } catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
} }
} }

View File

@@ -3,7 +3,6 @@ package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField; import com.loremind.domain.shared.template.TemplateField;
@@ -27,6 +26,7 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -135,7 +135,7 @@ public class GameSystemController {
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException { public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
sendImportError(emitter, "Fichier PDF vide."); sendImportError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter; return emitter;
} }
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus // Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
@@ -143,46 +143,100 @@ public class GameSystemController {
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true));
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
try { try {
gameSystemService.importRulesFromPdfStreaming( gameSystemService.importRulesFromPdfStreaming(
bytes, filename, bytes, filename,
progress -> sendImportEvent(emitter, "progress", progress), progress -> sendImportEvent(emitter, clientGone, "progress", progress),
() -> sendImportHeartbeat(emitter, clientGone),
result -> { result -> {
sendImportEvent(emitter, "done", result); sendImportEvent(emitter, clientGone, "done", result);
emitter.complete(); emitter.complete();
}, },
error -> { error -> {
if (clientGone.get()) {
// La "panne" amont n'est que l'écho de la déconnexion
// du navigateur : pas un échec d'import.
log.info("Import de règles (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import de règles (stream) échoué : {}", error.getMessage()); log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, error.getMessage()); sendImportError(emitter, clientGone, error.getMessage());
}); });
} catch (ClientGoneException e) {
log.info("Import de règles (stream) interrompu : client déconnecté.");
} catch (Exception e) { } catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage()); log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, e.getMessage()); sendImportError(emitter, clientGone, e.getMessage());
} }
}); });
return emitter; return emitter;
} }
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */ /** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) { private void sendImportEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try { try {
emitter.send(SseEmitter.event().name(eventName).data( emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) { } catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti. On marque l'état et on INTERROMPT le pipeline amont — sinon
// chaque évènement suivant rejouerait l'échec (bruit de logs + LLM gaspillé).
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
/**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendImportHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
} }
} }
/** Envoie un évènement `error` {message} puis termine le flux. */ /** Envoie un évènement `error` {message} puis termine le flux. */
private void sendImportError(SseEmitter emitter, String message) { private void sendImportError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
try { try {
emitter.send(SseEmitter.event().name("error").data( emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of( objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")), "message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON)); MediaType.APPLICATION_JSON));
emitter.complete(); emitter.complete();
} catch (IOException e) { } catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
} }
} }

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.0-beta", "version": "0.12.1-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.0-beta", "version": "0.12.1-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.0-beta", "version": "0.12.1-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",