Extraire des dates d’un texte libre

Retrouver les dates mentionnées dans un message ou un document, quel que soit leur format.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la difficulté de ce besoin n’est pas de repérer les dates mais de refuser celles qui n’existent pas, et c’est un calendrier qui le fait, pas un modèle : 31/02/2024 et 29/02/1900 sont écartés par celui de la bibliothèque standard, et le résultat se teste unitairement. Sur 03/04/2024, il lit la convention dans le document lui-même — une date dont le premier champ dépasse douze la fixe pour tout le document — puis ce que l’appelant sait, puis il s’abstient : la date revient sans son jour plutôt qu’avec une supposition. Ajoutez N1, un analyseur éprouvé, le jour où vos échéances s’écrivent en relation avec la date du document ; c’est local, gratuit et déterministe, et cela ne demande aucun appel facturé. N3 ne se justifie qu’au-delà, sur les tournures que ces analyseurs ne lisent pas, en sachant que vous échangerez un test unitaire contre une réponse à relire. Dans les deux cas, la date de référence est celle du document, pas celle du traitement : c’est pourquoi ni N1 ni N3 ne lui donnent de valeur par défaut.

Une expression régulière par format, validation calendaire, convention lue dans le document

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-dates-from-text/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Extraire les dates d'un texte : une expression régulière par format, puis une
vraie vérification calendaire.

Niveau N0. Déterministe, bibliothèque standard seule.

L'expression régulière est la moitié facile. Elle trouve des groupes de
chiffres et ne sait rien d'autre : 31/02/2024 lui correspond parfaitement, et
29/02/2023 aussi. La seconde moitié est ce qui fait la différence, et elle
tient en un seul appel, parce que `datetime.date` possède déjà le calendrier —
la longueur des mois, les années bissextiles, et la règle du siècle qui fait
de 1900 une année commune.

