Amélioration de l'exploitation des PDF par l'IA
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 2m0s
Build & Push Images / build-switcher (push) Successful in 23s
Build & Push Images / build (web) (push) Successful in 1m50s

Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
This commit is contained in:
2026-06-12 01:28:45 +02:00
parent 0799c850ec
commit 7f519588b6
7 changed files with 324 additions and 17 deletions

View File

@@ -111,9 +111,14 @@ def get_import_rules_use_case(
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase: ) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration).""" """Factory du use case d'import de règles PDF (extraction + structuration)."""
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
return ImportRulesUseCase( return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, llm=llm, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings)) chunk_target_tokens=_effective_import_chunk_tokens(settings),
segment_only=settings.llm_provider == "ollama")
def get_import_campaign_use_case( def get_import_campaign_use_case(

View File

@@ -29,7 +29,12 @@ from app.domain.models import (
RoomProposal, RoomProposal,
SceneProposal, SceneProposal,
) )
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,6 +112,84 @@ Format de réponse :
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait. - N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}.""" - Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
# requis : le _TreeMerger tolère déjà tous les champs absents.
_TREE_SCHEMA: dict = {
"type": "object",
"properties": {
"arcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"player_narration": {"type": "string"},
"gm_notes": {"type": "string"},
"rooms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enemies": {"type": "string"},
"loot": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["arcs"],
"additionalProperties": False,
}
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités # Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les # séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle # mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
@@ -480,6 +563,17 @@ class ImportCampaignUseCase:
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
# l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
# Consolidation finale : fusion des quasi-doublons inter-morceaux # Consolidation finale : fusion des quasi-doublons inter-morceaux
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau. # (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
if total > 1: if total > 1:
@@ -557,8 +651,26 @@ class ImportCampaignUseCase:
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."
) )
try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Génération trop lente pour la taille demandée (fréquent en local /
# tier gratuit) : même remède que la troncature, deux moitiés →
# sortie 2× plus courte. Re-lever si plus découpable.
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_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
payload, truncated = self._parse_payload(raw, index=index) payload, truncated = self._parse_payload(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:

View File

@@ -13,6 +13,7 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
@@ -86,6 +87,55 @@ Règles impératives :
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond. - N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle).""" - Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible.
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"titre": {"type": "string"},
"debut": {"type": "string"},
},
"required": ["titre", "debut"],
"additionalProperties": False,
},
},
},
"required": ["sections"],
"additionalProperties": False,
}
class _SectionMerger: class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé. """Fusionne les sections issues des différents morceaux, ordre préservé.
@@ -178,6 +228,27 @@ def _coerce_markdown(value: object) -> str:
return "" if value is None else str(value) return "" if value is None else str(value)
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
"""Position de `anchor` dans `text` à partir de `start`, ou None.
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
pos = text.find(anchor, start)
if pos != -1:
return pos
words = anchor.split()
if not words:
return None
pattern = r"\s+".join(re.escape(w) for w in words)
match = re.compile(pattern).search(text, start)
if match:
return match.start()
match = re.compile(pattern, re.IGNORECASE).search(text, start)
return match.start() if match else None
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]: 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é). """Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
@@ -204,10 +275,16 @@ class ImportRulesUseCase:
llm: LLMProvider, llm: LLMProvider,
extractor: PdfTextExtractor, extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS, chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
segment_only: bool = False,
) -> None: ) -> None:
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
frontières des sections (titre + premiers mots) et le texte original est
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
le LLM réécrit le contenu en sections markdown nettoyées."""
self._llm = llm self._llm = llm
self._extractor = extractor self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult: async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet.""" """Variante non-streamée : traite tout puis renvoie le résultat complet."""
@@ -322,8 +399,10 @@ class ImportRulesUseCase:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = ( prompt = (
_MAP_SYSTEM.format( system.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
) )
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
@@ -331,7 +410,7 @@ class ImportRulesUseCase:
) )
try: try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, prompt, output_format=_SECTIONS_SCHEMA, temperature=_TEMPERATURE) self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
except LLMGenerationTimeout: except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau # 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). # dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
@@ -347,6 +426,9 @@ class ImportRulesUseCase:
a = await self._extract_sections(left, index=index, total=total, depth=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) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b) return _combine_sections(a, b)
if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index)
else:
sections, truncated = self._parse_sections(raw, index=index) sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -363,6 +445,68 @@ class ImportRulesUseCase:
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections return sections
@staticmethod
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
Une ancre introuvable est abandonnée (son contenu reste dans la section
précédente — aucun texte n'est perdu). Le texte avant la première ancre
trouvée est rattaché à la première section (le prompt demande au modèle de
faire démarrer la première entrée aux premiers mots de l'extrait)."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
entries = parsed.get("sections") if isinstance(parsed, dict) else None
if not isinstance(entries, list):
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
return {}, False
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
located: list[tuple[str, int]] = []
cursor = 0
dropped = 0
for entry in entries:
if not isinstance(entry, dict):
continue
title = str(entry.get("titre") or "").strip()
anchor = str(entry.get("debut") or "").strip()
if not title or not anchor:
continue
pos = _find_anchor(text, anchor, cursor)
if pos is None:
dropped += 1
continue
located.append((title, pos))
cursor = pos + 1
if dropped:
logger.info(
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
"à la section précédente.", index, dropped)
if not located:
return {}, False
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
located[0] = (located[0][0], 0)
sections: dict[str, str] = {}
for i, (title, start) in enumerate(located):
end = located[i + 1][1] if i + 1 < len(located) else len(text)
content = text[start:end].strip()
if not content:
continue
if title in sections:
sections[title] = f"{sections[title]}\n\n{content}"
else:
sections[title] = content
return sections, recovered
@staticmethod @staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]: def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle.""" """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""

View File

@@ -31,8 +31,13 @@ public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class); private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final CampaignImportService campaignImportService; private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor; private final TaskExecutor taskExecutor;
@@ -66,7 +71,15 @@ public class CampaignImportController {
// amont (ClientGoneException remonte dans le doOnNext du WebClient → // amont (ClientGoneException remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM). // annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false); AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true)); emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true)); emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {

View File

@@ -35,8 +35,13 @@ public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class); private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final GameSystemService gameSystemService; private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper; private final GameSystemMapper gameSystemMapper;
@@ -148,7 +153,15 @@ public class GameSystemController {
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient → // amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM). // annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false); AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> clientGone.set(true)); emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendImportError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true)); emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {

View File

@@ -61,17 +61,24 @@ export class CampaignImportService {
let buffer = ''; let buffer = '';
let currentEvent: string | null = null; let currentEvent: string | null = null;
let currentData = ''; let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => { const dispatch = () => {
const name = currentEvent ?? 'message'; const name = currentEvent ?? 'message';
if (name === 'error') { if (name === 'error') {
let message = 'Échec de l\'import.'; let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
terminated = true;
subscriber.error(new Error(message)); subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') { } else if (name === 'progress' || name === 'done') {
try { try {
const obj = JSON.parse(currentData); const obj = JSON.parse(currentData);
if (name === 'done') { if (name === 'done') {
terminated = true;
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] }); subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
subscriber.complete(); subscriber.complete();
} else { } else {
@@ -105,9 +112,12 @@ export class CampaignImportService {
} }
} }
if (currentEvent !== null || currentData !== '') dispatch(); if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete(); if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
}
} catch (err) { } catch (err) {
subscriber.error(err); if (!terminated) subscriber.error(err);
} }
} }
} }

View File

@@ -92,17 +92,24 @@ export class GameSystemService {
let buffer = ''; let buffer = '';
let currentEvent: string | null = null; let currentEvent: string | null = null;
let currentData = ''; let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => { const dispatch = () => {
const name = currentEvent ?? 'message'; const name = currentEvent ?? 'message';
if (name === 'error') { if (name === 'error') {
let message = 'Échec de l\'import.'; let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
terminated = true;
subscriber.error(new Error(message)); subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') { } else if (name === 'progress' || name === 'done') {
try { try {
const obj = JSON.parse(currentData); const obj = JSON.parse(currentData);
if (name === 'done') { if (name === 'done') {
terminated = true;
subscriber.next({ type: 'done', ...obj }); subscriber.next({ type: 'done', ...obj });
subscriber.complete(); subscriber.complete();
} else { } else {
@@ -136,9 +143,12 @@ export class GameSystemService {
} }
} }
if (currentEvent !== null || currentData !== '') dispatch(); if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete(); if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
}
} catch (err) { } catch (err) {
subscriber.error(err); if (!terminated) subscriber.error(err);
} }
} }
} }