Extraire les montants d’un texte

Retrouver les sommes citées dans un message ou un document, avec leur devise, quelle que soit leur écriture.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que ce qui fait d’un nombre un montant est écrit à côté de lui, et qu’une marque se cherche. L’outil de référence est `price-parser`, en Python ; il lit un prix dans une chaîne, ce qu’est un champ de prix aspiré sur une page, et sur une ligne de facture française qui porte un numéro de pièce, une date et un total, il rend le numéro de pièce — le test de la fiche le montre. En JavaScript, rien de maintenu n’en fait même autant. Deux décisions portent le reste. La convention de séparateur est exigée de l’appelant et n’a pas de défaut, parce qu’un défaut est exactement la supposition silencieuse que ce site existe pour refuser : le même outil de référence rend « 1,859 » mille fois trop grand ou juste selon un paramètre qu’on ne lui passe pas. Et la valeur rendue est une chaîne de chiffres, jamais un flottant : sur des sommes d’argent, la différence a un nom.

La marque de devise à côté du nombre, et la convention déclarée par l’appelant

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-amounts-and-currencies-from-text/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Retrouver les montants cités dans un texte, avec la devise écrite à côté.

Niveau N0. Un montant dans un texte courant, c'est un nombre avec une marque
à côté de lui — un symbole, un code ISO 4217, ou le mot dans la langue du
lecteur. C'est cette marque qui distingue un montant du numéro de facture
trois mots plus tôt, et chercher le nombre sans elle est ce qui fait rendre
« 2026 » comme total.

L'outil de référence est `price-parser`, en Python. Il lit un prix dans une
chaîne, ce qu'est un champ de prix aspiré sur une page, et le test de cette
fiche montre où cela s'arrête : sur une ligne de facture française qui porte
un numéro de pièce, une date et un total, il rend le numéro de pièce ; et sur
un document dont l'entête dit « Montants en euros » quand la ligne cite une
somme en dollars, il rend l'euro de l'entête. En JavaScript, rien de maintenu
n'en fait même autant.

