diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index b24d8f0..17d0794 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -96,6 +96,32 @@ 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": []}}.""" +# 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 +# les chapitres coupés au lieu de créer des doublons. +_TOC_BLOCK = """ + +--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) --- +{toc} +--- FIN DE LA STRUCTURE --- +IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres +de cette structure (caractère pour caractère). Rattache le contenu de l'extrait +au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait.""" + +# 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é. @@ -200,9 +226,11 @@ class ImportCampaignUseCase: """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() for i, chunk in enumerate(chunks): - merger.add(await self._map_chunk(chunk, index=i, total=len(chunks))) + merger.add(await self._map_chunk( + chunk, index=i, total=len(chunks), toc_block=toc_block)) return CampaignImportResult( arcs=merger.result(), page_count=doc.page_count, @@ -221,10 +249,12 @@ class ImportCampaignUseCase: 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).", + "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", @@ -245,7 +275,7 @@ class ImportCampaignUseCase: try: arcs_payload: list[dict] | None = None async for kind, payload in with_heartbeat( - self._map_chunk(chunk, index=i, total=total) + self._map_chunk(chunk, index=i, total=total, toc_block=toc_block) ): if kind == "heartbeat": yield {"type": "heartbeat", "current": i + 1, "total": total} @@ -286,17 +316,22 @@ class ImportCampaignUseCase: # --- MAP : un morceau → sous-arbre --------------------------------------- - async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]: - return await self._extract_arcs(chunk, index=index, total=total, depth=0) + async def _map_chunk( + self, chunk: str, *, index: int, total: int, toc_block: str = "" + ) -> list[dict]: + return await self._extract_arcs( + chunk, index=index, total=total, depth=0, toc_block=toc_block) async def _extract_arcs( - self, text: str, *, index: int, total: int, depth: int + self, text: str, *, index: int, total: int, depth: int, toc_block: str = "" ) -> list[dict]: """Extrait l'arborescence 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 = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" prompt = ( _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) + + toc_section + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" "Renvoie maintenant le JSON de l'arborescence." ) @@ -310,8 +345,10 @@ class ImportCampaignUseCase: logger.info( "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", index, depth + 1) - a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1) - b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1) + a = await self._extract_arcs( + left, index=index, total=total, depth=depth + 1, toc_block=toc_block) + b = await self._extract_arcs( + right, index=index, total=total, depth=depth + 1, toc_block=toc_block) return a + b if truncated: logger.warning( diff --git a/brain/app/domain/models.py b/brain/app/domain/models.py index 992ebe6..924a55e 100644 --- a/brain/app/domain/models.py +++ b/brain/app/domain/models.py @@ -320,11 +320,26 @@ class ExtractedPage: used_ocr: bool +@dataclass(frozen=True) +class TocEntry: + """Une entrée de la table des matières (bookmarks/outline) du PDF. + + `level` : profondeur 1-based (1 = chapitre, 2 = section…). `page` : 1-based. + """ + + level: int + title: str + page: int + + @dataclass(frozen=True) class ExtractedDocument: """Résultat brut de l'extraction d'un PDF : une entrée par page.""" pages: list[ExtractedPage] + # Table des matières (bookmarks PDF). Vide si le PDF n'en a pas — fréquent + # pour les scans ; les livres born-digital en ont presque toujours une. + toc: list[TocEntry] = field(default_factory=list) @property def page_count(self) -> int: diff --git a/brain/app/infrastructure/pdf_extractor.py b/brain/app/infrastructure/pdf_extractor.py index a8cb79e..ca41b5c 100644 --- a/brain/app/infrastructure/pdf_extractor.py +++ b/brain/app/infrastructure/pdf_extractor.py @@ -22,7 +22,7 @@ import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf` # (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz" # qui échoue sur `from frontend import *`. -from app.domain.models import ExtractedDocument, ExtractedPage +from app.domain.models import ExtractedDocument, ExtractedPage, TocEntry from app.domain.ports import PdfExtractionError logger = logging.getLogger(__name__) @@ -72,7 +72,18 @@ class PyMuPdfTextExtractor: raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc pages: list[ExtractedPage] = [] + toc: list[TocEntry] = [] try: + # Bookmarks/outline du PDF : structure officielle du livre, gratuite + # (pas d'appel LLM). Sert de squelette de référence aux imports. + try: + for level, title, page_no in doc.get_toc(simple=True) or []: + title = str(title or "").strip() + if title: + toc.append(TocEntry(level=int(level), title=title, page=int(page_no))) + except Exception as exc: # noqa: BLE001 — TOC best-effort, jamais bloquante + logger.warning("Lecture de la table des matières impossible : %s", exc) + for index, page in enumerate(doc): text = (page.get_text() or "").strip() used_ocr = False @@ -85,7 +96,7 @@ class PyMuPdfTextExtractor: finally: doc.close() - return ExtractedDocument(pages=pages) + return ExtractedDocument(pages=pages, toc=toc) @staticmethod def _ocr_page(page: "fitz.Page") -> str: