Extract dates from free text
Find the dates mentioned in a message or a document, whatever their format.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the hard part of this need is not spotting dates but refusing the ones that do not exist, and a calendar does that, not a model: 31/02/2024 and 29/02/1900 are dropped by the standard library's own, and the result is unit testable. On 03/04/2024 it reads the convention off the document itself — one date whose first field is above twelve fixes it for the whole document — then what the caller knows, then it abstains: the date comes back without its day rather than with a guess. Add N1, a mature parser, the day your deadlines are written relative to the document's own date; it is local, free and deterministic, and it asks for no billed call. N3 only earns its place beyond that, on the turns of phrase those parsers do not read, knowing you will trade a unit test for an answer that needs reviewing. Either way the reference date is the document's, not the run's: which is why neither N1 nor N3 gives it a default.
One regular expression per format, calendar validation, convention read off the document
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Extract dates from text: one regular expression per format, then a real
calendar check.
Rung N0. Deterministic, standard library only.
The regular expression is the easy half. It finds groups of digits and knows
nothing else: 31/02/2024 matches it perfectly, and so does 29/02/2023. The
second half is what makes the difference, and it is a single call, because
`datetime.date` already owns the calendar — month lengths, leap years, and the
century rule that makes 1900 a common year.
What the digits cannot say is whether 03/04/2024 is 3 April or 4 March. The
document usually says it somewhere else: one date in it whose first field is
above twelve can only be day-first, and that settles every other date of the
same document. Failing that, the caller may know the locale of the sender.
Failing that too, the snippet abstains: the date comes back without a day
rather than with a guess in the shape of a fact.
"""
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
/**
* Extract dates from text: one regular expression per format, then a real
* calendar check.
*
* Rung N0. Deterministic, no dependency.
*
* The regular expression is the easy half. It finds groups of digits and knows
* nothing else: 31/02/2024 matches it perfectly, and so does 29/02/2023.
*
* The second half is what makes the difference, and in JavaScript it needs
* care: `new Date(2024, 1, 31)` does not fail, it quietly rolls over to
* 2 March. The only honest check is to build the date and read its parts back.
*
* What the digits cannot say is whether 03/04/2024 is 3 April or 4 March. The
* document usually says it somewhere else: one date in it whose first field is
* above twelve can only be day-first, and that settles every other date of the
* same document. Failing that, the caller may know the locale of the sender.
* Failing that too, the snippet abstains: the date comes back without a day
* rather than with a guess in the shape of a fact.
*/
// 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 }));
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
The two columns give both readings of the same passage. The snippet itself reads the convention off the document first — one date whose first field is above twelve fixes it for the whole document — and abstains when the document says nothing.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
3 matches
What the code picked up
Meeting on 12/03/2024, delivery on 3 April 2024, spec freeze 2024-03-01.
What it returns
| As written in the text | Read day-first | Read month-first |
|---|---|---|
| 12/03/2024 | March 12, 2024 | December 3, 2024 |
| 3 April 2024 | April 3, 2024 | April 3, 2024 |
| 2024-03-01 | March 1, 2024 | March 1, 2024 |
3 dates kept in this text.
Here it breaksSee the level above
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 document never leaves your infrastructure
Breaking point
Relative dates. "next Thursday", "in 15 days", "from tomorrow": there is nothing to match, and the failure is silent, an empty list rather than an error. Rung N1 is what reads those. Same silence on "5 March" with no year: that names a day, not a date.
When to move up a level
Your documents carry deadlines written relative to their own date — "within a fortnight", "in 15 days" — and the list N0 hands back is empty where a deadline stood.
The four options
- RecommendedN0One regular expression per format, calendar validation, convention read off the documentFree · Instant
- N1A mature date parser: dateparser in Python, chrono-node in JavaScriptFree · Instant
- N2Not worth it here
- N3Structured extraction through a general-purpose modelélevé · About a second
The other options
N1A mature date parser: dateparser in Python, chrono-node in JavaScriptFree · Instant
A mature date parser: dateparser in Python, chrono-node in JavaScript
Lightweight classic modelN1FreeInstant
Python
"""
Read the dates a pattern cannot: a mature date parser.
Rung N1. Rung N0 enumerates the shapes it knows, and a relative deadline has
no shape to enumerate: "dans 15 jours" has to be counted from a date. That
counting, in thirty-odd languages, is what a date parser library does.
`dateparser` here, `chrono-node` in JavaScript: local, free, deterministic,
and installed rather than written.
Two things have to be told to it that a pattern never needed. Which languages
the text may be in — a parser given every language it knows reads more, and
reads more wrongly. And which date to count from: `reference` has no default
here, on purpose, because the date to count from is the date of the document,
not the date of the run. "jeudi prochain" in an email received three weeks ago
is not next Thursday.
A snippet that hands the job to a library inherits the library, not a
specification: the two languages of this page do not have the same parser, and
they do not read the same things. On "3 janv. 2024" `dateparser` answers
3 January 2025 and `chrono-node` answers nothing, where rung N0 answers
3 January 2024 — which is why N0 stays underneath rather than beside. On
"jeudi prochain" they disagree too: 14 March for one, 21 March for the other.
"""
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
/**
* Read the dates a pattern cannot: a mature date parser.
*
* Rung N1. Rung N0 enumerates the shapes it knows, and a relative deadline has
* no shape to enumerate: "dans 15 jours" has to be counted from a date. That
* counting, in several languages, is what a date parser library does.
* `chrono-node` here, `dateparser` in Python: local, free, deterministic, and
* installed rather than written.
*
* Two things have to be told to it that a pattern never needed. Which languages
* the text may be in — `chrono` has one parser per language, and the French one
* reads nothing English. And which date to count from: `reference` has no
* default here, on purpose, because the date to count from is the date of the
* document, not the date of the run. "jeudi prochain" in an email received
* three weeks ago is not next Thursday.
*
* A snippet that hands the job to a library inherits the library, not a
* specification: the two languages of this page do not have the same parser, and
* they do not read the same things. On "3 janv. 2024" `dateparser` answers
* 3 January 2025 and `chrono-node` answers nothing, where rung N0 answers
* 3 January 2024 — which is why N0 stays underneath rather than beside. On
* "jeudi prochain" they disagree too: 14 March for one, 21 March for the other.
*/
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 }));
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- Processing of the documents on your own infrastructure: the library is local and opens no connection
- No corpus: nothing of the document is learnt or kept beyond the run
Breaking point
A spelled-out duration: "in a fortnight" returns nothing, where "in 15 days" returns 27 March. And the parser never abstains: on a bare 03/04/2024 it commits by its own rules, and the two libraries do not commit the same way — 4 March for dateparser, 3 April for chrono-node.
When to move up a level
Your deadlines are written in a way neither parser reads, and you can see it on a sample checked by hand.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A named-entity model, spaCy's for instance, labels a DATE span, "next Thursday" included, but hands back the span and its offsets, not the day it names: it settles neither 03/04/2024 nor "next Thursday". It adds weights to download to spot what N0's patterns already spot, and leaves untouched what the parser at N1 settles for nothing.
N3Structured extraction through a general-purpose modelélevé · About a second
Structured extraction through a general-purpose model
General-purpose LLM APIN3élevéAbout a second
Python
"""
Extract dates by asking a general-purpose model.
Rung N3. Its request asks for relative dates, "jeudi prochain", to be
resolved: it sends a reference date along with the text, and asks for every
date as a calendar day. Rung N1 resolves the same expressions locally and for
nothing; this rung is worth its price only on the ones a parser misses.
The reference is the date of the document, not the day of the run: "jeudi
prochain" in an email received three weeks ago is not next Thursday. It has no
default here, on purpose, because a default would quietly be today and a
backlog reprocessed on Monday would move every deadline.
The cap on the input raises rather than truncating: a contract cut in half
would come back with a list of deadlines that looks complete. What a caller
does above the cap is split the document into overlapping chunks and merge the
answers; this snippet does not, on purpose, because it shows one call.
Note what the code has to do that N0 did not: pass a reference date, because
the model is not told what day it is otherwise; cap the input size; retry on
failure; parse an answer that is only probably valid JSON; and check the
calendar itself, because nothing in the request stops the answer from holding
2024-02-31. That plumbing is the real cost of the rung, and it is the part the
tests have to cover, because the model itself is not 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
/**
* Extract dates by asking a general-purpose model.
*
* Rung N3. Its request asks for relative dates, "jeudi prochain", to be
* resolved: it sends a reference date along with the text, and asks for every
* date as a calendar day. Rung N1 resolves the same expressions locally and for
* nothing; this rung is worth its price only on the ones a parser misses.
*
* The reference is the date of the document, not the day of the run: "jeudi
* prochain" in an email received three weeks ago is not next Thursday. It has no
* default here, on purpose, because a default would quietly be today and a
* backlog reprocessed on Monday would move every deadline.
*
* The cap on the input raises rather than truncating: a contract cut in half
* would come back with a list of deadlines that looks complete. What a caller
* does above the cap is split the document into overlapping chunks and merge the
* answers; this snippet does not, on purpose, because it shows one call.
*
* Note what the code has to do that N0 did not: pass a reference date, because
* the model is not told what day it is otherwise; cap the input size; retry on
* failure; parse an answer that is only probably valid JSON; and check the
* calendar itself, because nothing in the request stops the answer from holding
* 2024-02-31. That plumbing is the real cost of the rung, and it is the part the
* tests have to cover, because the model itself is not 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));
}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
- High footprint
What this adds to your regulatory scope
- Transfer of the document to a processor, with the contractual framing that implies
- Processing location to be confirmed with the provider
- Does not excuse you from your own duty of minimisation: the whole document leaves, not just the dates
Breaking point
Fluency is not correctness. Nothing in the request stops the answer from carrying 2024-02-31 in flawless JSON, or a date that appears nowhere in the document. The calendar check from N0 has to stay, and it drops the impossible day; the date missing from the document gets through, and nothing in the answer marks it. An answer that is not a list of objects with a YYYY-MM-DD date — prose, a misnamed key, a date written 12/03/2024 — is asked again, then the snippet raises rather than returning an empty list, which would claim there was no date to find.
When to move up a level
There is no level above this one.
Further reading
- 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
Where the figures come from
- 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
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.