Extraction de dates
Extrait des dates absolues et relatives en demandant à TypeSafe les parties nommées d’un document, puis les résout en code avec examen selon la confiance.
Lis les parties d’une date dans le texte avec TypeSafe, puis résous-les en un date en code.
La fonction que tu construis ici, extract_date(document, role), prend un document et une
phrase nommant la date que tu veux, comme « le délai de retour du formulaire », et renvoie
un date avec une confiance. Elle signale une lecture à faible confiance, et une dont les
parties ne forment pas du tout une date, y compris une date que le document n’énonce jamais.
La date peut être écrite en toutes lettres (« 14 août 2027 ») ou relative à aujourd’hui
(« demain », « jeudi prochain »).
TypeSafe répond à des questions Choice sur la date en un seul appel : de quel type de
date il s’agit, et quel mois, jour, année ou jour de la semaine le texte nomme. Le code
transforme ces réponses en un date. Le modèle lit ce que dit le texte et ne fait jamais
le calcul du calendrier.
Les cellules ci-dessous exécutent cette fonction sur quatre courts documents, affichent chaque date avec sa confiance, et répartissent les résultats entre ceux que le code accepte et ceux qu’une personne devrait regarder.
TypeSafe lit comment la date est écrite et quelles parties le texte nomme. Le code
transforme ces réponses en un date, en comptant à partir d’aujourd’hui quand la date est
relative, et soit l’accepte, soit l’envoie en examen.
Configuration
pip install ipython 'cooksafe>=0.2.0,<0.3.0'
puis définis TYPESAFE_API_KEY.
import os
from datetime import date, timedelta
from pathlib import Path
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
TODAY = date(
2026, 7, 30
) # fixed reference "today" so relative dates resolve reproducibly
REVIEW_BELOW = 0.60 # gate: a date below this confidence is flagged for a human
MONTHS = {
"January": 1,
"February": 2,
"March": 3,
"April": 4,
"May": 5,
"June": 6,
"July": 7,
"August": 8,
"September": 9,
"October": 10,
"November": 11,
"December": 12,
}
WEEKDAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
YEAR_WINDOW = list(range(1900, 2051)) # 1900..2050
# Cached to json_cache.json (shipped with the cookbook, so re-rendering replays the published
# results with no API spend); delete it to re-run live.
json_cache = JsonCache(Path("json_cache.json"))
# The demo cells below run when this file is executed as the cookbook; the constants and the pure
# resolve/assemble code stay importable, so the calendar math can be unit-tested on its own.
if __name__ == "__cookbook__":
client = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # cached re-renders need no key
base_url=os.environ.get("TYPESAFE_BASE_URL"),
timeout=30.0,
)
Les questions
Sept questions Choice partent dans un seul appel. mode dit comment la date est écrite :
absolute
pour une date qui nomme un mois, relative pour une écrite par rapport à aujourd’hui, et
none quand le document n’énonce pas du tout la date.
Les six autres lisent les morceaux. Une date absolue a besoin de month, day et year.
Une relative a besoin de day_anchor : aujourd’hui, demain, après-demain, ou un jour de
semaine nommé. Quand elle nomme un jour de semaine, weekday et week_offset disent lequel
et quelle semaine. Le code ne lit que les morceaux que mode réclame.
year liste une option par année de 1900 à 2050, plus deux échappatoires. none signifie
que le texte n’indique aucune année et que le code en remplit une. out_of_range signifie
que le texte indique une année hors de la liste, et le code le signale au lieu de deviner.
Si une liste aussi longue te gêne, extrais d’abord du texte les nombres qui ressemblent à
des années et n’offre au modèle que ceux-là.
def date_questions(role: str) -> dict[str, Choice]:
"""Seven typed choices that read a date's shape and parts off the text -- no math."""
absent = "The document does not state this, or it is not this kind of date."
return {
"mode": Choice(
instructions=(
f"How is {role} written? 'absolute' = a calendar date naming a month (e.g. "
"'August 14', 'the 3rd of March'); 'relative' = given relative to today (today, "
"tomorrow, the day after tomorrow, or a named weekday such as 'next Thursday'); "
"'none' = the document does not state this date."
),
criteria={"absolute": None, "relative": None, "none": None},
),
"month": Choice(
instructions=f"If {role} is an absolute calendar date, which month is it in?",
criteria={m: None for m in MONTHS} | {"none": absent},
),
"day": Choice(
instructions=f"If {role} is an absolute calendar date, which day of the month (1-31)?",
criteria={str(d): None for d in range(1, 32)} | {"none": absent},
),
"year": Choice(
instructions=(
f"If {role} is an absolute calendar date, which year? Pick 'none' if the document "
"states no year (code infers it), or 'out_of_range' if a year is stated but not "
"in the list."
),
criteria={str(y): None for y in YEAR_WINDOW}
| {
"out_of_range": "A year is stated for this date but is outside the listed range.",
"none": "No year is stated for this date.",
},
),
"day_anchor": Choice(
instructions=(
f"If {role} is relative to today, which day is it? 'today', 'tomorrow', "
"'day_after' (the day after tomorrow), or 'weekday' (a named day of the week)."
),
criteria={
"today": None,
"tomorrow": None,
"day_after": None,
"weekday": None,
"none": absent,
},
),
"weekday": Choice(
instructions=f"If {role} names a day of the week, which one?",
criteria={w: None for w in WEEKDAYS} | {"none": absent},
),
"week_offset": Choice(
instructions=(
f"If {role} names a weekday, which week is it in? 'next' for 'next Thursday' or "
"'Thursday next week'; 'current' for 'this Thursday'; 'none' for a bare weekday "
"with no qualifier (just 'Thursday' / 'on Thursday')."
),
criteria={"current": None, "next": None, "none": absent},
),
}
Résous-le en code
read_parts fait l’appel. assemble transforme les réponses en un date : elle remplit
l’année quand le texte n’en indique aucune, et détermine vers quel jour pointe un jour de
semaine nommé. Les deux comptent à partir de TODAY, qui est figé pour que les dates
relatives sortent identiques à chaque exécution. assemble rapporte aussi la confiance la
plus basse parmi les parties qu’elle a utilisées, donc une réponse faible sur une seule partie peut envoyer toute la date en examen.
« jeudi prochain » peut désigner deux jours différents, donc le code décide lequel. Un
jour de semaine sans qualificatif signifie le prochain à partir d’aujourd’hui. next
signifie la semaine calendaire suivante, et current signifie cette semaine.
@json_cache
def read_parts(document: str, role: str) -> dict:
"""One TypeSafe call -> {part: {choice, confidence}} for the seven questions."""
answers = client.system_one(
state=document, questions=date_questions(role), model=TYPESAFE_MODEL
).answers
return {
part: {"choice": ans.choice, "confidence": ans.confidence}
for part, ans in answers.items()
}
def resolve_weekday(today: date, weekday: str, week_offset: str) -> date:
"""Which date a named weekday points to, by our stated convention: a bare weekday is the next
occurrence on or after today; 'next' is the following calendar week; 'current' is this week."""
w = WEEKDAYS.index(weekday)
this_monday = today - timedelta(days=today.weekday())
if week_offset == "next":
return this_monday + timedelta(days=7 + w)
if week_offset == "current":
return this_monday + timedelta(days=w)
return today + timedelta(days=(w - today.weekday()) % 7)
def assemble(parts: dict, today: date = TODAY) -> dict:
"""Resolve the parts TypeSafe read into a concrete date, in code. Confidence is the weakest of
the parts the shape actually used."""
mode = parts["mode"]["choice"]
confs = [parts["mode"]["confidence"]]
def result(resolved: date | None, note: str) -> dict:
usable = [c for c in confs if c is not None]
confidence = min(usable) if usable else None
needs_review = (
resolved is None or confidence is None or confidence < REVIEW_BELOW
)
return {
"date": resolved,
"confidence": confidence,
"needs_review": needs_review,
"note": note,
}
if mode == "none":
return result(None, "no such date stated")
if mode == "absolute":
month, day, year = (
parts["month"]["choice"],
parts["day"]["choice"],
parts["year"]["choice"],
)
confs += [
parts["month"]["confidence"],
parts["day"]["confidence"],
parts["year"]["confidence"],
]
if "none" in (month, day) or not day.isdigit() or month not in MONTHS:
return result(None, "absolute date incomplete")
if (
year == "out_of_range"
): # a year is stated but off the list -> flag, don't guess
return result(None, f"year outside {YEAR_WINDOW[0]}-{YEAR_WINDOW[-1]}")
if (
year == "none"
): # no year stated -> infer this year, bumped to next if well past
try:
resolved = date(today.year, MONTHS[month], int(day))
except (
ValueError
): # e.g. February 30 -- an inconsistent read, not a real date
return result(None, f"impossible date: {month} {day}")
if resolved < today - timedelta(days=31):
resolved = date(today.year + 1, MONTHS[month], int(day))
return result(resolved, "")
try: # a stated, in-range year
return result(date(int(year), MONTHS[month], int(day)), "")
except ValueError:
return result(None, f"impossible date: {year}-{month}-{day}")
if mode == "relative":
anchor = parts["day_anchor"]["choice"]
confs.append(parts["day_anchor"]["confidence"])
if anchor == "today":
return result(today, "")
if anchor == "tomorrow":
return result(today + timedelta(days=1), "")
if anchor == "day_after":
return result(today + timedelta(days=2), "")
if anchor == "weekday":
weekday, offset = parts["weekday"]["choice"], parts["week_offset"]["choice"]
confs += [
parts["weekday"]["confidence"],
parts["week_offset"]["confidence"],
]
if weekday not in WEEKDAYS:
return result(None, "relative weekday not read")
return result(resolve_weekday(today, weekday, offset), "")
return result(None, "relative day not read")
return result(None, f"unrecognized mode: {mode}")
def extract_date(document: str, role: str) -> dict:
return assemble(read_parts(document, role))
Exécute-le
Six questions sur quatre courts documents : deux dates d’un contrat qui indique ses années,
un délai de formulaire écrit sans année, une enquête qui ferme « aujourd’hui », une revue
fixée à « jeudi prochain », et une date que le formulaire ne mentionne jamais. Toutes se
résolvent par rapport à TODAY = 2026-07-30, un jeudi.
CONTRACT = "This agreement is effective January 1, 2025 and expires December 31, 2027."
FORM = "Please return the signed form by August 14."
SURVEY = "Heads up - the customer survey closes today at 5pm."
REVIEW = "Let's schedule the design review for next Thursday."
# (document, question phrase, expected date) -- the expected value is only for the scorecard.
EXAMPLES = [
(CONTRACT, "the date the agreement takes effect", date(2025, 1, 1)),
(CONTRACT, "the date the agreement expires", date(2027, 12, 31)),
(FORM, "the deadline to return the form", date(2026, 8, 14)),
(FORM, "the date of the kickoff call", None),
(SURVEY, "the date the survey closes", date(2026, 7, 30)),
(REVIEW, "the date of the design review", date(2026, 8, 6)),
]
if __name__ == "__cookbook__":
print(f"{'':3}{'question':<38}{'expected':<12}{'got':<12}{'conf':>6} flags")
print("-" * 84)
for document, role, expected in EXAMPLES:
r = extract_date(document, role)
got = r["date"].isoformat() if r["date"] else "none"
exp = expected.isoformat() if expected else "none"
mark = "OK" if r["date"] == expected else "XX"
conf = f"{r['confidence']:.2f}" if r["confidence"] is not None else " n/a"
flags = " <== review" if r["needs_review"] else ""
if r["note"]:
flags += f" ({r['note']})"
print(f"{mark:<3}{role:<38}{exp:<12}{got:<12}{conf:>6}{flags}")
question expected got conf flags
------------------------------------------------------------------------------------
OK the date the agreement takes effect 2025-01-01 2025-01-01 0.97
OK the date the agreement expires 2027-12-31 2027-12-31 0.91
OK the deadline to return the form 2026-08-14 2026-08-14 0.95
OK the date of the kickoff call none none 0.46 <== review (absolute date incomplete)
OK the date the survey closes 2026-07-30 2026-07-30 0.94
OK the date of the design review 2026-08-06 2026-08-06 0.92
Le contrat indique ses deux années, donc celles-ci viennent du texte. Le formulaire n’indique aucune année, donc le code a rempli 2026 : il prend l’année courante et ne passe à la suivante que lorsque la date est déjà passée de plus d’un mois. « aujourd’hui » et « jeudi prochain » sont passés par la même fonction que les dates écrites en toutes lettres.
L’appel de lancement est celui que le formulaire ne mentionne jamais. Il y a une date dans
ce formulaire, mais pas celle-ci, et la note absolute date incomplete signifie que mode
est revenu absolute sans mois pour l’accompagner. La date est revenue vide, la confiance
affiche 0,46, et la ligne est signalée pour une personne.
La confiance pour router
Chaque réponse revient avec une confiance calibrée, et la confiance d’une date est la plus
basse parmi les parties qui la composent. Une date sous REVIEW_BELOW = 0.60 part vers une
personne, et une date que le code n’a pas pu assembler du tout aussi. Le reste passe directement.
if __name__ == "__cookbook__":
confident = [
(doc, role)
for doc, role, _ in EXAMPLES
if not extract_date(doc, role)["needs_review"]
]
review = [
(doc, role)
for doc, role, _ in EXAMPLES
if extract_date(doc, role)["needs_review"]
]
print(f"auto-accept ({len(confident)}):")
for _doc, role in confident:
print(f" - {role}")
print(f"\nsend to review ({len(review)}):")
for _doc, role in review:
r = extract_date(_doc, role)
print(
f" - {role} (conf {r['confidence']:.2f} / {r['note'] or 'low confidence'})"
)
auto-accept (5):
- the date the agreement takes effect
- the date the agreement expires
- the deadline to return the form
- the date the survey closes
- the date of the design review
send to review (1):
- the date of the kickoff call (conf 0.46 / absolute date incomplete)
Ouvre-le dans le playground TypeSafe
Le lien ci-dessous porte le message « jeudi prochain » et les mêmes questions que le code envoie. Ouvre-le pour voir les réponses et leurs confiances, et pour changer la formulation sans écrire une ligne de code.
if __name__ == "__cookbook__":
playground_link = make_playground_link(
REVIEW, date_questions("the date of the design review"), models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open this document + questions in the TypeSafe playground]({playground_link})"
)
)
Ouvre ce document + les questions dans le playground TypeSafe →