Lire un PDF financier sans se tromper de colonne
Le 15 octobre, nous avons lancé l'analyse en désactivant la lecture visuelle des pages scannées. Aucune erreur, aucun avertissement, tous les contrôles comptables au vert. Pourtant, neuf montants du tableau des flux 2024 de Maison Ferlac étaient faux, et l'écart maximal sur les ratios atteignait 168 %. Le modèle n'avait pas lu la page 6 : il l'avait reconstituée. Cette leçon traite de ce qui se passe avant l'extraction : transformer un PDF en quelque chose qu'un modèle lit sans deviner.
Le mécanisme : trois couches d'un PDF
Un PDF ne contient pas de tableau. Il contient des instructions « écris ce texte à ces coordonnées ». Une page scannée contient une image et, au mieux, une couche OCR. Il y a donc trois situations, et chacune appelle un traitement différent.
flowchart TD
P["Page du PDF"] --> T{"Couche texte ?"}
T -- "oui" --> L["pypdf, mode layout : colonnes alignées, séparées par |"]
T -- "non : moins de 20 caractères" --> S["page marquée scannée"]
L --> M["texte balisé === PAGE n ==="]
S --> M
S --> J["PDF joint au message : Gemini lit l'image"]
M --> X["Agent extracteur"]
J --> X
Le mode layout. Par défaut, pypdf restitue le texte dans l'ordre d'écriture. Avec extraction_mode="layout", il reproduit la disposition à l'écran avec des espaces : les colonnes restent alignées. Le code remplace ensuite toute suite de trois espaces ou plus par « | », ce qui donne au modèle une ligne de tableau sans ambiguïté.
def pages_texte(chemin: Path) -> list[str]:
mode = os.environ.get("EXTRACTION_TEXTE", "layout") # "plain" pour reproduire l'échec
pages = []
for page in PdfReader(str(chemin)).pages:
brut = page.extract_text(extraction_mode=mode)
lignes = [re.sub(r" {3,}", " | ", ligne.strip()) for ligne in brut.splitlines() if ligne.strip()]
pages.append("\n".join(lignes))
return pagesLe bilan de Solvéa, dont les colonnes sont dans l'ordre chronologique, donne en mode layout :
en k€ | 31/12/2024 | 31/12/2025
Immobilisations nettes | 43 900 | 55 100
Stocks et en-cours | 19 800 | 31 400
Créances clients | 34 900 | 71 800et en mode plain, une valeur par ligne, sans lien visible avec son en-tête :
en k€
31/12/2024
31/12/2025
Immobilisations nettes
43 900Les repères de page. Chaque page est précédée de === PAGE n ===. Le modèle s'en sert pour déclarer la page de chaque état et de chaque citation. Sans repère, les numéros de page imprimés en pied (« Page 5 / 8 ») et l'index réel du PDF divergent dès qu'il y a une couverture non numérotée.
Un rapport réel commence par deux pages de garde non numérotées ; le bilan est imprimé « page 12 » mais c'est la 14e page du fichier. Quelle page la visionneuse doit-elle ouvrir ?
pdf.js et l'ancre #page=n travaillent sur l'index du fichier. Le numéro imprimé est du texte comme un autre, parfois absent, parfois faux. Les repères insérés par le code donnent une référence unique ; afficher en plus le numéro imprimé peut aider, mais la navigation se fait sur l'index.
Les pages scannées
Une page sans couche texte renvoie une chaîne vide. Le code la détecte et la remplace par un marqueur explicite, pour que le modèle sache qu'il ne voit rien :
SANS_TEXTE = "[page sans couche texte : image scannée, à lire dans le PDF joint]"
def pages_scannees(pages: list[str]) -> list[int]:
return [i for i, p in enumerate(pages, start=1) if len(p.strip()) < 20]Quand une page scannée existe, l'agent reçoit en plus le PDF entier : Gemini lit nativement les PDF, images comprises. Le message ADK devient un types.Content à deux parties.
if scans and SCANS == "vision":
joint = types.Part.from_bytes(data=Path(node_input["chemin"]).read_bytes(), mime_type="application/pdf")
def message(consigne: str) -> types.Content | str:
return types.Content(role="user", parts=[types.Part(text=consigne), joint]) if joint else consignePourquoi ne pas envoyer toujours le PDF ? Parce que le texte extrait est moins cher, et qu'il se vérifie : on peut comparer une citation au texte de la page, pas à une image. Le PDF n'est joint que lorsque c'est nécessaire.
Résultat obtenu (2026-10-15)
Analyse complète, lecture visuelle activée (défaut), puis désactivée avec SCANS=ignorer :
INFO analyste.agents ferlac : page(s) scannée(s) [6], PDF joint pour lecture visuelle
Maison Ferlac SAS 68/68 3/3 OK 0 0.00% 6/6 6/6
$ SCANS=ignorer .venv/bin/python run.py analyser ... && run.py evaluer ...
Maison Ferlac SAS 59/68 3/3 OK 0 167.76% 6/6 6/6
2024 flux.variation_bfr : lu -2100.0, attendu -5640
2024 flux.flux_exploitation : lu 13882.0, attendu 10342
2024 flux.investissements : lu 11200.0, attendu 14300
...
2024 flux.tresorerie_ouverture : lu 24780.0, attendu 29920La colonne « contrôles » affiche OK : les neuf valeurs inventées sont cohérentes entre elles. Le modèle a reconstruit les flux 2025 à partir des bilans (ils étaient justes) et inventé ceux de 2024, pour lesquels il manquait le bilan 2023.
Ce qui casse en production
- La reconstitution plausible. Symptôme : extraction complète, contrôles verts, chiffres faux. Cause : un état « lu » sur une page sans texte. Parade : le code compare la page déclarée de chaque état à la liste des pages scannées ; si l'une d'elles n'a pas été lue visuellement, l'état est marqué
etats_non_lusetmake e2eéchoue. - Le scan avec une mauvaise couche OCR. Symptôme : la page a du texte, mais « 8 » devient « B » et les colonnes sont mêlées. Le seuil de 20 caractères ne la voit pas. Parade : compter la proportion de chiffres dans les pages d'états ; en dessous d'un seuil, traiter la page comme scannée.
- Le PDF de 300 pages. Joindre le PDF entier pour une page scannée multiplie le coût. Parade : extraire les seules pages scannées dans un PDF temporaire (pypdf
PdfWriter) avant de les joindre.
non_lus = [etat for etat, page in pages_etats.items() if page in scans and joint is None]
if non_lus:
log.error("%s : %s lu(s) sur une page scannée sans lecture visuelle", sid, non_lus)Règle de décision
Texte en mode layout par défaut, PDF joint uniquement pour les pages sans texte, et jamais d'état accepté s'il provient d'une page que personne n'a lue. Pour un corpus majoritairement scanné (archives papier), inversez : PDF joint systématiquement, et vérification des citations par OCR plutôt que par le texte natif.
Pratique
Sur 200 rapports, 30 ont une couche OCR de mauvaise qualité : les pages ont du texte, mais les montants sont souvent déformés. Votre détecteur de pages scannées ne se déclenche jamais. Que changez-vous en premier ?
Le problème est la détection, pas la lecture : il faut reconnaître les pages dont le texte n'est pas fiable. Joindre le PDF partout réglerait le cas mais multiplierait le coût sur les 170 rapports sains. Un modèle plus grand lirait le même texte déformé.
Avec SCANS=ignorer, tous les contrôles comptables de Ferlac sont verts mais 9 champs sont faux. Quelle conclusion en tirez-vous pour la conception des contrôles ?
Une ligne mal lue casse une égalité ; un tableau inventé par un modèle qui connaît la comptabilité les respecte toutes. La cohérence et la provenance sont deux vérifications distinctes. Les tolérances étaient déjà à 1 k€ : les valeurs inventées étaient exactement cohérentes.
Exercice. Le seuil de 20 caractères échoue si un scan conserve un pied de page texte ajouté par l'imprimeur (« Rapport annuel 2025 - Page 6 / 8 »). Réécrivez pages_scannees pour qu'une page de moins de 3 lignes contenant des chiffres groupés soit aussi considérée comme scannée, et ajoutez un test.
Voir la solution
On compte les montants (« 12 450 », « (3 112) ») plutôt que les caractères : une page d'état en contient des dizaines, un pied de page aucun.
MONTANT = re.compile(r"\(?\d{1,3}(?: \d{3})+\)?")
def pages_scannees(pages: list[str]) -> list[int]:
return [i for i, p in enumerate(pages, start=1)
if len(p.strip()) < 20 or (len(p.splitlines()) < 3 and not MONTANT.search(p))]def test_pied_de_page_seul_compte_comme_scan():
pages = ["Bilan | 12 450 | 11 300\nStocks | 3 100 | 2 900\nTotal | 15 550 | 14 200",
"Rapport annuel 2025 - Page 6 / 8"]
assert pdf.pages_scannees(pages) == [2]Attention aux pages narratives courtes (une couverture) : elles deviennent « scannées » et déclenchent l'envoi du PDF. C'est un surcoût acceptable ; l'inverse, une page d'état non lue, ne l'est pas.
- Je sais pourquoi le mode layout de pypdf évite les confusions de colonnes
- Je sais détecter une page scannée et joindre le PDF au message ADK
- Je sais distinguer cohérence et provenance d'une donnée extraite
- J'ai reproduit la panne avec
SCANS=ignorer