La seule chose qu'un texte ne tranche pas, c'est le séparateur. « 1,859 » est
un groupe de milliers sous une convention et trois décimales sous l'autre, et
les deux sont ordinaires — une somme ronde d'un côté, un prix de carburant de
l'autre. L'appelant déclare donc la convention dans laquelle ses documents
sont écrits, et tout nombre dont la lecture dépend de cette déclaration
revient marqué `ambiguous`. Rien n'est deviné en silence, et rien n'est
transformé en flottant au passage : la valeur, ce sont les chiffres qui ont
été écrits.
"""

from __future__ import annotations

import re

# The decimal sign each convention uses. The caller declares one; there is no
# default, because a default is precisely the silent guess this entry exists
# to refuse.
CONVENTIONS = {"fr": ",", "en": "."}

# What a currency mark can look like, and what it may stand for. A symbol that
# several currencies share is not resolved here: the candidates are returned.
SYMBOLS = {"€": ["EUR"], "£": ["GBP"], "₹": ["INR"], "¥": ["JPY", "CNY"],
           "$": ["USD", "CAD", "AUD", "NZD", "SGD", "HKD"],
           "kr": ["SEK", "NOK", "DKK"]}
CODES = ["EUR", "GBP", "USD", "CHF", "JPY", "CAD", "AUD", "CNY", "SEK", "NOK",
         "DKK", "INR", "PLN", "CZK"]
WORDS = {"euro": ["EUR"], "euros": ["EUR"], "livre sterling": ["GBP"],
         "livres sterling": ["GBP"], "pound": ["GBP"], "pounds": ["GBP"],
         "franc suisse": ["CHF"], "francs suisses": ["CHF"], "yen": ["JPY"],
         "dollar": SYMBOLS["$"], "dollars": SYMBOLS["$"]}

# A number as it is written: groups separated by spaces, dots, commas or
# apostrophes. The shapes are checked afterwards, not here. The digits are
# written out rather than taken from « \\d », which matches every Unicode digit
# in Python and only the ten in JavaScript — and « ١٢٣ » is not a value this
# rung knows how to hand to an accountant.
NUMBER = re.compile(r"[0-9][0-9 \u00a0\u202f.,']*[0-9]|[0-9]")

# A code or a word only counts when it stands on its own; a symbol always
# does. « (?<![^\W_]) » is « not preceded by a letter or a digit », written
# without \b so that the JavaScript side can say exactly the same thing.
MARK = re.compile("|".join([re.escape(s) for s in sorted(SYMBOLS, key=len, reverse=True)]
                           + [rf"(?<![^\W_]){w}(?![^\W_])" for w in
                              CODES + sorted(WORDS, key=len, reverse=True)]),
                  re.IGNORECASE)

SPACES = str.maketrans({"\u00a0": " ", "\u202f": " ", "'": " "})

# How far a mark may sit from its number: one space, or none. A mark further
# away belongs to something else — a heading, the sentence before.
GAP = r"[ \u00a0\u202f]?"


def extract_amounts(text, convention: str) -> dict:
    """
    Every number in `text` that carries a currency mark, read exactly.

    `convention` is « fr » or « en » — the decimal sign the caller's documents
    use. It is required: without it, a number written « 1,234 » has two
    readings a thousand apart and the code would have to pick one alone.
    """
    if not isinstance(text, str):
        return {"amounts": [], "unmarked": 0,
                "reason": f"expected text, not {type(text).__name__}"}
    if convention not in CONVENTIONS:
        return {"amounts": [], "unmarked": 0,
                "reason": f"declare a convention among {sorted(CONVENTIONS)}"}

    amounts, unmarked = [], 0
    for match in NUMBER.finditer(text):
        codes, mark = _mark_beside(text, match)
        if codes is None:
            # A number with no mark beside it. It may be an amount whose
            # currency is written once in a heading, and it may be a date or a
            # reference; counting them is all this rung can honestly say.
            unmarked += 1
            continue
        value, ambiguous = read_number(match.group(), convention)
        if value is None:
            continue
        amounts.append({"text": match.group(), "value": value, "mark": mark,
                        "currency": codes[0] if len(codes) == 1 else None,
                        "currency_candidates": codes, "ambiguous": ambiguous,
                        "start": match.start(), "end": match.end()})
    return {"amounts": amounts, "unmarked": unmarked, "reason": None}


def _mark_beside(text: str, match):
    """The currency mark touching this number, before it or after it."""
    before = text[max(0, match.start() - 24):match.start()]
    for mark in MARK.finditer(before):
        if re.fullmatch(GAP, before[mark.end():]):
            return codes_of(mark.group()), mark.group()
    after = text[match.end():match.end() + 24]
    mark = MARK.match(after, re.match(GAP, after).end())
    if mark:
        return codes_of(mark.group()), mark.group()
    return None, None


def codes_of(mark: str):
    upper = mark.upper()
    if upper in CODES:
        return [upper]
    return SYMBOLS.get(mark) or WORDS[mark.lower()]


def read_number(raw: str, convention: str):
    """
    The digits that were written, as a decimal string, and whether the reading
    depended on the declared convention.
    """
    decimal_sign = CONVENTIONS[convention]
    raw = raw.translate(SPACES)
    kinds = {c for c in raw if c in " .,"}
    if not kinds:
        return raw, False

    last = max(raw.rfind(c) for c in kinds)
    tail = len(raw) - last - 1
    if len(kinds) > 1 or raw.count(raw[last]) > 1 or raw[last] == " ":
        # Several kinds of separator, or the same one twice: the last one is
        # the decimal sign and the others group the thousands. A space never
        # is a decimal sign.
        decimal, ambiguous = raw[last] != " ", False
    elif tail != 3:
        # A thousands group is exactly three digits, always.
        decimal, ambiguous = True, False
    else:
        # « 1,234 » — three digits behind one separator. Only the convention
        # the caller declared can read this, and the caller is told.
        decimal, ambiguous = raw[last] == decimal_sign, True

    parts = re.split(r"[ .,]", raw[:last] if decimal else raw)
    if not parts[0] or (len(parts) > 1
                        and (len(parts[0]) > 3 or any(len(p) != 3 for p in parts[1:]))):
        return None, False  # not a number: « 3, 4 », « 12 5 », « 1,2345,6 »
    whole = "".join(parts)
    return (f"{whole}.{raw[last + 1:]}" if decimal else whole), ambiguous

JavaScript

snippets/extract-amounts-and-currencies-from-text/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Retrouver les montants cités dans un texte, avec la devise écrite à côté.
 *
 * Niveau N0. Un montant dans un texte courant, c'est un nombre avec une marque
 * à côté de lui — un symbole, un code ISO 4217, ou le mot dans la langue du
 * lecteur. C'est cette marque qui distingue un montant du numéro de facture
 * trois mots plus tôt, et chercher le nombre sans elle est ce qui fait rendre
 * « 2026 » comme total.
 *
 * L'outil de référence est `price-parser`, en Python. Il lit un prix dans une
 * chaîne, ce qu'est un champ de prix aspiré sur une page, et le test de cette
 * fiche montre où cela s'arrête : sur une ligne de facture française qui porte
 * un numéro de pièce, une date et un total, il rend le numéro de pièce ; et sur
 * un document dont l'entête dit « Montants en euros » quand la ligne cite une
 * somme en dollars, il rend l'euro de l'entête. En JavaScript, rien de maintenu
 * n'en fait même autant.
 *
 * La seule chose qu'un texte ne tranche pas, c'est le séparateur. « 1,859 » est
 * un groupe de milliers sous une convention et trois décimales sous l'autre, et
 * les deux sont ordinaires — une somme ronde d'un côté, un prix de carburant de
 * l'autre. L'appelant déclare donc la convention dans laquelle ses documents
 * sont écrits, et tout nombre dont la lecture dépend de cette déclaration
 * revient marqué `ambiguous`. Rien n'est deviné en silence, et rien n'est
 * transformé en flottant au passage : la valeur, ce sont les chiffres qui ont
 * été écrits.
 */

// The decimal sign each convention uses. The caller declares one; there is no
// default, because a default is precisely the silent guess this entry exists
// to refuse.
export const CONVENTIONS = { fr: ',', en: '.' };

// What a currency mark can look like, and what it may stand for. A symbol that
// several currencies share is not resolved here: the candidates are returned.
export const SYMBOLS = {
  '€': ['EUR'],
  '£': ['GBP'],
  '₹': ['INR'],
  '¥': ['JPY', 'CNY'],
  $: ['USD', 'CAD', 'AUD', 'NZD', 'SGD', 'HKD'],
  kr: ['SEK', 'NOK', 'DKK'],
};
export const CODES = ['EUR', 'GBP', 'USD', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY',
  'SEK', 'NOK', 'DKK', 'INR', 'PLN', 'CZK'];
export const WORDS = {
  euro: ['EUR'], euros: ['EUR'], 'livre sterling': ['GBP'], 'livres sterling': ['GBP'],
  pound: ['GBP'], pounds: ['GBP'], 'franc suisse': ['CHF'], 'francs suisses': ['CHF'],
  yen: ['JPY'], dollar: SYMBOLS.$, dollars: SYMBOLS.$,
};

// A number as it is written: groups separated by spaces, dots, commas or
// apostrophes. The shapes are checked afterwards, not here. The digits are
// written out rather than taken from « \\d », which matches every Unicode digit
// in Python and only the ten in JavaScript — and « ١٢٣ » is not a value this
// rung knows how to hand to an accountant.
const NUMBER = /[0-9][0-9 \u00a0\u202f.,']*[0-9]|[0-9]/g;

// A code or a word only counts when it stands on its own; a symbol always
// does. The lookarounds say « not touching a letter or a digit », written
// without \b so that the Python side can say exactly the same thing.
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const MARK = new RegExp([
  ...Object.keys(SYMBOLS).sort((a, b) => b.length - a.length).map(escape),
  ...[...CODES, ...Object.keys(WORDS).sort((a, b) => b.length - a.length)]
    .map((w) => `(?<![\\p{L}\\p{N}])${w}(?![\\p{L}\\p{N}])`),
].join('|'), 'giu');

const SPACES = /[\u00a0\u202f']/g;

// How far a mark may sit from its number: one space, or none. A mark further
// away belongs to something else — a heading, the sentence before.
const GAP = /^[ \u00a0\u202f]?$/;
const GAP_HEAD = /^[ \u00a0\u202f]?/;

/**
 * Every number in `text` that carries a currency mark, read exactly.
 *
 * `convention` is « fr » or « en » — the decimal sign the caller's documents
 * use. It is required: without it, a number written « 1,234 » has two readings
 * a thousand apart and the code would have to pick one alone.
 */
export function extractAmounts(text, convention) {
  if (typeof text !== 'string') {
    return { amounts: [], unmarked: 0, reason: `expected text, not ${typeof text}` };
  }
  if (!(convention in CONVENTIONS)) {
    const noms = Object.keys(CONVENTIONS).sort();
    return { amounts: [], unmarked: 0, reason: `declare a convention among [${noms.map((n) => `'${n}'`).join(', ')}]` };
  }

  const amounts = [];
  let unmarked = 0;
  for (const match of text.matchAll(NUMBER)) {
    const [codes, mark] = markBeside(text, match);
    if (codes === null) {
      // A number with no mark beside it. It may be an amount whose currency is
      // written once in a heading, and it may be a date or a reference;
      // counting them is all this rung can honestly say.
      unmarked += 1;
      continue;
    }
    const [value, ambiguous] = readNumber(match[0], convention);
    if (value === null) continue;
    amounts.push({
      text: match[0],
      value,
      mark,
      currency: codes.length === 1 ? codes[0] : null,
      currency_candidates: codes,
      ambiguous,
      start: match.index,
      end: match.index + match[0].length,
    });
  }
  return { amounts, unmarked, reason: null };
}

/** The currency mark touching this number, before it or after it. */
function markBeside(text, match) {
  const before = text.slice(Math.max(0, match.index - 24), match.index);
  for (const mark of before.matchAll(MARK)) {
    if (GAP.test(before.slice(mark.index + mark[0].length))) {
      return [codesOf(mark[0]), mark[0]];
    }
  }
  const end = match.index + match[0].length;
  const after = text.slice(end, end + 24);
  const head = GAP_HEAD.exec(after)[0].length;
  const mark = new RegExp(MARK.source, 'iu').exec(after.slice(head));
  if (mark && mark.index === 0) return [codesOf(mark[0]), mark[0]];
  return [null, null];
}

export function codesOf(mark) {
  const upper = mark.toUpperCase();
  if (CODES.includes(upper)) return [upper];
  return SYMBOLS[mark] ?? WORDS[mark.toLowerCase()];
}

/**
 * The digits that were written, as a decimal string, and whether the reading
 * depended on the declared convention.
 */
export function readNumber(raw, convention) {
  const decimalSign = CONVENTIONS[convention];
  const clean = raw.replace(SPACES, ' ');
  const kinds = new Set([...clean].filter((c) => ' .,'.includes(c)));
  if (kinds.size === 0) return [clean, false];

  const last = Math.max(...[...kinds].map((c) => clean.lastIndexOf(c)));
  const tail = clean.length - last - 1;
  const sign = clean[last];
  let decimal;
  let ambiguous = false;
  if (kinds.size > 1 || clean.split(sign).length - 1 > 1 || sign === ' ') {
    // Several kinds of separator, or the same one twice: the last one is the
    // decimal sign and the others group the thousands. A space never is a
    // decimal sign.
    decimal = sign !== ' ';
  } else if (tail !== 3) {
    // A thousands group is exactly three digits, always.
    decimal = true;
  } else {
    // « 1,234 » — three digits behind one separator. Only the convention the
    // caller declared can read this, and the caller is told.
    decimal = sign === decimalSign;
    ambiguous = true;
  }

  const parts = (decimal ? clean.slice(0, last) : clean).split(/[ .,]/);
  if (!parts[0] || (parts.length > 1
      && (parts[0].length > 3 || parts.slice(1).some((p) => p.length !== 3)))) {
    return [null, false]; // not a number: « 3, 4 », « 12 5 », « 1,2345,6 »
  }
  const whole = parts.join('');
  return [decimal ? `${whole}.${clean.slice(last + 1)}` : whole, ambiguous];
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Rien ne part sur le réseau : la lecture se fait dans votre navigateur. La convention de séparateur est celle que le cas déclare.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

1 passage attrapé

Ce que le code a repéré

Facture n° 2026-118 du 10 octobre 2026 — total 1 250,00 €

Ce qu’il renvoie

1 montant(s), lus en convention « fr »

Le détail

ÉcritValeurDeviseConvention nécessaire
1 250,001250.00EUR—

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le texte est lu dans votre processus et n’est transmis à personne
  • Un relevé, un contrat ou une facture porte des données financières : ce niveau est celui qui les garde chez vous

Point de rupture

Trois chiffres derrière un séparateur se lisent de deux façons, et les deux sont ordinaires : « 1,859 € » vaut 1,859 en convention française — le prix d’un litre de carburant — et mille fois plus en convention anglaise. Le code lit sous la convention que l’appelant a déclarée et marque le résultat `ambiguous` ; ce drapeau dit que la lecture dépend de la déclaration, il ne dit pas laquelle est juste. Le témoin est dans le même test : « 1 859,00 € », où l’espace tranche le groupement, revient sans drapeau et vaut la même chose sous les deux conventions.

Quand passer au niveau suivant

Le rapport compte des nombres sans marque à côté d’eux, et vous savez que ce sont des montants : la devise est écrite une fois, dans un intitulé de colonne ou une phrase du préambule. Rattacher l’une aux autres est une question de mise en page, et c’est ce que fait le niveau N3.

Les quatre options

  1. RecommandéN0La marque de devise à côté du nombre, et la convention déclarée par l’appelantGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Le modèle rattache les nombres à la devise de l’entête, et ne rend que des portions du documentmodéré · Une seconde

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

C’est le niveau qui existe en anglais et pas en français, et cette fiche est écrite pour les deux. Les modèles anglais de spaCy portent une étiquette `MONEY` parmi dix-huit ; les modèles français, entraînés sur un autre corpus, en portent quatre — lieu, organisation, personne, divers — et aucune ne désigne une somme. Même là où l’étiquette existe, elle rend une portion de texte et pas une valeur : il faut encore lire le nombre, c’est-à-dire ce que fait le niveau ci-dessus.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Un petit modèle spécialisé auto-hébergé devrait être entraîné sur des textes annotés dans chaque langue traitée, pour retrouver une frontière qu’un symbole donne exactement. Et il ne dirait rien de plus sur la seule question difficile : « 1,859 » reste deux nombres possibles, quelle que soit la machine qui le regarde, parce que l’information qui tranche n’est pas dans le texte mais dans la convention du document.

N3Le modèle rattache les nombres à la devise de l’entête, et ne rend que des portions du documentmodéré · Une seconde

Le modèle rattache les nombres à la devise de l’entête, et ne rend que des portions du document

API de LLM généralisteN3modéréUne seconde

Python

snippets/extract-amounts-and-currencies-from-text/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Lire les montants d'un document dont la devise est écrite une fois, en tête.

Niveau N3. Il existe pour le cas que le niveau N0 signale sans pouvoir le
trancher : un tableau dont la colonne s'intitule « Montant (en euros) », un
contrat qui dit une fois que toutes les sommes sont en euros, puis trois
pages de nombres sans rien à côté. Aucune marque ne touche ces nombres, et
les rattacher à l'entête est une question de mise en page, pas de chiffres.

La garde est ce qui rend la lecture vérifiable, et elle vaut mieux que la
consigne. On demande au modèle des portions, pas des valeurs : chaque montant
qu'il rend doit figurer dans le document, caractère pour caractère, et la
devise qu'il donne doit être nommée quelque part dans le document. Ce qui
survit est ensuite lu par la fonction du niveau N0 — l'arithmétique reste là
où elle se teste, et le modèle ne rend jamais un nombre que personne n'a
écrit.

Deux conditions d'exploitation. Le document est coupé à un budget : un
contrat plus long que le budget est lu jusque-là et pas au-delà. Et le coût
est un appel par document : ce niveau est pour les documents où le niveau N0
signale des nombres qu'il n'a pas pu marquer, pas pour une boîte de factures
qui portent leur symbole.
"""

