Amélioration de l'exploitation des PDF par l'IA
All checks were successful
All checks were successful
Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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(() -> {
|
||||||
|
|||||||
@@ -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(() -> {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user