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
"""
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
/**
* 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
interactifLe 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.
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.
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 texte | Lu en jour d’abord | Lu en mois d’abord |
|---|---|---|
| 12/03/2024 | 12 mars 2024 | 3 décembre 2024 |
| 3 avril 2024 | 3 avril 2024 | 3 avril 2024 |
| 2024-03-01 | 1 mars 2024 | 1 mars 2024 |
3 dates retenues sur ce texte.
Là, ça casseVoir le niveau au-dessus
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
- RecommandéN0Une expression régulière par format, validation calendaire, convention lue dans le documentGratuit · Instantané
- N1Un analyseur de dates éprouvé : dateparser en Python, chrono-node en JavaScriptGratuit · Instantané
- N2Niveau sans objet ici
- 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
"""
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
/**
* 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
"""
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
/**
* 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
- dateparser — analyse des dates relatives et des formats de plusieurs langues, en Python
- chrono-node — l’équivalent en JavaScript, un analyseur par langue
- Python — module datetime, le calendrier grégorien proleptique et ses années bissextiles
- MDN — constructeur Date, et le report silencieux des dates impossibles
- RFC 3339 — Date and Time on the Internet: Timestamps
- Unicode CLDR — motifs de date par locale, d’où vient l’ambiguïté jour-mois
Source des chiffres
- dateparser 1.2.2, exécuté : « dans 15 jours » rend le 27 mars depuis le 12 mars 2024, « dans quinze jours » ne rend rien, « le 03/04/2024 » est lu 4 mars, « 3 janv. 2024 » est lu 3 janvier 2025
- chrono-node 2.9.0, exécuté : mêmes entrées, « le 03/04/2024 » est lu 3 avril et « 3 janv. 2024 » n’est pas lu du tout
- MySQL 8.4 Reference Manual — 2-Digit Years in Dates : 00-69 deviennent 2000-2069, 70-99 deviennent 1970-1999
- Python — module time : strptime lit 69-99 comme 1969-1999, selon POSIX, un an plus tôt que MySQL
- Python — la lecture ISO des dates accepte aussi 20191204 et 2021-W01-1, d’où la vérification de forme de N3
- MDN — constructeur Date : report des valeurs hors bornes, années 0 à 99 lues 1900 à 1999
- spaCy — glossaire des étiquettes : DATE, « Absolute or relative dates or periods »
- spaCy — API Span : texte, positions et étiquette, aucune valeur de date résolue
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.