Ce que les chiffres ne peuvent pas dire, c'est si 03/04/2024 est un 3 avril
ou un 4 mars. Le document le dit le plus souvent ailleurs : une de ses dates
dont le premier champ dépasse douze ne peut être qu'un jour d'abord, et cela
fixe toutes les autres dates du même document. À défaut, l'appelant connaît
peut-être la locale de l'expéditeur. À défaut encore, l'extrait s'abstient :
la date revient sans son jour plutôt qu'avec une supposition qui a la forme
d'un fait.
"""

import re
import unicodedata
from datetime import date

# Month names in French and English, the two a French-language document mixes,
# with the abbreviations an invoice, a delivery note or an email actually use.
MONTHS = {name: number for number, names in enumerate(
    ("janvier january janv jan", "fevrier february fevr fev feb", "mars march mar",
     "avril april avr apr", "mai may", "juin june jun", "juillet july juil jul",
     "aout august aou aug", "septembre september sept sep",
     "octobre october oct", "novembre november nov", "decembre december dec"), 1)
    for name in names.split()}

# ASCII digits and full-width digits, read the same way in Python and JavaScript.
D = "[0-90-9]"
# A month name, its accents typed as one character or as a letter plus a mark.
WORD = r"(?P<month>(?:[^\W\d_]|[̀-ͯ])+)"
ORDINAL = "(?:er|st|nd|rd|th)?"

# "3 avril 2024", "1er mars 2024", "3rd April 2024", "3 janv. 2024",
# "3 April, 2024", in any case. The full stop of an abbreviation and the comma
# that often follows the month are both optional.
TEXTUAL = re.compile(
    rf"(?<!{D})(?P<day>{D}{{1,2}}){ORDINAL}\s+{WORD}\.?,?\s+(?P<year>{D}{{4}})(?!{D})", re.I)

# "March 3, 2024", "Mar 3, 2024". It only starts at the start of a word — and
# a combining mark is not the start of a word — so a long run of letters or of
# marks is read once, not once per character.
MONTH_FIRST = re.compile(
    rf"(?<![^\W\d_])(?<![\u0300-\u036f]){WORD}\.?\s+(?P<day>{D}{{1,2}}){ORDINAL},?\s+(?P<year>{D}{{4}})(?!{D})",
    re.I)

# "12/03/2024", "12.03.24", "12-03-2024", and the ISO "2024-03-12". Never a
# piece of a longer dotted number: in "10.1.1.24", "1.1.24" is not a date.
NUMERIC = re.compile(rf"(?<!{D})(?<!{D}[/.-])({D}{{1,2}}|{D}{{4}})[/.-]({D}{{1,2}})[/.-]({D}{{2}}|{D}{{4}})(?![/.-]?{D})")


def _fold(word: str) -> str:
    """Drop accents, so that "février" and "fevrier" reach the same entry."""
    decomposed = unicodedata.normalize("NFKD", word.lower())
    return "".join(c for c in decomposed if not unicodedata.combining(c))


def _to_date(year: int, month: int, day: int) -> date | None:
    """
    Real calendar validation, and the point of this rung.

    A regular expression accepts 31 February; `date` does not. Leap years come
    with it, century rule included.
    """
    try:
        return date(year, month, day)
    except ValueError:
        return None


def _full_year(written: str) -> int:
    """
    Two-digit years as MySQL reads them: 00-69 are 2000-2069, 70-99 are 1970-1999.

    The pivot is right for deadlines and wrong for birth dates: "12/03/65"
    comes out 2065. A field that holds dates of birth wants its own rule.
    """
    year = int(written)
    return year if len(written) == 4 else year + (2000 if year < 70 else 1900)


def _read_textual(match: re.Match) -> date | None:
    month = MONTHS.get(_fold(match["month"]))
    return _to_date(int(match["year"]), month, int(match["day"])) if month else None


UNDECIDED = object()  # a date whose day nothing in the document settles


def _fields_of(match: re.Match) -> tuple[str, str, str] | None:
    """The three numeric fields, or None when the match is not a date at all."""
    first, second, third = match.groups()
    if len(third) == 2 and (len(first) == 1 or len(second) == 1):
        return None  # a short year only with a padded day and month: "version 2.1.24" is no date
    return first, second, third


def document_convention(text: str) -> bool | None:
    """
    Read the day-month convention off the document itself.

    A numeric date whose first field is above twelve can only be day-first;
    one whose second field is above twelve can only be month-first. A single
    such date settles every ambiguous date of the same document, which is what
    a person does when reading it. A document that carries both kinds
    contradicts itself and settles nothing.

    Returns True for day-first, False for month-first, None when the document
    is silent or contradicts itself.
    """
    convention = None
    for match in NUMERIC.finditer(text):
        fields = _fields_of(match)
        if fields is None or len(fields[0]) == 4:
            continue  # ISO carries its own order and proves nothing about the rest
        first, second, _ = (int(field) for field in fields)
        proof = True if first > 12 else False if second > 12 else None
        if proof is None:
            continue
        if convention is not None and convention != proof:
            return None
        convention = proof
    return convention


def _read_numeric(match: re.Match, day_first: bool | None):
    fields = _fields_of(match)
    if fields is None:
        return None
    first, second, third = fields
    if len(first) == 4:  # ISO order, whatever the local habit is
        return _to_date(int(first), int(second), int(third))
    settled = True if int(first) > 12 else False if int(second) > 12 else day_first
    if settled is None:
        return UNDECIDED
    day, month = (first, second) if settled else (second, first)
    return _to_date(_full_year(third), int(month), int(day))


def extract_dates(text: str, day_first: bool | None = None) -> list[tuple[str, date | None]]:
    """
    Return every real date in `text`, as (what was written, what it means).

    `day_first` says how to read 03/04/2024 when the document does not. Left
    out, the convention is read from the document itself, once, and applied to
    all of it: see `document_convention`. Passed, it is the caller's own
    knowledge — the locale of the sender, the country of the supplier — and it
    wins over nothing, because the document's own proof is stronger and is
    applied date by date before it.

    A date the document does not settle and the caller did not settle comes
    back with None instead of a day. That is the point: a guess in the shape
    of a fact is worse than a hole, because nothing downstream can tell the
    two apart.
    """
    if day_first is None:
        day_first = document_convention(text)
    found, taken = [], bytearray(len(text))  # taken: characters already read as a date
    for pattern in (TEXTUAL, MONTH_FIRST, NUMERIC):
        for match in pattern.finditer(text):
            start, end = match.span()
            # A match that overlaps an accepted one is a second reading of the
            # same characters, not a second date.
            if 1 in taken[start:end]:
                continue
            value = _read_numeric(match, day_first) if pattern is NUMERIC else _read_textual(match)
            if value is not None:
                found.append((start, match.group(0), None if value is UNDECIDED else value))
                taken[start:end] = b"\x01" * (end - start)
    found.sort(key=lambda item: item[0])
    return [(written, value) for _, written, value in found]

JavaScript

snippets/extract-dates-from-text/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Extraire les dates d'un texte : une expression régulière par format, puis une
 * vraie vérification calendaire.
 *
 * Niveau N0. Déterministe, sans dépendance.
 *
 * L'expression régulière est la moitié facile. Elle trouve des groupes de
 * chiffres et ne sait rien d'autre : 31/02/2024 lui correspond parfaitement, et
 * 29/02/2023 aussi.
 *
 * La seconde moitié est ce qui fait la différence, et en JavaScript elle
 * demande de l'attention : `new Date(2024, 1, 31)` n'échoue pas, il glisse
 * silencieusement au 2 mars. La seule vérification honnête est de construire la
 * date et de relire ses parties.
 *
 * Ce que les chiffres ne peuvent pas dire, c'est si 03/04/2024 est un 3 avril
 * ou un 4 mars. Le document le dit le plus souvent ailleurs : une de ses dates
 * dont le premier champ dépasse douze ne peut être qu'un jour d'abord, et cela
 * fixe toutes les autres dates du même document. À défaut, l'appelant connaît
 * peut-être la locale de l'expéditeur. À défaut encore, l'extrait s'abstient :
 * la date revient sans son jour plutôt qu'avec une supposition qui a la forme
 * d'un fait.
 */

// Month names in French and English, the two a French-language document mixes,
// with the abbreviations an invoice, a delivery note or an email actually use.
const MONTHS = new Map(['janvier january janv jan', 'fevrier february fevr fev feb',
  'mars march mar', 'avril april avr apr', 'mai may', 'juin june jun',
  'juillet july juil jul', 'aout august aou aug', 'septembre september sept sep',
  'octobre october oct', 'novembre november nov', 'decembre december dec']
  .flatMap((names, index) => names.split(' ').map((name) => [name, index + 1])));

// ASCII digits and full-width digits, read the same way in Python and JavaScript.
const D = '[0-90-9]';
// A month name, its accents typed as one character or as a letter plus a mark.
const WORD = '(?<month>[\\p{L}\\p{M}]+)';
const ORDINAL = '(?:er|st|nd|rd|th)?';

// "3 avril 2024", "1er mars 2024", "3rd April 2024", "3 janv. 2024",
// "3 April, 2024", in any case. The full stop of an abbreviation and the comma
// that often follows the month are both optional.
const TEXTUAL = new RegExp(`(?<!${D})(?<day>${D}{1,2})${ORDINAL}\\s+${WORD}\\.?,?\\s+(?<year>${D}{4})(?!${D})`, 'giu');

// "March 3, 2024", "Mar 3, 2024". It only starts at the start of a word — and
// a combining mark is not the start of a word — so a long run of letters or of
// marks is read once, not once per character.
const MONTH_FIRST = new RegExp(`(?<!\\p{L})(?<!\\p{M})${WORD}\\.?\\s+(?<day>${D}{1,2})${ORDINAL},?\\s+(?<year>${D}{4})(?!${D})`, 'giu');

// "12/03/2024", "12.03.24", "12-03-2024", and the ISO "2024-03-12". Never a
// piece of a longer dotted number: in "10.1.1.24", "1.1.24" is not a date.
const NUMERIC = new RegExp(`(?<!${D})(?<!${D}[/.-])(${D}{1,2}|${D}{4})[/.-](${D}{1,2})[/.-](${D}{2}|${D}{4})(?![/.-]?${D})`, 'gu');

/** Drop accents, so that "février" and "fevrier" reach the same entry. */
function fold(word) {
  return word.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '');
}

/** Full-width digits become ASCII digits before being read as a number. */
const number = (digits) => Number(digits.normalize('NFKC'));

/**
 * Real calendar validation, and the point of this rung.
 *
 * Building the date is not enough, because JavaScript rolls an impossible one
 * over instead of refusing it. Reading the parts back is the check.
 */
function toDate(year, month, day) {
  const value = new Date(0);
  value.setUTCFullYear(year, month - 1, day); // unlike Date.UTC, keeps year 24 as 24, not 1924
  const real = year >= 1 && value.getUTCFullYear() === year && value.getUTCMonth() === month - 1
    && value.getUTCDate() === day;
  return real ? value : null;
}

/**
 * Two-digit years as MySQL reads them: 00-69 are 2000-2069, 70-99 are 1970-1999.
 *
 * The pivot is right for deadlines and wrong for birth dates: "12/03/65" comes
 * out 2065. A field that holds dates of birth wants its own rule.
 */
function fullYear(written) {
  const year = number(written);
  return written.length === 4 ? year : year + (year < 70 ? 2000 : 1900);
}

function readTextual(match) {
  const month = MONTHS.get(fold(match.groups.month));
  return month ? toDate(number(match.groups.year), month, number(match.groups.day)) : null;
}

/** A date whose day nothing in the document settles. */
export const UNDECIDED = Symbol('undecided');

/** The three numeric fields, or null when the match is not a date at all. */
function fieldsOf(match) {
  const [first, second, third] = match.slice(1, 4);
  // A short year only with a padded day and month: "version 2.1.24" is no date.
  if (third.length === 2 && (first.length === 1 || second.length === 1)) return null;
  return [first, second, third];
}

/**
 * Read the day-month convention off the document itself.
 *
 * A numeric date whose first field is above twelve can only be day-first; one
 * whose second field is above twelve can only be month-first. A single such
 * date settles every ambiguous date of the same document, which is what a
 * person does when reading it. A document that carries both kinds contradicts
 * itself and settles nothing.
 *
 * Returns true for day-first, false for month-first, null when the document is
 * silent or contradicts itself.
 */
export function documentConvention(text) {
  let convention = null;
  for (const match of text.matchAll(NUMERIC)) {
    const fields = fieldsOf(match);
    // ISO carries its own order and proves nothing about the rest.
    if (fields === null || fields[0].length === 4) continue;
    const [first, second] = fields.map(number);
    const proof = first > 12 ? true : second > 12 ? false : null;
    if (proof === null) continue;
    if (convention !== null && convention !== proof) return null;
    convention = proof;
  }
  return convention;
}

function readNumeric(match, dayFirst) {
  const fields = fieldsOf(match);
  if (fields === null) return null;
  const [first, second, third] = fields;
  if (first.length === 4) return toDate(number(first), number(second), number(third)); // ISO order
  const settled = number(first) > 12 ? true : number(second) > 12 ? false : dayFirst;
  if (settled === null) return UNDECIDED;
  const [day, month] = settled ? [first, second] : [second, first];
  return toDate(fullYear(third), number(month), number(day));
}

/**
 * Return every real date in `text`, as { text: what was written, date }.
 *
 * `dayFirst` says how to read 03/04/2024 when the document does not. Left out,
 * the convention is read from the document itself, once, and applied to all of
 * it: see `documentConvention`. Passed, it is the caller's own knowledge — the
 * locale of the sender, the country of the supplier — and it wins over nothing,
 * because the document's own proof is stronger and is applied date by date
 * before it.
 *
 * A date the document does not settle and the caller did not settle comes back
 * with `date: null` instead of a day. That is the point: a guess in the shape
 * of a fact is worse than a hole, because nothing downstream can tell the two
 * apart.
 */
export function extractDates(text, dayFirst = null) {
  const convention = dayFirst === null ? documentConvention(text) : dayFirst;
  const found = [];
  const taken = new Uint8Array(text.length); // characters already read as a date
  for (const pattern of [TEXTUAL, MONTH_FIRST, NUMERIC]) {
    for (const match of text.matchAll(pattern)) {
      const [start, end] = [match.index, match.index + match[0].length];
      // A match that overlaps an accepted one is a second reading of the same
      // characters, not a second date.
      if (taken.subarray(start, end).includes(1)) continue;
      const date = pattern === NUMERIC ? readNumeric(match, convention) : readTextual(match);
      if (date) {
        found.push({ start, text: match[0], date: date === UNDECIDED ? null : date });
        taken.fill(1, start, end);
      }
    }
  }
  found.sort((a, b) => a.start - b.start);
  return found.map(({ text: written, date }) => ({ text: written, date }));
}

Essayer

interactif

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

Les deux colonnes donnent les deux lectures du même passage. L’extrait, lui, lit d’abord la convention dans le document — une date dont le premier champ dépasse douze la fixe pour tout le document — et s’abstient quand le document se tait.

à 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

3 passages attrapés

Ce que le code a repéré

Réunion le 12/03/2024, livraison le 3 avril 2024, gel des specs 2024-03-01.

Ce qu’il renvoie

Écrit dans le texteLu en jour d’abordLu en mois d’abord
12/03/202412 mars 20243 décembre 2024
3 avril 20243 avril 20243 avril 2024
2024-03-011 mars 20241 mars 2024

3 dates retenues sur ce texte.

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 document ne quitte pas votre infrastructure

Point de rupture

Les dates relatives. « jeudi prochain », « dans 15 jours », « à partir de demain » : il n’y a rien à faire correspondre, et l’échec est silencieux, une liste vide et non une erreur. C’est le niveau N1 qui les lit. Même silence sur « le 5 mars », sans année : cela nomme un jour, pas une date.

Quand passer au niveau suivant

Vos documents portent des échéances écrites en relation avec leur propre date — « sous quinzaine », « dans 15 jours » — et la liste des dates que N0 rend est vide là où il y avait une échéance.

Les quatre options

  1. RecommandéN0Une expression régulière par format, validation calendaire, convention lue dans le documentGratuit · Instantané
  2. N1Un analyseur de dates éprouvé : dateparser en Python, chrono-node en JavaScriptGratuit · Instantané
  3. N2Niveau sans objet ici
  4. N3Extraction structurée par appel à un modèle généralisteélevé · Une seconde

Les autres options

N1Un analyseur de dates éprouvé : dateparser en Python, chrono-node en JavaScriptGratuit · Instantané

Un analyseur de dates éprouvé : dateparser en Python, chrono-node en JavaScript

Modèle classique légerN1GratuitInstantané

Python

snippets/extract-dates-from-text/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Lire les dates qu'un motif ne peut pas lire : un analyseur de dates éprouvé.

Niveau N1. Le niveau N0 énumère les formes qu'il connaît, et une échéance
relative n'a pas de forme à énumérer : « dans 15 jours » se compte depuis une
date. C'est ce comptage, dans une trentaine de langues, que fait une
bibliothèque d'analyse de dates. `dateparser` ici, `chrono-node` en
JavaScript : locale, gratuite, déterministe, et installée plutôt qu'écrite.

Il faut lui dire deux choses dont un motif n'avait pas besoin. Quelles langues
le texte peut employer — un analyseur à qui l'on donne toutes celles qu'il
connaît lit davantage, et lit davantage de travers. Et depuis quelle date
compter : `reference` n'a pas de valeur par défaut ici, à dessein, parce que
la date depuis laquelle compter est celle du document, pas celle de
l'exécution. « jeudi prochain » dans un courriel reçu il y a trois semaines
n'est pas jeudi prochain.

Un extrait qui confie le travail à une bibliothèque hérite de la
bibliothèque, pas d'une spécification : les deux langages de cette page n'ont
pas le même analyseur, et ils ne lisent pas les mêmes choses. Sur
« 3 janv. 2024 », `dateparser` répond le 3 janvier 2025 et `chrono-node` ne
répond rien, là où le niveau N0 répond le 3 janvier 2024 — c'est pourquoi N0
reste dessous, et non à côté. Sur « jeudi prochain » ils divergent aussi : le
14 mars pour l'un, le 21 pour l'autre.
"""

