Documentation

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.

Schéma d'ensemble

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 →