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