diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py
index cc1b3e0..e2631ff 100644
--- a/brain/app/application/import_rules.py
+++ b/brain/app/application/import_rules.py
@@ -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
diff --git a/brain/app/application/llm_json.py b/brain/app/application/llm_json.py
index 0d33f4e..81b4bc9 100644
--- a/brain/app/application/llm_json.py
+++ b/brain/app/application/llm_json.py
@@ -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
diff --git a/brain/app/application/llm_retry.py b/brain/app/application/llm_retry.py
index 2c68fb1..6da70b0 100644
--- a/brain/app/application/llm_retry.py
+++ b/brain/app/application/llm_retry.py
@@ -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
diff --git a/brain/app/domain/ports.py b/brain/app/domain/ports.py
index aaaeb97..d9407d5 100644
--- a/brain/app/domain/ports.py
+++ b/brain/app/domain/ports.py
@@ -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).
+ """
diff --git a/brain/app/infrastructure/gemini_adapter.py b/brain/app/infrastructure/gemini_adapter.py
index 7640d43..67f0fba 100644
--- a/brain/app/infrastructure/gemini_adapter.py
+++ b/brain/app/infrastructure/gemini_adapter.py
@@ -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:
diff --git a/brain/app/infrastructure/mistral_adapter.py b/brain/app/infrastructure/mistral_adapter.py
index bc63486..10e9a09 100644
--- a/brain/app/infrastructure/mistral_adapter.py
+++ b/brain/app/infrastructure/mistral_adapter.py
@@ -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:
diff --git a/brain/app/infrastructure/ollama_adapter.py b/brain/app/infrastructure/ollama_adapter.py
index 59cf547..e5af2e2 100644
--- a/brain/app/infrastructure/ollama_adapter.py
+++ b/brain/app/infrastructure/ollama_adapter.py
@@ -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}"
diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py
index 7e43943..0dc56c1 100644
--- a/brain/app/infrastructure/openrouter_adapter.py
+++ b/brain/app/infrastructure/openrouter_adapter.py
@@ -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
diff --git a/brain/app/main.py b/brain/app/main.py
index ed1ef31..81a9283 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -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__)
diff --git a/core/pom.xml b/core/pom.xml
index 7391d46..397d360 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremind
loremind-core
- 0.12.0-beta
+ 0.12.1-beta
LoreMind Core
Backend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java
index a297581..7759768 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java
@@ -64,9 +64,11 @@ public class CampaignImportService {
byte[] pdfBytes,
String filename,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError) {
- campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
+ campaignPdfImporter.importCampaignStreaming(
+ pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
}
/**
diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java
index 5782924..d75c429 100644
--- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java
+++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemService.java
@@ -39,9 +39,11 @@ public class GameSystemService {
byte[] pdfBytes,
String filename,
java.util.function.Consumer onProgress,
+ Runnable onHeartbeat,
java.util.function.Consumer onDone,
java.util.function.Consumer onError) {
- rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
+ rulesPdfImporter.importRulesStreaming(
+ pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
}
/**
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java
index 5879db3..ffb8e02 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/CampaignPdfImporter.java
@@ -15,14 +15,18 @@ public interface CampaignPdfImporter {
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale.
*
- * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
- * @param onDone invoqué une fois avec l'arbre proposé (non persisté).
- * @param onError invoqué si l'extraction/structuration échoue.
+ * @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 onError invoqué si l'extraction/structuration échoue.
*/
void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError);
}
diff --git a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java
index 006d0be..828a83a 100644
--- a/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java
+++ b/core/src/main/java/com/loremind/domain/gamesystemcontext/ports/RulesPdfImporter.java
@@ -26,14 +26,18 @@ public interface RulesPdfImporter {
* l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
*
- * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
- * @param onDone invoqué une fois avec le résultat final.
- * @param onError invoqué si l'extraction/structuration échoue.
+ * @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 onError invoqué si l'extraction/structuration échoue.
*/
void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError);
}
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
index 3a7ba76..6870c9d 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainCampaignImportClient.java
@@ -60,6 +60,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
byte[] pdfBytes,
String filename,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError) {
@@ -83,7 +84,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
- sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
+ sse, pageCount, ocrPageCount, terminated,
+ onProgress, onHeartbeat, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
@@ -107,12 +109,19 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount,
boolean[] terminated,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError) {
String event = sse.event();
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)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java
index 3a49c08..84831ff 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainRulesImportClient.java
@@ -114,6 +114,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
byte[] pdfBytes,
String filename,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError) {
@@ -139,7 +140,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
- sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
+ sse, pageCount, ocrPageCount, terminated,
+ onProgress, onHeartbeat, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
@@ -165,12 +167,20 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount,
boolean[] terminated,
Consumer onProgress,
+ Runnable onHeartbeat,
Consumer onDone,
Consumer onError) {
String event = sse.event();
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)) {
terminated[0] = true;
onError.accept(new RulesImportException(
diff --git a/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java b/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java
index 77e1db2..3ae3676 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/GlobalExceptionHandler.java
@@ -10,6 +10,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.util.LinkedHashMap;
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
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java
index 70a614f..139a14f 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java
@@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
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.
@@ -54,33 +55,55 @@ public class CampaignImportController {
@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
- sendError(emitter, "Fichier PDF vide.");
+ sendError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
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(() -> {
try {
campaignImportService.importStructureStreaming(
bytes, filename,
- progress -> sendEvent(emitter, "progress", progress),
+ progress -> sendEvent(emitter, clientGone, "progress", progress),
+ () -> sendHeartbeat(emitter, clientGone),
proposal -> {
- sendEvent(emitter, "done", proposal);
+ sendEvent(emitter, clientGone, "done", proposal);
emitter.complete();
},
error -> {
+ if (clientGone.get()) {
+ log.info("Import campagne (stream) interrompu : client déconnecté.");
+ return;
+ }
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) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
- sendError(emitter, e.getMessage());
+ sendError(emitter, clientGone, e.getMessage());
}
});
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)
public ResponseEntity apply(
@PathVariable String campaignId,
@@ -96,23 +119,52 @@ public class CampaignImportController {
// --- 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 {
emitter.send(SseEmitter.event().name(eventName).data(
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);
+ 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 {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
- } catch (IOException e) {
+ } catch (Exception e) {
+ clientGone.set(true);
emitter.completeWithError(e);
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
index 04cd7f9..b76d2ce 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java
@@ -3,7 +3,6 @@ package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
-import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
@@ -27,6 +26,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@RestController
@@ -135,7 +135,7 @@ public class GameSystemController {
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
- sendImportError(emitter, "Fichier PDF vide.");
+ sendImportError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter;
}
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
@@ -143,46 +143,100 @@ public class GameSystemController {
byte[] bytes = file.getBytes();
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(() -> {
try {
gameSystemService.importRulesFromPdfStreaming(
bytes, filename,
- progress -> sendImportEvent(emitter, "progress", progress),
+ progress -> sendImportEvent(emitter, clientGone, "progress", progress),
+ () -> sendImportHeartbeat(emitter, clientGone),
result -> {
- sendImportEvent(emitter, "done", result);
+ sendImportEvent(emitter, clientGone, "done", result);
emitter.complete();
},
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());
- 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) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
- sendImportError(emitter, e.getMessage());
+ sendImportError(emitter, clientGone, e.getMessage());
}
});
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é. */
- 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 {
emitter.send(SseEmitter.event().name(eventName).data(
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);
+ 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. */
- 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 {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
- } catch (IOException e) {
+ } catch (Exception e) {
+ clientGone.set(true);
emitter.completeWithError(e);
}
}
diff --git a/web/package-lock.json b/web/package-lock.json
index db67679..176ef31 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loremind-web",
- "version": "0.12.0-beta",
+ "version": "0.12.1-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
- "version": "0.12.0-beta",
+ "version": "0.12.1-beta",
"dependencies": {
"@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16",
diff --git a/web/package.json b/web/package.json
index e3be311..595d413 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "loremind-web",
- "version": "0.12.0-beta",
+ "version": "0.12.1-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",