Leçon 3 — Extraire

Extraction structurée et contrôles comptables

8 min de lecture

Extraction structurée et contrôles comptables

Une sortie structurée garantit un JSON valide, pas un JSON juste. Le schéma Pydantic dira que creances_clients est un nombre ; il ne dira pas que c'est le bon nombre, de la bonne année, dans la bonne unité. Ce qui dit qu'un bilan est juste, c'est la comptabilité elle-même : l'actif égale le passif, la trésorerie de clôture du tableau des flux égale celle du bilan, le résultat net du compte de résultat égale celui des flux. Cette leçon branche ces égalités sur l'extracteur, et les fait servir de consigne de relecture.

Le schéma : transcrire, pas interpréter

Le schéma demande les montants tels qu'imprimés : dans l'unité du tableau, négatifs si entre parenthèses. Le modèle n'a ni conversion ni changement de signe à faire.

files/analyste/schemas.py
IMPRIME = "Montant tel qu'imprimé dans le tableau, dans l'unité du tableau ; entre parenthèses = négatif"

class ExtractionBrute(BaseModel):
    societe: str
    unite: Literal["k€", "M€"] = Field(description="Unité indiquée en tête des tableaux")
    page_compte_resultat: int
    page_bilan: int
    page_flux: int
    exercices: list[Exercice] = Field(description="Les deux exercices présentés, quel que soit l'ordre des colonnes")

Chaque exercice porte son annee : le modèle dit à quelle année appartient chaque colonne, ce qui neutralise le bilan de Solvéa aux colonnes inversées. Les descriptions des champs (Field(description=...)) sont transmises au modèle avec le schéma : c'est là qu'on met la règle, pas dans un long prompt.

La normalisation est du Python : conversion en k€, charges en valeur positive.

files/analyste/schemas.py
def normaliser(extraction: ExtractionBrute) -> dict[int, dict[str, dict[str, float]]]:
    facteur = 1000 if extraction.unite == "M€" else 1
    ...
            etat[bloc] = {k: round((abs(v) if k in POSITIFS.get(bloc, ()) else v) * facteur, 1)
                          for k, v in valeurs.items()}

Le tableau des flux imprime « Acquisitions d'immobilisations (13,6) » en M€. Votre collègue veut que le modèle renvoie directement 13600. Que répondez-vous ?

Les contrôles : douze égalités comptables

flowchart LR
  subgraph CR["Compte de résultat"]
    A["CA - charges = résultat d'exploitation"]
    B["exploitation + financier - impôt = résultat net"]
  end
  subgraph BI["Bilan"]
    C["actif = passif"]
    D["totaux = somme des postes"]
  end
  subgraph FL["Flux"]
    E["RN + dotations + variation BFR = flux d'exploitation"]
    F["ouverture + variation = clôture"]
  end
  B -- "RN identique" --> E
  F -- "trésorerie identique" --> C
  C -- "N-1 vers N : capitaux propres, trésorerie d'ouverture" --> C

