Import campagne : la table des matieres du PDF guide la structuration

Les bookmarks PDF (doc.get_toc, gratuits) sont injectes dans chaque prompt MAP
comme referentiel commun de nommage : les morceaux traites separement nomment
desormais les memes chapitres a l identique, et la fusion par nom du _TreeMerger
recolle les chapitres coupes au lieu de creer des doublons. Bornee (2 niveaux,
80 entrees) pour ne pas gonfler le prompt ; absente sur les scans -> inchange.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:15:50 +02:00
parent 91069525a5
commit e7aa67bc42
3 changed files with 73 additions and 10 deletions

View File

@@ -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. - 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": []}}."""
# 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: class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé. """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.""" """Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes) doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens) chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
merger = _TreeMerger() merger = _TreeMerger()
for i, chunk in enumerate(chunks): 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( return CampaignImportResult(
arcs=merger.result(), arcs=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
@@ -221,10 +249,12 @@ class ImportCampaignUseCase:
doc = self._extractor.extract(pdf_bytes) doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens) chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
total = len(chunks) total = len(chunks)
logger.info( 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, doc.page_count, doc.ocr_page_count, total,
"présente" if toc_block else "absente",
) )
yield { yield {
"type": "start", "type": "start",
@@ -245,7 +275,7 @@ class ImportCampaignUseCase:
try: try:
arcs_payload: list[dict] | None = None arcs_payload: list[dict] | None = None
async for kind, payload in with_heartbeat( 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": if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total} yield {"type": "heartbeat", "current": i + 1, "total": total}
@@ -286,17 +316,22 @@ class ImportCampaignUseCase:
# --- MAP : un morceau → sous-arbre --------------------------------------- # --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]: async def _map_chunk(
return await self._extract_arcs(chunk, index=index, total=total, depth=0) 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( 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]: ) -> list[dict]:
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le """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 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é).""" nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = ( prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) _MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
+ toc_section
+ 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."
) )
@@ -310,8 +345,10 @@ class ImportCampaignUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
a = await self._extract_arcs(left, index=index, total=total, depth=depth + 1) a = await self._extract_arcs(
b = await self._extract_arcs(right, index=index, total=total, depth=depth + 1) 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 return a + b
if truncated: if truncated:
logger.warning( logger.warning(

View File

@@ -320,11 +320,26 @@ class ExtractedPage:
used_ocr: bool 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) @dataclass(frozen=True)
class ExtractedDocument: class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page.""" """Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage] 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 @property
def page_count(self) -> int: def page_count(self) -> int:

View File

@@ -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" # (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
# qui échoue sur `from frontend import *`. # 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 from app.domain.ports import PdfExtractionError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,7 +72,18 @@ class PyMuPdfTextExtractor:
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
pages: list[ExtractedPage] = [] pages: list[ExtractedPage] = []
toc: list[TocEntry] = []
try: 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): for index, page in enumerate(doc):
text = (page.get_text() or "").strip() text = (page.get_text() or "").strip()
used_ocr = False used_ocr = False
@@ -85,7 +96,7 @@ class PyMuPdfTextExtractor:
finally: finally:
doc.close() doc.close()
return ExtractedDocument(pages=pages) return ExtractedDocument(pages=pages, toc=toc)
@staticmethod @staticmethod
def _ocr_page(page: "fitz.Page") -> str: def _ocr_page(page: "fitz.Page") -> str: