diff --git a/brain/app/api/deps.py b/brain/app/api/deps.py index a93fa87..1326a17 100644 --- a/brain/app/api/deps.py +++ b/brain/app/api/deps.py @@ -111,9 +111,14 @@ def get_import_rules_use_case( settings: Annotated[Settings, Depends(get_settings)], ) -> ImportRulesUseCase: """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( 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( diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index 08061d1..dd3cbea 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -29,7 +29,12 @@ from app.domain.models import ( RoomProposal, SceneProposal, ) -from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor +from app.domain.ports import ( + LLMGenerationTimeout, + LLMProvider, + LLMProviderError, + PdfTextExtractor, +) 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. - 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 # 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 @@ -480,6 +563,17 @@ class ImportCampaignUseCase: f"Dernier message : {last_error or 'inconnu'}"} 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 # (best-effort, voir _consolidate). Inutile sur un import mono-morceau. if total > 1: @@ -557,8 +651,26 @@ class ImportCampaignUseCase: + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON de l'arborescence." ) - raw = await generate_with_retry( - self._llm, prompt, output_format="json", temperature=_TEMPERATURE) + try: + raw = await generate_with_retry( + 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) if truncated and depth < _MAX_SPLIT_DEPTH: diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index ab15566..2f5424f 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -13,6 +13,7 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor from __future__ import annotations import logging +import re 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 @@ -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. - 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: """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) +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]: """Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé). @@ -204,10 +275,16 @@ class ImportRulesUseCase: llm: LLMProvider, extractor: PdfTextExtractor, chunk_target_tokens: int = CHUNK_TARGET_TOKENS, + segment_only: bool = False, ) -> 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._extractor = extractor self._chunk_target_tokens = chunk_target_tokens + self._segment_only = segment_only async def execute(self, pdf_bytes: bytes) -> RulesImportResult: """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 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.""" + system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM + schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA prompt = ( - _MAP_SYSTEM.format( + system.format( canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) ) + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" @@ -331,7 +410,7 @@ class ImportRulesUseCase: ) try: raw = await generate_with_retry( - self._llm, prompt, output_format=_SECTIONS_SCHEMA, temperature=_TEMPERATURE) + self._llm, prompt, output_format=schema, 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). @@ -347,7 +426,10 @@ class ImportRulesUseCase: 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 self._segment_only: + sections, truncated = self._parse_anchors(raw, text, index=index) + else: + sections, truncated = self._parse_sections(raw, index=index) if truncated and depth < _MAX_SPLIT_DEPTH: left, right = split_in_half(text) @@ -363,6 +445,68 @@ class ImportRulesUseCase: "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) 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 def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]: """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle.""" diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java index 139a14f..085b30e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignImportController.java @@ -31,8 +31,13 @@ public class CampaignImportController { 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 TaskExecutor taskExecutor; @@ -66,7 +71,15 @@ public class CampaignImportController { // 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.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)); taskExecutor.execute(() -> { diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java index b76d2ce..8adf53b 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/GameSystemController.java @@ -35,8 +35,13 @@ public class GameSystemController { 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 GameSystemMapper gameSystemMapper; @@ -148,7 +153,15 @@ public class GameSystemController { // 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.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)); taskExecutor.execute(() -> { diff --git a/web/src/app/services/campaign-import.service.ts b/web/src/app/services/campaign-import.service.ts index 697fe4a..f5cd6b1 100644 --- a/web/src/app/services/campaign-import.service.ts +++ b/web/src/app/services/campaign-import.service.ts @@ -61,17 +61,24 @@ export class CampaignImportService { let buffer = ''; let currentEvent: string | null = null; 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 name = currentEvent ?? 'message'; if (name === 'error') { let message = 'Échec de l\'import.'; try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ } + terminated = true; subscriber.error(new Error(message)); } else if (name === 'progress' || name === 'done') { try { const obj = JSON.parse(currentData); if (name === 'done') { + terminated = true; subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] }); subscriber.complete(); } else { @@ -105,9 +112,12 @@ export class CampaignImportService { } } 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) { - subscriber.error(err); + if (!terminated) subscriber.error(err); } } } diff --git a/web/src/app/services/game-system.service.ts b/web/src/app/services/game-system.service.ts index 0115d5c..8dfb49c 100644 --- a/web/src/app/services/game-system.service.ts +++ b/web/src/app/services/game-system.service.ts @@ -92,17 +92,24 @@ export class GameSystemService { let buffer = ''; let currentEvent: string | null = null; 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 name = currentEvent ?? 'message'; if (name === 'error') { let message = 'Échec de l\'import.'; try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ } + terminated = true; subscriber.error(new Error(message)); } else if (name === 'progress' || name === 'done') { try { const obj = JSON.parse(currentData); if (name === 'done') { + terminated = true; subscriber.next({ type: 'done', ...obj }); subscriber.complete(); } else { @@ -136,9 +143,12 @@ export class GameSystemService { } } 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) { - subscriber.error(err); + if (!terminated) subscriber.error(err); } } }