Import campagne : passe de consolidation finale des quasi-doublons

Apres la fusion des morceaux, un unique appel LLM sur le SQUELETTE de l arbre
(noms seuls, quasi gratuit) detecte les chapitres/scenes en double sous des
libelles legerement differents et les fusionne (contenus accumules, rooms
reunies). Consigne conservatrice (doute = pas de fusion) et best-effort
strict : toute erreur laisse l arbre tel quel, jamais d import perdu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:25:35 +02:00
parent c1811b0040
commit 5061457f76

View File

@@ -116,6 +116,28 @@ _TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80 _TOC_MAX_ENTRIES = 80
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
# ne fusionner que les doublons évidents, jamais des entités distinctes.
_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
"Salle des gardes").
{skeleton}
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
JAMAIS être fusionnés.
Réponds UNIQUEMENT par un objet JSON valide :
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
"merge": ["nom à fusionner", ...]}}]}}
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""
def _format_toc(toc) -> str: def _format_toc(toc) -> str:
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord).""" """Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord)."""
entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES] entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
@@ -230,6 +252,79 @@ class _TreeMerger:
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values()) scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
return arcs, chapters, scenes return arcs, chapters, scenes
# --- Consolidation : fusion des quasi-doublons détectés par le LLM ---------
def skeleton_text(self) -> str:
"""Squelette de l'arbre (noms seuls) — entrée compacte de la consolidation."""
lines: list[str] = []
for a in self._arcs.values():
lines.append(f"ARC: {a['name']}")
for c in a["chapters"].values():
lines.append(f" CHAPITRE: {c['name']}")
for s in c["scenes"].values():
lines.append(f" SCENE: {s['name']}")
return "\n".join(lines)
def merge_chapters(self, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les chapitres `merge_names` dans `into_name` (tous arcs).
Best-effort : les noms inconnus sont ignorés. Renvoie True si modifié.
"""
target = self._find_chapter(into_name)
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
for a in self._arcs.values():
src = a["chapters"].pop(key, None)
if src is None or src is target:
continue
self._fill_desc(target, src)
for skey, sdict in src["scenes"].items():
if skey in target["scenes"]:
self._merge_scene_into(target["scenes"][skey], sdict)
else:
target["scenes"][skey] = sdict
changed = True
return changed
def merge_scenes(self, chapter_name: str, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les scènes `merge_names` dans `into_name` au sein du chapitre."""
chapter = self._find_chapter(chapter_name)
if chapter is None:
return False
target = chapter["scenes"].get(str(into_name).strip().lower())
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
src = chapter["scenes"].pop(key, None)
if src is None or src is target:
continue
self._merge_scene_into(target, src)
changed = True
return changed
def _find_chapter(self, name: str) -> dict | None:
key = str(name).strip().lower()
for a in self._arcs.values():
if key in a["chapters"]:
return a["chapters"][key]
return None
def _merge_scene_into(self, target: dict, src: dict) -> None:
self._fill_desc(target, src)
self._append_field(target, src, "player_narration")
self._append_field(target, src, "gm_notes")
for rkey, rdict in src.get("rooms", {}).items():
target["rooms"].setdefault(rkey, rdict)
class ImportCampaignUseCase: class ImportCampaignUseCase:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène.""" """Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
@@ -264,6 +359,8 @@ class ImportCampaignUseCase:
)) ))
for res in results: for res in results:
merger.add(res) merger.add(res)
if total > 1:
await self._consolidate(merger)
return CampaignImportResult( return CampaignImportResult(
arcs=merger.result(), arcs=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
@@ -351,6 +448,14 @@ class ImportCampaignUseCase:
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
# Consolidation finale : fusion des quasi-doublons inter-morceaux
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
if total > 1:
yield {"type": "consolidating", "total": total}
async for kind, _ in with_heartbeat(self._consolidate(merger)):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total}
yield { yield {
"type": "done", "type": "done",
"arcs": _serialize_arcs(merger.result()), "arcs": _serialize_arcs(merger.result()),
@@ -359,6 +464,44 @@ class ImportCampaignUseCase:
"skipped": skipped, "skipped": skipped,
} }
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
async def _consolidate(self, merger: _TreeMerger) -> None:
"""Une passe LLM sur le squelette pour fusionner les quasi-doublons.
BEST-EFFORT : toute erreur (LLM indisponible, JSON invalide, noms
inconnus) laisse l'arbre tel quel — la consolidation ne peut qu'améliorer,
jamais bloquer un import.
"""
_, chapters, scenes = merger.counts()
if chapters + scenes < 3:
return # rien à dédoublonner sur un arbre minuscule
skeleton = merger.skeleton_text()
try:
raw = await generate_with_retry(
self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton),
output_format="json", temperature=0.0)
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
logger.warning("Consolidation ignorée (échec) : %s", exc)
return
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
logger.warning("Consolidation ignorée (réponse non-JSON).")
return
merged = 0
for cm in parsed.get("chapter_merges") or []:
if isinstance(cm, dict) and merger.merge_chapters(
str(cm.get("into") or ""), list(cm.get("merge") or [])):
merged += 1
for sm in parsed.get("scene_merges") or []:
if isinstance(sm, dict) and merger.merge_scenes(
str(sm.get("chapter") or ""), str(sm.get("into") or ""),
list(sm.get("merge") or [])):
merged += 1
if merged:
logger.info("Consolidation : %s fusion(s) de quasi-doublons appliquée(s).", merged)
# --- MAP : un morceau → sous-arbre --------------------------------------- # --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk( async def _map_chunk(