Extract amounts and currencies from a text

Find the sums quoted in a message or a document, with their currency, however they are written.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because what makes a number an amount is written beside it, and a mark can be searched for. The reference tool is price-parser, in Python; it reads one price out of one string, which is what a scraped price field is, and on a French invoice line carrying a document number, a date and a total it returns the document number — this entry’s test shows it. In JavaScript nothing maintained does even that much. Two decisions carry the rest. The separator convention is required of the caller and has no default, because a default is exactly the silent guess this site exists to refuse: the same reference tool returns “1,859” a thousand times too large, or right, depending on an argument you do not pass it. And the value returned is a string of digits, never a float: on sums of money, that difference has a name.

The currency mark beside the number, and the convention the caller declares

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-amounts-and-currencies-from-text/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Find the amounts quoted in a text, with the currency written next to them.

Rung N0. An amount in running text is a number with a mark beside it — a
symbol, an ISO 4217 code, or the word in the reader's language. That mark is
what tells an amount from the invoice number three words earlier, and looking
for the number without it is what makes an extractor return « 2026 » as a
total.

The reference tool is `price-parser` in Python. It reads one price out of one
string, which is what a scraped price field is, and this entry's test shows
where that stops: on a French invoice line that carries a document number, a
date and a total, it returns the document number; and on a document whose
heading reads « Montants en euros » while the line quotes a sum in dollars, it
hands back the euro of the heading. In JavaScript nothing maintained does even
that much.

The one thing a text cannot settle is the separator. « 1,859 » is a thousands
group under one convention and three decimals under the other, and both are
ordinary — a rounded sum on one side, a fuel price on the other. So the caller
declares the convention their documents are written in, and any number whose
reading depends on that declaration comes back marked `ambiguous`. Nothing is
guessed in silence, and nothing is turned into a float on the way: the value is
the digits that were written.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Find the amounts quoted in a text, with the currency written next to them.
 *
 * Rung N0. An amount in running text is a number with a mark beside it — a
 * symbol, an ISO 4217 code, or the word in the reader's language. That mark is
 * what tells an amount from the invoice number three words earlier, and
 * looking for the number without it is what makes an extractor return « 2026 »
 * as a total.
 *
 * The reference tool is `price-parser` in Python. It reads one price out of
 * one string, which is what a scraped price field is, and this entry's test
 * shows where that stops: on a French invoice line that carries a document
 * number, a date and a total, it returns the document number; and on a
 * document whose heading reads « Montants en euros » while the line quotes a
 * sum in dollars, it hands back the euro of the heading. In JavaScript nothing
 * maintained does even that much.
 *
 * The one thing a text cannot settle is the separator. « 1,859 » is a
 * thousands group under one convention and three decimals under the other, and
 * both are ordinary — a rounded sum on one side, a fuel price on the other. So
 * the caller declares the convention their documents are written in, and any
 * number whose reading depends on that declaration comes back marked
 * `ambiguous`. Nothing is guessed in silence, and nothing is turned into a
 * float on the way: the value is the digits that were written.
 */

// 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];
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

Nothing goes out on the network: the reading happens in your browser. The separator convention is the one the case declares.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

1 match

What the code picked up

Invoice no. 2026-118 of 10 October 2026 — total £1,250.00

What it returns

1 amount(s), read under the “en” convention

In detail

WrittenValueCurrencyConvention needed
1,250.001250.00GBP—

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the text is read inside your own process and is sent to nobody
  • A statement, a contract or an invoice carries financial data: this is the level that keeps it at home

Breaking point

Three digits behind a separator read two ways, and both are ordinary: “1,859 €” is 1.859 under the French convention — the price of a litre of fuel — and a thousand times more under the English one. The code reads under the convention the caller declared and marks the result `ambiguous`; that flag says the reading depends on the declaration, it does not say which one is right. The witness is in the same test: “1 859,00 €”, where the space settles the grouping, comes back with no flag and is worth the same under both conventions.

When to move up a level

The report counts numbers with no mark beside them, and you know they are amounts: the currency is written once, in a column heading or a sentence of the preamble. Tying the one to the others is a question of layout, and that is what level N3 does.

The four options

  1. RecommendedN0The currency mark beside the number, and the convention the caller declaresFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3The model ties the numbers to the heading’s currency, and returns only spans of the documentmodéré · About a second

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

This is the level that exists in English and not in French, and this entry is written for both. The English spaCy models carry a MONEY label among eighteen; the French models, trained on another corpus, carry four — location, organisation, person, miscellaneous — and none of them is a sum. Even where the label exists it returns a span and not a value: you still have to read the number, which is what the level above does.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A small self-hosted specialised model would have to be trained on annotated texts in every language handled, to recover a boundary a symbol gives exactly. And it would say nothing more about the one hard question: “1,859” stays two possible numbers whatever machine looks at it, because what settles it is not in the text but in the document’s convention.

N3The model ties the numbers to the heading’s currency, and returns only spans of the documentmodéré · About a second

The model ties the numbers to the heading’s currency, and returns only spans of the document

General-purpose LLM APIN3modéréAbout a second

Python

snippets/extract-amounts-and-currencies-from-text/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Read the amounts of a document whose currency is written once, in a heading.

Rung N3. It exists for the case rung N0 reports and cannot settle: a table
whose column is headed « Montant (en euros) », a contract that says once that
all sums are in euros, and then three pages of numbers with nothing beside
them. No mark touches those numbers, and attaching the heading to them is a
question about the layout, not about the digits.

The guard keeps the reading verifiable, and it is worth more than the prompt.
The model is asked for spans, not values: each amount it returns must appear in
the document, character for character, and the currency it gives must be named
somewhere in the document. What survives is then read by the same function as
rung N0 — the arithmetic stays where it can be tested, and the model never
hands back a number nobody wrote.

Two operating conditions. The document is cut to a budget, so a contract longer
than the budget is read up to it and no further. And the cost is a call per
document: this rung is for the documents rung N0 reports numbers it could not
mark, not for a mailbox of invoices that carry their symbol.
"""

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.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Read the amounts of a document whose currency is written once, in a heading.
 *
 * Rung N3. It exists for the case rung N0 reports and cannot settle: a table
 * whose column is headed « Montant (en euros) », a contract that says once that
 * all sums are in euros, and then three pages of numbers with nothing beside
 * them. No mark touches those numbers, and attaching the heading to them is a
 * question about the layout, not about the digits.
 *
 * The guard keeps the reading verifiable, and it is worth more than the
 * prompt. The model is asked for spans, not values: each amount it returns
 * must appear in the document, character for character, and the currency it
 * gives must be named somewhere in the document. What survives is then read by
 * the same function as rung N0 — the arithmetic stays where it can be tested,
 * and the model never hands back a number nobody wrote.
 *
 * Two operating conditions. The document is cut to a budget, so a contract
 * longer than the budget is read up to it and no further. And the cost is a
 * call per document: this rung is for the documents rung N0 reports numbers it
 * could not mark, not for a mailbox of invoices that carry their symbol.
 */

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));
}

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • Moderate footprint

What this adds to your regulatory scope

  • Transfer of financial data to a processor, with the contractual arrangements that implies
  • Where the processing happens is to be checked with the provider

Breaking point

The guard checks that the digits returned are in the document and that the currency is named in it, not that the number was an amount: on an invoice whose heading says everything is in euros, the document number passes the guard and comes back as a sum. The witness is in the same test: a total the document does not contain is dropped, with its reason.

When to move up a level

There is no level above this one.

Further reading

Where the figures come from

Metadata