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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user