Le problème, le corpus et l'architecture
Un fonds de dette privée demande une première lecture de trois sociétés avant un comité. L'analyste qui fait ce travail à la main commet toujours les mêmes fautes : il prend la colonne 2024 pour 2025 parce que l'ordre des colonnes change d'un tableau à l'autre, il recopie 184,2 sans voir que le tableau est en millions, il résume une note d'annexe au lieu de la citer. Un LLM branché naïvement sur les PDF reproduit ces trois fautes, plus une quatrième : il calcule de tête et se trompe en silence.
Le mécanisme : séparer ce qui se lit de ce qui se calcule
La décision d'architecture qui structure tout le projet : le modèle ne produit jamais un chiffre qu'il n'a pas lu. Il transcrit des montants tels qu'imprimés, il recopie des citations, il rédige à partir de faits fournis. Tout le reste est du Python.
flowchart TB
subgraph LLM["Ce que fait le modèle"]
A["transcrire les tableaux"]
B["repérer et citer les signaux"]
C["rédiger le mémo"]
end
subgraph PY["Ce que fait Python"]
D["convertir M€ en k€, normaliser les signes"]
E["12 contrôles comptables"]
F["16 ratios, 8 règles d'alerte"]
G["vérifier chaque citation sur sa page"]
H["vérifier chaque nombre du mémo"]
end
A --> D --> E --> F
B --> G
F --> C
G --> C
C --> H
Chaque flèche qui sort du modèle entre dans une vérification. C'est ce qui rend le système auditable : quand un chiffre est faux, on sait si l'erreur vient de la lecture (le modèle) ou du calcul (le code, donc un test).
| Tâche | Modèle ou Python | Pourquoi |
|---|---|---|
| Lire « (12 450) » dans un tableau | modèle | la mise en page varie, le modèle s'y adapte |
| Transformer (12 450) en -12 450 et M€ en k€ | Python | une règle fixe, testable, sans variance |
| Vérifier actif = passif | Python | une égalité, pas une opinion |
| Calculer la marge d'EBITDA | Python | le modèle arrondit, inverse, invente |
| Trouver la phrase sur le covenant | modèle | lecture du langage naturel |
| Prouver que la phrase existe page 7 | Python | comparaison de chaînes |
Un collègue propose de demander directement au modèle « calcule le ROE et le levier de chaque société », en arguant que Gemini sait faire des divisions. Quelle objection l'emporte ?
Le modèle connaît la formule et calcule souvent juste. Le problème est ailleurs : la même question peut donner deux arrondis différents, et quand le levier est faux, impossible de savoir si le montant a été mal lu ou mal divisé. La vitesse n'est pas l'argument : quelques microsecondes contre un appel déjà payé.
Le corpus : trois sociétés, des pièges réels
On ne valide pas un extracteur sur des documents qu'on n'a pas annotés. Le projet génère donc son corpus : trois rapports annuels de 8 pages (couverture, message de la direction, chiffres clés, compte de résultat, bilan, flux de trésorerie, notes annexes, facteurs de risque), et pour chacun un fichier d'annotations qui sert de vérité terrain.
| Société | Profil | Pièges dans le PDF |
|---|---|---|
| Helvane Robotique SA | robotique industrielle, croissance de 12 %, trésorerie nette | tableaux en M€ à une décimale |
| Maison Ferlac SAS | plats cuisinés, marge laminée, levier de 5,5x | page des flux scannée (image sans texte), EBITDA « ajusté » en page chiffres clés, covenant sur un indicateur ajusté |
| Solvéa Énergies SA | photovoltaïque, croissance de 45 %, free cash-flow très négatif | bilan aux colonnes inversées (2024 puis 2025), changement de méthode comptable, parties liées |
Les états financiers sont dérivés d'hypothèses (chiffre d'affaires, charges, investissements, niveaux de BFR, dette), ce qui les rend cohérents par construction : le bilan de 2025 découle de celui de 2024 et des flux de l'année.
var_bfr = -(bfr - bfr_prec) # impact trésorerie : négatif si le BFR augmente
cfo = rn + h.dotations_amortissements + var_bfr
cfi = -h.investissements
cff = h.variation_dettes - h.dividendes_verses + h.augmentation_capital
tres = precedent["tresorerie"] + cfo + cfi + cff
...
assert bilan["total_actif"] == bilan["total_passif"], (s.id, annee)L'assert en fin de boucle garantit qu'aucune hypothèse modifiée ne produit un bilan déséquilibré : si vous ajoutez une société, le générateur refuse de produire un rapport faux. Le code complet : modele_comptable.py et generation.py.
Mise en œuvre
cd files
make setup # .venv, dépendances, .env copié depuis .env.example
.venv/bin/python run.py generer
make testRésultat obtenu (2026-10-15)
INFO rapport généré : rapports/helvane-rapport-annuel-2025.pdf
INFO rapport généré : rapports/ferlac-rapport-annuel-2025.pdf
INFO rapport généré : rapports/solvea-rapport-annuel-2025.pdf
$ make test
.............. [100%]
15 passed, 1 warning in 1.77sLe rapport de Ferlac pèse 110 Ko contre 11 Ko pour les deux autres : la page 6 est une image JPEG.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Ratios faux sur une seule société, extraction « réussie » | unité mal lue (M€ pris pour k€) | le modèle déclare l'unité, Python convertit ; ajoutez un contrôle de vraisemblance (CA rapporté à l'effectif, par exemple) |
| Croissance négative alors que l'activité progresse | colonnes inversées dans un tableau | instruction explicite « lis l'en-tête de chaque colonne » et champ annee par exercice |
| Valeurs plausibles mais fausses sur une page | page scannée sans couche texte | détection des pages vides et lecture visuelle (leçon 2) |
| Le corpus de test passe à 100 %, la production non | corpus trop propre | ajouter des rapports réels annotés et mesurer séparément |
Règle de décision
Utilisez ce découpage modèle/Python dès qu'un chiffre produit peut engager une décision. Ne l'utilisez pas pour une synthèse exploratoire où l'ordre de grandeur suffit : un seul agent avec le PDF en entrée coûte moins cher à écrire. Surveillez le ratio entre champs lus et champs vérifiés : un champ qui n'entre dans aucun contrôle est un champ que personne ne relit.
Pratique
Vous ajoutez une quatrième société au générateur et l'assert « total_actif == total_passif » échoue. Que concluez-vous ?
Le générateur calcule trésorerie, immobilisations et capitaux propres à partir des flux : le bilan s'équilibre si les hypothèses passent toutes par ces formules. Un échec signale une donnée saisie hors de la mécanique. Supprimer l'assert produirait un corpus faux, donc une évaluation fausse. reportlab n'intervient qu'après le calcul.
Le responsable des risques veut que la vérité terrain soit saisie par un analyste plutôt que générée. Quand a-t-il raison ?
La vérité générée est exacte pour les PDF qu'elle a produits, et seulement pour eux. Sur des rapports réels, il faut une annotation humaine, idéalement double (deux analystes, arbitrage des écarts). Le statut coté ne change rien au besoin d'annotation.
Exercice. Ajoutez dans modele_comptable.py une quatrième société fictive, « Ostral Logistique SAS » (entreposage frigorifique, 95 000 k€ de chiffre d'affaires, levier proche de 4x), avec deux exercices. Générez les rapports et vérifiez que l'assert passe.
Voir la solution
Il faut un Societe(...) dans SOCIETES, une entrée dans TEXTES (pages 2, 7 et 8) et dans SIGNAUX de generation.py. Pour viser un levier de 4x, partez de l'EBITDA (résultat d'exploitation + dotations) : avec 95 000 k€ de CA et une marge d'EBITDA de 12 %, l'EBITDA vaut 11 400 k€ ; la dette nette visée est donc d'environ 45 600 k€. Comme la trésorerie est calculée (c'est la variable d'équilibre), ajustez dettes_financieres du bilan initial et variation_dettes jusqu'à obtenir ce niveau, puis contrôlez :
.venv/bin/python -c "from analyste.modele_comptable import *; from analyste.ratios import *
s=[x for x in SOCIETES if x.id=='ostral'][0]; print(calculer_ratios(calculer_etats(s))[2025]['levier'])"
.venv/bin/python run.py genererSi l'assert tres > 0 échoue, vos flux consomment plus de trésorerie qu'il n'en existe : augmentez la dette ou baissez les investissements. Les tests test_verite_terrain_coherente couvrent automatiquement la nouvelle société.
- Je sais expliquer pourquoi le modèle transcrit et ne calcule pas
- Je sais générer le corpus et ses annotations
- Je connais les quatre pièges du corpus et ce qu'ils testent
-
make testest vert sur mon poste