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",