from datetime import date, datetime

from dateparser.search import search_dates

LANGUAGES = ("fr", "en")


def extract_dates(text: str, reference: date, languages=LANGUAGES) -> list[tuple[str, date]]:
    """
    Return every date found in `text`, as (what was written, what it means).

    `reference` is the date the relative expressions count from: the date of
    the document. It is required, because a default would silently be the day
    of the run, and a backlog reprocessed on Monday would move every deadline.

    "PREFER_DATES_FROM: future" is the reading of a deadline: "le 3 janvier" in
    a March document is the next one, not the one gone by.
    """
    if not isinstance(reference, date):
        raise TypeError("reference must be a date: the date of the document, not of the run")
    if not languages:
        # An empty list means "every language it knows" to the library. Asked
        # for nothing, this returns nothing, as the JavaScript version does.
        raise ValueError("at least one language is needed")
    settings = {
        "RELATIVE_BASE": datetime(reference.year, reference.month, reference.day),
        "PREFER_DATES_FROM": "future",
    }
    found = search_dates(text, languages=list(languages), settings=settings)
    return [(written, value.date()) for written, value in found or []]

JavaScript

snippets/extract-dates-from-text/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Lire les dates qu'un motif ne peut pas lire : un analyseur de dates éprouvé.
 *
 * Niveau N1. Le niveau N0 énumère les formes qu'il connaît, et une échéance
 * relative n'a pas de forme à énumérer : « dans 15 jours » se compte depuis une
 * date. C'est ce comptage, dans plusieurs langues, que fait une bibliothèque
 * d'analyse de dates. `chrono-node` ici, `dateparser` en Python : locale,
 * gratuite, déterministe, et installée plutôt qu'écrite.
 *
 * Il faut lui dire deux choses dont un motif n'avait pas besoin. Quelles langues
 * le texte peut employer — `chrono` a un analyseur par langue, et le français ne
 * lit rien d'anglais. Et depuis quelle date compter : `reference` n'a pas de
 * valeur par défaut ici, à dessein, parce que la date depuis laquelle compter
 * est celle du document, pas celle de l'exécution. « jeudi prochain » dans un
 * courriel reçu il y a trois semaines n'est pas jeudi prochain.
 *
 * Un extrait qui confie le travail à une bibliothèque hérite de la
 * bibliothèque, pas d'une spécification : les deux langages de cette page n'ont
 * pas le même analyseur, et ils ne lisent pas les mêmes choses. Sur
 * « 3 janv. 2024 », `dateparser` répond le 3 janvier 2025 et `chrono-node` ne
 * répond rien, là où le niveau N0 répond le 3 janvier 2024 — c'est pourquoi N0
 * reste dessous, et non à côté. Sur « jeudi prochain » ils divergent aussi : le
 * 14 mars pour l'un, le 21 pour l'autre.
 */

