Plusieurs gros ajouts :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m29s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m43s

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 76977eda0e
commit 75357983a1
113 changed files with 6347 additions and 662 deletions

View File

@@ -14,9 +14,16 @@ from __future__ import annotations
import logging
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text
from app.application.llm_json import load_json_object
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_retry import generate_with_retry
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ;
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
@@ -95,6 +102,24 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
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é).
Titres insensibles à la casse : un même titre présent des deux côtés (une section
coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés.
"""
out = dict(a)
by_lower = {k.lower(): k for k in out}
for title, content in b.items():
key = by_lower.get(title.lower())
if key is not None:
out[key] = f"{out[key]}\n\n{content}".strip()
else:
out[title] = content
by_lower[title.lower()] = title
return out
class ImportRulesUseCase:
"""Transforme un PDF de règles en proposition de sections markdown."""
@@ -152,48 +177,103 @@ class ImportRulesUseCase:
}
merger = _SectionMerger()
skipped = 0
last_error: str | None = None
for i, chunk in enumerate(chunks):
new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total))
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
new_titles: list[str] = []
try:
sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total)
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
else:
sections = payload
new_titles = merger.add(sections or {})
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
"skipped": skipped,
}
if total > 0 and skipped == total:
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
yield {
"type": "done",
"sections": merger.result(),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
return await self._extract_sections(chunk, index=index, total=total, depth=0)
async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int
) -> dict[str, str]:
"""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."""
prompt = (
_MAP_SYSTEM.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n"
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
return self._parse_sections(raw, index=index)
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, 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)
return _combine_sections(a, b)
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections
@staticmethod
def _parse_sections(raw: str, *, index: int) -> dict[str, str]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
return {}
if recovered:
logger.warning(
"Morceau %s : sortie tronquée — récupération des sections complètes "
"(envisagez des morceaux plus petits).", index)
# Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
# structure complète (→ on signalera 'tronqué' pour re-découper).
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}
return {str(k): str(v) for k, v in parsed.items()}
return {}, False
return {str(k): str(v) for k, v in parsed.items()}, recovered