Files
LoreMind/brain/app/application/import_campaign.py
IETM_FIXE\ietm6 1fb4563557 Redécoupage des fichiers et sortie des prompts dans leurs propre fichiers pour ne pas tout mélanger ensemble.
On garde malgrès tout les promps à coté des parseurs car ils évoluent généralement ensemble.
2026-06-15 10:16:01 +02:00

677 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène.
Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR +
chunking + map-reduce) mais la cible est une ARBORESCENCE narrative :
- MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]}
- REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux
est recollé ; ses scènes s'accumulent).
PROPOSITION non persistée : le Core crée les entités seulement après revue.
"""
from __future__ import annotations
import asyncio
import logging
from app.application.chunking import chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
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.prompts import import_campaign as prompts
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
NpcImportProposal,
RoomProposal,
SceneProposal,
)
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__)
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier
# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le
# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000
# 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,
}
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées
# (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
_TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80
def _format_toc(toc) -> str:
"""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]
if not entries:
return ""
return "\n".join(f"{' ' * (e.level - 1)}- {e.title} (p. {e.page})" for e in entries)
class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène).
Description : la première non-vide rencontrée l'emporte (les morceaux suivants
ne l'écrasent pas).
"""
def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {}
# npc_key (nom en minuscules) -> {"name", "description"}
self._npcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []:
name = str(arc.get("name", "")).strip()
if not name:
continue
a = self._arcs.setdefault(
name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}})
self._fill_desc(a, arc)
# Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale
# souvent énoncée une fois, dans l'intro du livre).
if str(arc.get("type", "")).strip().upper() == "HUB":
a["type"] = "HUB"
for chap in arc.get("chapters", []) or []:
cname = str(chap.get("name", "")).strip()
if not cname:
continue
c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}})
self._fill_desc(c, chap)
for sc in chap.get("scenes", []) or []:
sname = str(sc.get("name", "")).strip()
if not sname:
continue
s = c["scenes"].setdefault(
sname.lower(),
{"name": sname, "description": "", "player_narration": "",
"gm_notes": "", "rooms": {}})
self._fill_desc(s, sc)
# Narration/notes : CONCATÉNATION (pas premier-gagne) — une
# scène coupée entre deux morceaux apporte la suite de son
# contenu dans le morceau suivant ; la jeter perdrait la
# moitié du donjon. Le doublon exact (overlap) est filtré.
self._append_field(s, sc, "player_narration")
self._append_field(s, sc, "gm_notes")
for rm in sc.get("rooms", []) or []:
rname = str(rm.get("name", "")).strip()
if not rname:
continue
r = s["rooms"].setdefault(
rname.lower(),
{"name": rname, "description": "", "enemies": "", "loot": ""})
self._fill_desc(r, rm)
self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot")
def add_npcs(self, npcs_json: list[dict]) -> None:
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
souvent décrit en détail une seule fois."""
for npc in npcs_json or []:
name = str(npc.get("name", "")).strip()
if not name:
continue
desc = str(npc.get("description") or "").strip()
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
if len(desc) > len(entry["description"]):
entry["description"] = desc
def npcs(self) -> list[NpcImportProposal]:
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
@staticmethod
def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]:
node["description"] = str(src.get("description") or "").strip()
@staticmethod
def _fill_field(node: dict, src: dict, field_name: str) -> None:
if not node[field_name]:
node[field_name] = str(src.get(field_name) or "").strip()
@staticmethod
def _append_field(node: dict, src: dict, field_name: str) -> None:
"""Accumule la valeur de `src` à la suite de l'existante (scène coupée
entre deux morceaux). Ignore le vide et le contenu déjà présent (un
morceau redondant — relecture d'overlap — ne duplique rien)."""
new = str(src.get(field_name) or "").strip()
if not new:
return
current = node[field_name]
if not current:
node[field_name] = new
elif new not in current and current not in new:
node[field_name] = current + "\n\n" + new
elif current in new:
# Le nouveau contenu ENGLOBE l'ancien (version plus complète) → on le prend.
node[field_name] = new
def result(self) -> list[ArcProposal]:
arcs: list[ArcProposal] = []
for a in self._arcs.values():
chapters: list[ChapterProposal] = []
for c in a["chapters"].values():
scenes: list[SceneProposal] = []
for s in c["scenes"].values():
rooms = [
RoomProposal(r["name"], r["description"], r["enemies"], r["loot"])
for r in s["rooms"].values()
]
scenes.append(SceneProposal(
s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms))
chapters.append(ChapterProposal(c["name"], c["description"], scenes))
arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters))
return arcs
def counts(self) -> tuple[int, int, int]:
arcs = len(self._arcs)
chapters = sum(len(a["chapters"]) for a in self._arcs.values())
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
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:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
map_concurrency: int = 1,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
# Appels MAP par VAGUES de cette taille : l'ordre narratif est préservé
# (fusion vague par vague, dans l'ordre du livre) mais le mur d'attente
# des appels LLM est divisé d'autant. 1 = comportement séquentiel.
self._map_concurrency = max(1, map_concurrency)
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
merger = _TreeMerger()
total = len(chunks)
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results = await asyncio.gather(*(
self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave
))
for res in results:
merger.add(res["arcs"])
merger.add_npcs(res["npcs"])
if total > 1:
await self._consolidate(merger)
return CampaignImportResult(
arcs=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
npcs=merger.npcs(),
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement.
{"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count,
total}, puis un {"type":"progress", current, total, arc_count,
chapter_count, scene_count} par morceau, et enfin
{"type":"done", arcs:[...], page_count, ocr_page_count}.
"""
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
total = len(chunks)
logger.info(
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x), TOC %s.",
doc.page_count, doc.ocr_page_count, total,
"présente" if toc_block else "absente",
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _TreeMerger()
skipped = 0
last_error: str | None = None
done_count = 0
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
# publient des messages destinés à l'UI — cf. import_status.notify_status.
status_queue: asyncio.Queue = asyncio.Queue()
status_token = set_status_queue(status_queue)
try:
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
# vague par vague, dans l'ordre du livre.
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
gathered = asyncio.gather(
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave),
return_exceptions=True,
)
results: list | None = None
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": done_count + 1, "total": total}
else:
results = payload
for (i, _), res in zip(wave, results or []):
done_count += 1
if isinstance(res, LLMProviderError):
skipped += 1
last_error = str(res)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(res)[:300]}
elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler en silence
else:
merger.add((res or {}).get("arcs") or [])
merger.add_npcs((res or {}).get("npcs") or [])
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped,
}
if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
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:
yield {"type": "consolidating", "total": total}
async for kind, payload in with_heartbeat(
self._consolidate(merger), status_queue=status_queue
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": total, "total": total}
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
# --- 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, prompts.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 ---------------------------------------
async def _map_chunk(
self, chunk: str, *, index: int, total: int, toc_block: str = ""
) -> dict:
"""Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
return await self._extract_payload(
chunk, index=index, total=total, depth=0, toc_block=toc_block)
async def _extract_payload(
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
) -> dict:
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = (
prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME)
+ toc_section
+ 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=_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)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
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:
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)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
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"]}
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return payload
@staticmethod
def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
"""Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
empty = {"arcs": [], "npcs": []}
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, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return empty, truncated
if isinstance(parsed, dict):
arcs = parsed.get("arcs", [])
npcs = parsed.get("npcs", [])
return {
"arcs": arcs if isinstance(arcs, list) else [],
"npcs": npcs if isinstance(npcs, list) else [],
}, recovered
return empty, recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
"""Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE."""
return [
{
"name": a.name,
"description": a.description,
"type": a.arc_type,
"chapters": [
{
"name": c.name,
"description": c.description,
"scenes": [
{
"name": s.name,
"description": s.description,
"player_narration": s.player_narration,
"gm_notes": s.gm_notes,
"rooms": [
{
"name": r.name,
"description": r.description,
"enemies": r.enemies,
"loot": r.loot,
}
for r in s.rooms
],
}
for s in c.scenes
],
}
for c in a.chapters
],
}
for a in arcs
]