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:
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user