import * as chrono from 'chrono-node';

export const LANGUAGES = ['fr', 'en'];

/**
 * Return every date found in `text`, as { text: what was written, date }.
 *
 * `reference` is the date the relative expressions count from: the date of the
 * document. It is required, because a default would silently be the day of the
 * run, and a backlog reprocessed on Monday would move every deadline.
 *
 * `forwardDate` is the reading of a deadline: "le 3 janvier" in a March
 * document is the next one, not the one gone by.
 */
export function extractDates(text, reference, languages = LANGUAGES) {
  if (!(reference instanceof Date) || Number.isNaN(reference.getTime())) {
    throw new TypeError('reference must be a Date: the date of the document, not of the run');
  }
  // Asked for no language, this returns nothing, and says so rather than
  // quietly reading none — the Python library would read every language it knows.
  if (languages.length === 0) throw new RangeError('at least one language is needed');
  const found = [];
  for (const language of languages) {
    const parser = chrono[language];
    if (!parser) throw new RangeError(`chrono has no parser for ${language}`);
    for (const result of parser.parse(text, reference, { forwardDate: true })) {
      // One parser per language means the same words can be read twice: the
      // first language asked for wins, as in the Python version.
      const end = result.index + result.text.length;
      if (found.some((seen) => seen.index < end && result.index < seen.index + seen.text.length)) continue;
      found.push({ index: result.index, text: result.text, date: result.start.date() });
    }
  }
  found.sort((a, b) => a.index - b.index);
  return found.map(({ text: written, date }) => ({ text: written, date }));
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

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

  • Traitement des documents sur votre infrastructure : la bibliothèque est locale et n’ouvre aucune connexion
  • Aucun corpus : rien du document n’est appris ni conservé au-delà de l’exécution

Point de rupture

Une durée écrite en toutes lettres : « dans quinze jours » ne rend rien, quand « dans 15 jours » rend le 27 mars. Et l’analyseur ne s’abstient jamais : sur « 03/04/2024 » seul il tranche selon ses propres règles, et les deux bibliothèques ne tranchent pas pareil — 4 mars pour dateparser, 3 avril pour chrono-node.

Quand passer au niveau suivant

Vos échéances s’écrivent d’une façon qu’aucun des deux analyseurs ne lit, et vous le voyez sur un échantillon relu à la main.

N2Niveau sans objet ici

Niveau sans objet ici

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

Un modèle de reconnaissance d’entités nommées, celui de spaCy par exemple, étiquette un passage DATE, « jeudi prochain » compris, mais rend le passage et ses positions, pas le jour qu’il désigne : il ne tranche ni 03/04/2024 ni « jeudi prochain ». Il ajoute des poids à télécharger pour repérer ce que les motifs de N0 repèrent déjà, et laisse entier ce que l’analyseur de N1 tranche pour rien.

N3Extraction structurée par appel à un modèle généralisteélevé · Une seconde

Extraction structurée par appel à un modèle généraliste

API de LLM généralisteN3élevéUne seconde

Python

snippets/extract-dates-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.
"""
Extraire les dates en interrogeant un modèle généraliste.

Niveau N3. Sa requête demande de résoudre les dates relatives, « jeudi
prochain » : elle envoie une date de référence avec le texte, et demande
chaque date sous forme de jour du calendrier. Le niveau N1 résout les mêmes
tournures sur place et pour rien ; ce niveau ne vaut son prix que sur celles
qu'un analyseur manque.

La référence est la date du document, pas le jour de l'exécution : « jeudi
prochain » dans un courriel reçu il y a trois semaines n'est pas jeudi
prochain. Elle n'a pas de valeur par défaut ici, à dessein, parce qu'une
valeur par défaut serait discrètement aujourd'hui, et qu'un arriéré retraité
un lundi déplacerait toutes les échéances.

Le plafond sur l'entrée lève au lieu de tronquer : un contrat coupé en deux
reviendrait avec une liste d'échéances qui a l'air complète. Ce que fait un
appelant au-dessus du plafond, c'est découper le document en morceaux qui se
recouvrent et fusionner les réponses ; cet extrait ne le fait pas, à dessein,
parce qu'il montre un appel.

Voyez ce que le code doit faire et que N0 ne faisait pas : passer une date de
référence, parce que rien d'autre n'indique au modèle le jour qu'il est ;
brider la taille de l'entrée ; réessayer en cas d'échec ; analyser une réponse
qui n'est que probablement du JSON valide ; et vérifier le calendrier
lui-même, parce que rien dans la requête n'empêche la réponse de contenir
2024-02-31. Cette plomberie est le vrai coût du niveau, et c'est la part que
les tests doivent couvrir, puisque le modèle lui-même n'est pas testable.
"""

import json
import re
from datetime import date

PROMPT = (
    "Find every date mentioned in the text below. Answer with JSON only: a list\n"
    "of objects with keys `text` and `date`, where `text` is the words as written\n"
    "and `date` is the day in ISO format, YYYY-MM-DD. Resolve relative dates such\n"
    "as 'next Thursday' against the date of the document, which is {reference}.\n"
    "If there is no date, answer with an empty list.\n\nText:\n{text}"
)
ISO_DAY = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")

MAX_CHARACTERS = 8000

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts


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


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


def extract_dates(text: str, client=None, *, reference: date, attempts: int = 3):
    """
    Return every date the model reports, as (what was written, what it means).

    `reference` is the date the relative expressions are resolved against: the
    date of the document. It has no default: it is a keyword argument the
    caller has to pass, because a default would quietly be today.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    if not isinstance(reference, date):
        raise TypeError("reference must be a date: the date of the document, not of the run")
    # The provider bills every token of the prompt. Refusing oversized input
    # before the call is a cost control; the cap counts characters, not tokens.
    if len(text) > MAX_CHARACTERS:
        raise ValueError(f"text longer than {MAX_CHARACTERS} characters")
    if not text.strip():
        return []  # nothing to read, so nothing to pay for
    client = client or ProviderClient()

    found = []
    for item in _ask(client, text, reference, attempts):
        try:
            # The same calendar check as N0, on the model's answer this time.
            value = date.fromisoformat(item["date"])
        except ValueError:
            continue  # a day that does not exist is not a date, however fluent
        written = item.get("text")
        found.append((written if isinstance(written, str) else "", value))
    return found


def _unfenced(answer: str) -> str:
    """
    Strip a code fence that wraps the whole answer, and nothing else.

    Models often hand back a JSON answer inside one fenced block, and refusing
    that form would pay for a second call for nothing. Any other departure —
    text before or after, two blocks, a fence never closed — is left alone, and
    fails to parse, which is the point.
    """
    stripped = answer.strip()
    if stripped.startswith("```") and stripped.endswith("```") and stripped.count("```") == 2:
        return stripped[3:-3].removeprefix("json")
    return stripped


def _is_usable(parsed) -> bool:
    """A list of objects, each with a `date` written YYYY-MM-DD, as the prompt asked."""
    return isinstance(parsed, list) and all(
        isinstance(item, dict) and isinstance(item.get("date"), str) and ISO_DAY.fullmatch(item["date"])
        for item in parsed
    )


def _ask(client, text: str, reference: date, attempts: int) -> list[dict]:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(
                prompt=PROMPT.format(text=text, reference=reference.isoformat()),
                # Temperature zero: a date that changes between two identical
                # calls cannot be reviewed.
                temperature=0,
            )
            if answer is None:  # a refusal carries no content: unusable, not empty
                last_error = ValueError("the model returned no content")
                continue
            parsed = json.loads(_unfenced(answer))
            if _is_usable(parsed):
                return parsed
            # A list of anything else would read as "no date found", which it is not.
            last_error = ValueError("the model answered something other than a list of ISO days")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ExtractionUnavailable(str(last_error))

JavaScript

snippets/extract-dates-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.
/**
 * Extraire les dates en interrogeant un modèle généraliste.
 *
 * Niveau N3. Sa requête demande de résoudre les dates relatives, « jeudi
 * prochain » : elle envoie une date de référence avec le texte, et demande
 * chaque date sous forme de jour du calendrier. Le niveau N1 résout les mêmes
 * tournures sur place et pour rien ; ce niveau ne vaut son prix que sur celles
 * qu'un analyseur manque.
 *
 * La référence est la date du document, pas le jour de l'exécution : « jeudi
 * prochain » dans un courriel reçu il y a trois semaines n'est pas jeudi
 * prochain. Elle n'a pas de valeur par défaut ici, à dessein, parce qu'une
 * valeur par défaut serait discrètement aujourd'hui, et qu'un arriéré retraité
 * un lundi déplacerait toutes les échéances.
 *
 * Le plafond sur l'entrée lève au lieu de tronquer : un contrat coupé en deux
 * reviendrait avec une liste d'échéances qui a l'air complète. Ce que fait un
 * appelant au-dessus du plafond, c'est découper le document en morceaux qui se
 * recouvrent et fusionner les réponses ; cet extrait ne le fait pas, à dessein,
 * parce qu'il montre un appel.
 *
 * Voyez ce que le code doit faire et que N0 ne faisait pas : passer une date de
 * référence, parce que rien d'autre n'indique au modèle le jour qu'il est ;
 * brider la taille de l'entrée ; réessayer en cas d'échec ; analyser une réponse
 * qui n'est que probablement du JSON valide ; et vérifier le calendrier
 * lui-même, parce que rien dans la requête n'empêche la réponse de contenir
 * 2024-02-31. Cette plomberie est le vrai coût du niveau, et c'est la part que
 * les tests doivent couvrir, puisque le modèle lui-même n'est pas testable.
 */

const PROMPT = [
  'Find every date mentioned in the text below. Answer with JSON only: a list',
  'of objects with keys `text` and `date`, where `text` is the words as written',
  'and `date` is the day in ISO format, YYYY-MM-DD. Resolve relative dates such',
  'as "next Thursday" against the date of the document, which is {reference}.',
  'If there is no date, answer with an empty list.', '', 'Text:',
].join('\n');
const ISO_DAY = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/;

export const MAX_CHARACTERS = 8000;

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

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 ExtractionUnavailable extends Error {}

/** The calendar check of N0, applied to the model's answer this time. */
function parseIsoDay(value) {
  const [year, month, day] = ISO_DAY.exec(value).slice(1).map(Number);
  const date = new Date(0);
  date.setUTCFullYear(year, month - 1, day); // unlike Date.UTC, keeps year 24 as 24
  const real = year >= 1 && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
  return real ? date : null;
}

/** The caller's own calendar day, not the UTC one: past midnight in Paris, UTC is still yesterday. */
function localDay(date) {
  const pad = (n) => String(n).padStart(2, '0');
  return `${String(date.getFullYear()).padStart(4, '0')}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

/**
 * Return every date the model reports, as { text: what was written, date }.
 *
 * @param {string} text
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {Date} options.reference the date of the document, which relative
 *   dates are resolved against, read in the local time zone. Required.
 * @param {number} [options.attempts]
 */
export async function extractDates(text, { client, reference, attempts = 3 } = {}) {
  if (!(reference instanceof Date) || Number.isNaN(reference.getTime())) {
    throw new TypeError('reference must be a Date: the date of the document, not of the run');
  }
  // The provider bills every token of the prompt. Refusing oversized input
  // before the call is a cost control; the cap counts characters, not tokens,
  // and code points, as Python does, not UTF-16 units.
  if (text.length > MAX_CHARACTERS && [...text].length > MAX_CHARACTERS) throw new RangeError(`text longer than ${MAX_CHARACTERS} characters`);
  if (!text.trim()) return []; // nothing to read, so nothing to pay for
  client ??= await providerClient();

  const items = await ask(client, text, localDay(reference), attempts);
  // A day that does not exist is not a date, however fluent the answer.
  return items.map((item) => ({ text: typeof item.text === 'string' ? item.text : '', date: parseIsoDay(item.date) }))
    .filter((item) => item.date);
}

/** A list of objects, each with a `date` written YYYY-MM-DD, as the prompt asked. */
function isUsable(parsed) {
  return Array.isArray(parsed) && parsed.every((item) =>
    item !== null && typeof item === 'object' && typeof item.date === 'string' && ISO_DAY.test(item.date));
}

/**
 * Strip a code fence that wraps the whole answer, and nothing else.
 *
 * Models often hand back a JSON answer inside one fenced block, and refusing
 * that form would pay for a second call for nothing. Any other departure — text
 * before or after, two blocks, a fence never closed — is left alone, and fails
 * to parse, which is the point.
 */
function unfenced(answer) {
  const stripped = answer.trim();
  const fences = stripped.match(/```/g)?.length ?? 0;
  if (stripped.startsWith('```') && stripped.endsWith('```') && fences === 2) {
    return stripped.slice(3, -3).replace(/^json/, '');
  }
  return stripped;
}

async function ask(client, text, reference, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      // Temperature zero: a date that changes between two identical calls
      // cannot be reviewed.
      const prompt = `${PROMPT.replace('{reference}', reference)}\n${text}`;
      const answer = await client.complete({ prompt, temperature: 0 });
      if (answer === null || answer === undefined) { // a refusal carries no content: unusable, not empty
        lastError = new Error('the model returned no content');
        continue;
      }
      const parsed = JSON.parse(unfenced(answer));
      if (isUsable(parsed)) return parsed;
      // A list of anything else would read as "no date found", which it is not.
      lastError = new Error('the model answered something other than a list of ISO days');
    } catch (error) {
      lastError = error;
    }
  }
  throw new ExtractionUnavailable(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 élevée

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

  • Transfert du document à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Ne vous dispense pas de vos propres obligations de minimisation : c’est le document entier qui part, pas les seules dates

Point de rupture

La fluidité n’est pas l’exactitude. Rien dans la requête n’empêche la réponse de porter « 2024-02-31 » en JSON impeccable, ni une date qui ne figure pas dans le document. La vérification calendaire de N0 doit rester, et elle écarte le jour impossible ; la date absente du document passe, et rien dans la réponse ne la signale. Une réponse qui n’est pas une liste d’objets à date AAAA-MM-JJ — de la prose, une clé mal nommée, une date écrite « 12/03/2024 » — est redemandée, puis l’extrait lève une erreur, plutôt que de rendre une liste vide qui affirmerait qu’il n’y avait pas de date.

Quand passer au niveau suivant

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

Pour aller plus loin

Source des chiffres

Métadonnées