Compare commits

..

4 Commits

Author SHA1 Message Date
a1f3b9b796 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
2026-06-11 01:31:24 +02:00
cff2ceb0b9 Ajout des dépendance dans le package.json pour forcer les dépendances sur les bonnes versions
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m45s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m51s
2026-06-10 16:38:25 +02:00
d1a11823bc Regénération du package-lock avec les bonnes versions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Failing after 54s
2026-06-10 16:32:37 +02:00
e1da369cfa Regénération du package-lock pour résorber les problème sur git actions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 24s
Build & Push Images / build (web) (push) Failing after 46s
2026-06-10 16:22:46 +02:00
21 changed files with 635 additions and 437 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).
_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

View File

@@ -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

View File

@@ -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

View File

@@ -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).
"""

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}"

View File

@@ -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

View File

@@ -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__)

View File

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

View File

@@ -64,9 +64,11 @@ public class CampaignImportService {
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
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,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
Runnable onHeartbeat,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
rulesPdfImporter.importRulesStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
}
/**

View File

@@ -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<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError);
}

View File

@@ -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<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError);
}

View File

@@ -60,6 +60,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> 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<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> 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(

View File

@@ -114,6 +114,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> 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<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> 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(

View File

@@ -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

View File

@@ -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<CampaignImportService.ApplyResult> 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);
}
}

View File

@@ -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);
}
}

739
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.12.0-beta",
"version": "0.12.1-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
@@ -34,6 +34,8 @@
"@angular-devkit/build-angular": "^21.2.14",
"@angular/cli": "^21.2.14",
"@angular/compiler-cli": "^21.2.16",
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.9.3"
}