Le code complet est dans coherence.py : 10 contrôles par exercice, plus 2 contrôles entre exercices (trésorerie d'ouverture de N = clôture de N-1 ; capitaux propres de N = N-1 + résultat - dividendes + augmentation de capital), soit 22 contrôles pour deux exercices.

Les tolérances. Un rapport en M€ à une décimale arrondit chaque ligne à 100 k€ près : la somme de cinq postes arrondis peut s'écarter du total imprimé de 250 k€. La tolérance de base vaut donc 1 k€ pour un rapport en k€ et 100 k€ pour un rapport en M€, multipliée par le nombre de termes pour les sous-totaux.

Un contrôle qui échoue produit un message lisible, renvoyé tel quel au modèle :

2024 - total actif = somme des postes : attendu 157 275, lu 120 375 (k€)
2025 - total actif = somme des postes : attendu 141 150, lu 178 050 (k€)

Ce message ne dit pas quelle ligne est fausse (ici, les créances clients des deux années ont été interverties). Il dit où chercher, ce qui suffit au modèle pour relire.

La boucle de relecture

files/analyste/agents.py
        consigne, historique = texte, []
        for tentative in range(1, MAX_RELECTURES + 2):
            brut = ExtractionBrute.model_validate(
                await ctx.run_node(agents["extracteur"], node_input=message(consigne)))
            etats = normaliser(brut)
            ctrl = coherence.controler(etats, tolerance=100 if brut.unite == "M€" else 1)
            ko = coherence.echecs(ctrl)
            historique.append({"tentative": tentative, "echecs": [c.message() for c in ko]})
            if not ko or tentative > MAX_RELECTURES:
                break
            consigne = (texte + "\n\nTa transcription précédente viole ces contrôles comptables :\n- "
                        + "\n- ".join(c.message() for c in ko)
                        + "\nRelis les lignes concernées dans les tableaux et transcris à nouveau.")

ctx.run_node exécute l'agent comme un nœud enfant du workflow ADK et renvoie sa sortie structurée sous forme de dictionnaire. La boucle est bornée (MAX_RELECTURES=2 par défaut) : au-delà, le résultat est conservé avec ses contrôles en échec, visibles dans l'analyse, et make e2e échoue sur le seuil d'exactitude.

Résultat obtenu (2026-10-15)

Avec le faux modèle des tests, qui se trompe volontairement une fois sur les stocks d'une société :

$ .venv/bin/python -m pytest -q tests/test_workflow.py --log-cli-level=INFO
INFO analyste.agents ferlac : extraction 1, 1 contrôle(s) en échec
INFO analyste.agents helvane : extraction 1, 0 contrôle(s) en échec
INFO analyste.agents solvea : extraction 1, 0 contrôle(s) en échec
INFO analyste.agents ferlac : extraction 2, 0 contrôle(s) en échec
1 passed, 1 warning in 2.00s

Avec Gemini (make e2e), aucune relecture n'a été nécessaire : 204 champs sur 204 exacts dès la première extraction, sur gemini-3.5-flash, gemini-3.5-flash-lite et gemini-2.5-flash (Vertex AI).

Ce qui casse en production

  • Les contrôles verts sur une donnée inventée (leçon 2) : la cohérence ne prouve pas la lecture.
  • Les erreurs qui se compensent. Deux lignes du même total mal lues en sens opposé passent le contrôle des totaux. Les contrôles croisés (trésorerie, résultat net, capitaux propres) en rattrapent une partie, jamais toutes. D'où l'évaluation champ par champ de la leçon 7.
  • La relecture qui tourne en rond. Si l'erreur vient du document (un rapport réel où le total imprimé est faux, cela arrive), aucune relecture ne la corrige. Symptôme : mêmes contrôles en échec à chaque tentative. Parade : bornage à deux relectures et affichage de l'échec, pas une troisième tentative.
  • Les présentations hétérogènes. Un rapport réel a vingt lignes au passif (dettes obligataires, bancaires, crédit-bail…). Le schéma à onze postes oblige alors le modèle à additionner, donc à calculer. Parade : un schéma qui accepte des lignes libres (libelle, montant, categorie) et une agrégation en Python.

Règle de décision

Mettez un contrôle sur chaque identité comptable que le document permet de vérifier, et renvoyez les échecs au modèle une ou deux fois. N'augmentez pas le nombre de relectures pour « faire passer » un rapport : un échec persistant est une information pour l'analyste. Surveillez le taux de relecture par source : s'il monte, c'est un format de document nouveau.

Pratique

Sur un rapport, le contrôle « trésorerie des flux = trésorerie du bilan » échoue en 2025 et 2024, ainsi que les deux contrôles entre exercices ; tous les contrôles internes à un état passent. Quelle est l'hypothèse la plus probable ?

Un rapport en M€ à une décimale échoue au contrôle « total passif = somme des postes » avec un écart de 0,2 M€. Que faites-vous ?

Exercice. Ajoutez un contrôle qui relie le bilan et le tableau des flux par le BFR : la variation du BFR du tableau des flux doit être égale à l'opposé de la variation du BFR calculée sur les deux bilans (BFR = stocks + créances + autres actifs courants - fournisseurs - autres passifs courants). Vérifiez qu'il passe sur la vérité terrain.

Voir la solution

Le contrôle n'est possible que pour l'année N (il faut le bilan N-1). Dans la branche if a - 1 in etats: de controler :

files/analyste/coherence.py
            def bfr(b):
                return (b["stocks"] + b["creances_clients"] + b["autres_actifs_courants"]
                        - b["dettes_fournisseurs"] - b["autres_passifs_courants"])
            ajouter("variation BFR des flux = -(BFR N - BFR N-1)", a,
                    -(bfr(bi) - bfr(etats[a - 1]["bilan"])), fl["variation_bfr"], tolerance * 5)

make test reste vert (test_verite_terrain_coherente exécute tous les contrôles sur les trois sociétés). Sur des comptes réels, ce contrôle échoue souvent pour de bonnes raisons (variations de périmètre, écarts de conversion, reclassements) : affichez-le comme un indicateur, ne l'utilisez pas pour déclencher une relecture.

  • Je sais écrire un schéma qui demande une transcription et non un calcul
  • Je sais choisir les tolérances selon l'unité du document
  • Je sais transformer un contrôle en échec en consigne de relecture bornée
  • Je sais pourquoi un contrôle vert ne prouve pas une extraction juste
Suivant