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)],
) -> 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(

View File

@@ -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."
)
try:
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)
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
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,6 +426,9 @@ 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)
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:
@@ -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."""

View File

@@ -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(() -> {

View File

@@ -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(() -> {

View File

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

View File

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