from __future__ import annotations

import json
import re

from n0 import CODES, MARK, codes_of, read_number

# The budget, in characters. A document that does not fit is cut, not refused.
MAX_CHARACTERS = 6000

# A single code fence around the whole answer is a common shape, and refusing
# it would buy another call for nothing.
FENCE = re.compile(r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z", re.DOTALL)

MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

PROMPT = (
    "Read this document and list the sums of money it quotes.\n"
    "Answer with JSON only: {{\"amounts\": [{{\"text\": \"…\", \"currency\": \"…\"}}]}}.\n"
    "Copy `text` from the document, character for character, and give `currency`"
    " as an ISO 4217 code.\n\n"
    "Document:\n{document}"
)


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class ReadingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def read_amounts(text: str, convention: str, client=None, *, attempts: int = 3) -> dict:
    """
    The amounts a model finds, kept only where the document backs them.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()
    document = text[:MAX_CHARACTERS]
    answer = _ask(client, document, attempts)
    named = {code for mark in MARK.finditer(document) for code in codes_of(mark.group())}

    amounts, dropped = [], []
    for item in answer.get("amounts", []) if isinstance(answer, dict) else []:
        written = str(item.get("text", "")) if isinstance(item, dict) else ""
        currency = str(item.get("currency", "")).upper() if isinstance(item, dict) else ""
        value = read_number(written, convention)[0] if written else None
        if not written or written not in document:
            dropped.append({"text": written, "why": "not in the document"})
        elif currency not in CODES:
            dropped.append({"text": written, "why": f"{currency} is not a code this rung knows"})
        elif currency not in named:
            dropped.append({"text": written, "why": f"{currency} is named nowhere in the document"})
        elif value is None:
            dropped.append({"text": written, "why": "not a number this rung can read"})
        else:
            amounts.append({"text": written, "value": value, "currency": currency,
                            "start": document.index(written)})
    return {"source": "model", "amounts": amounts, "dropped": dropped,
            "characters_sent": len(document)}


def _ask(client, document: str, attempts: int) -> dict:
    prompt = PROMPT.format(document=document)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=prompt, temperature=0)
            if not isinstance(answer, str):  # a refusal comes back as no content
                last_error = ValueError("the model answered no text")
                continue
            fenced = FENCE.match(answer)
            return json.loads(fenced.group(1) if fenced else answer)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ReadingUnavailable(str(last_error))

JavaScript

snippets/extract-amounts-and-currencies-from-text/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Lire les montants d'un document dont la devise est écrite une fois, en tête.
 *
 * Niveau N3. Il existe pour le cas que le niveau N0 signale sans pouvoir le
 * trancher : un tableau dont la colonne s'intitule « Montant (en euros) », un
 * contrat qui dit une fois que toutes les sommes sont en euros, puis trois
 * pages de nombres sans rien à côté. Aucune marque ne touche ces nombres, et
 * les rattacher à l'entête est une question de mise en page, pas de chiffres.
 *
 * La garde est ce qui rend la lecture vérifiable, et elle vaut mieux que la
 * consigne. On demande au modèle des portions, pas des valeurs : chaque montant
 * qu'il rend doit figurer dans le document, caractère pour caractère, et la
 * devise qu'il donne doit être nommée quelque part dans le document. Ce qui
 * survit est ensuite lu par la fonction du niveau N0 — l'arithmétique reste là
 * où elle se teste, et le modèle ne rend jamais un nombre que personne n'a
 * écrit.
 *
 * Deux conditions d'exploitation. Le document est coupé à un budget : un
 * contrat plus long que le budget est lu jusque-là et pas au-delà. Et le coût
 * est un appel par document : ce niveau est pour les documents où le niveau N0
 * signale des nombres qu'il n'a pas pu marquer, pas pour une boîte de factures
 * qui portent leur symbole.
 */

import { CODES, MARK, codesOf, readNumber } from './n0.js';

// The budget, in characters. A document that does not fit is cut, not refused.
export const MAX_CHARACTERS = 6000;

// A single code fence around the whole answer is a common shape, and refusing
// it would buy another call for nothing.
const FENCE = /^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/;

export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

const PROMPT = [
  'Read this document and list the sums of money it quotes.',
  'Answer with JSON only: {"amounts": [{"text": "…", "currency": "…"}]}.',
  'Copy `text` from the document, character for character, and give `currency`'
  + ' as an ISO 4217 code.',
  '',
  'Document:',
].join('\n');

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class ReadingUnavailable extends Error {}

/**
 * The amounts a model finds, kept only where the document backs them.
 *
 * @param {string} text
 * @param {'fr'|'en'} convention
 * @param {{client?: {complete: Function}, attempts?: number}} [options]
 */
export async function readAmounts(text, convention, { client, attempts = 3 } = {}) {
  client ??= await providerClient();
  const document = text.slice(0, MAX_CHARACTERS);
  const answer = await ask(client, document, attempts);
  const named = new Set([...document.matchAll(MARK)].flatMap((m) => codesOf(m[0])));

  const amounts = [];
  const dropped = [];
  const asked = answer && typeof answer === 'object' ? answer.amounts ?? [] : [];
  for (const item of Array.isArray(asked) ? asked : []) {
    const written = item && typeof item === 'object' ? String(item.text ?? '') : '';
    const currency = item && typeof item === 'object'
      ? String(item.currency ?? '').toUpperCase() : '';
    const value = written ? readNumber(written, convention)[0] : null;
    if (!written || !document.includes(written)) {
      dropped.push({ text: written, why: 'not in the document' });
    } else if (!CODES.includes(currency)) {
      dropped.push({ text: written, why: `${currency} is not a code this rung knows` });
    } else if (!named.has(currency)) {
      dropped.push({ text: written, why: `${currency} is named nowhere in the document` });
    } else if (value === null) {
      dropped.push({ text: written, why: 'not a number this rung can read' });
    } else {
      amounts.push({ text: written, value, currency, start: document.indexOf(written) });
    }
  }
  return { source: 'model', amounts, dropped, characters_sent: document.length };
}

async function ask(client, document, attempts) {
  const prompt = `${PROMPT}\n${document}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({ prompt, temperature: 0 });
      if (typeof answer !== 'string') {
        lastError = new Error('the model answered no text'); // a refusal has no content
        continue;
      }
      const fenced = FENCE.exec(answer);
      return JSON.parse(fenced ? fenced[1] : answer);
    } catch (error) {
      lastError = error;
    }
  }
  throw new ReadingUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert de données financières à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur

Point de rupture

La garde vérifie que les chiffres rendus sont dans le document et que la devise y est nommée, pas que le nombre était un montant : sur une facture dont l’entête dit que tout est en euros, le numéro de pièce passe la garde et revient comme une somme. Le témoin est dans le même test : un total que le document ne contient pas est écarté, avec sa raison.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées