---
title: "Cookbook: Date extraction"
type: cookbook
tags: [cookbook, date-extraction, choice, confidence, parsing]
created: 2026-09-17
updated: 2026-09-17
confidence: high
sources:
  - raw/docs/cookbooks__date_extraction_cookbook.md
jev_version: "jev-1.13.0"
sdk_python: "0.6.0"
summary: "Seven Choice questions read a date's parts off a document in one call; code does all the calendar math and gates on the weakest part's confidence."
---

# Cookbook: Date extraction

> **TL;DR** Ask seven `Choice` questions in one request (`mode`, `month`, `day`, `year`, `day_anchor`, `weekday`, `week_offset`) that read only what the text *says*. Code turns the answers into a `date` — filling in a missing year, resolving "next Thursday" against a pinned `TODAY`. The date's confidence is the **minimum** across the parts actually used; below `REVIEW_BELOW = 0.60`, or if the parts don't assemble, send it to a human. **The model never does calendar arithmetic.**

## Goal

Build `extract_date(document, role)`: given a document and a phrase naming which date you want ("the deadline to return the form"), return a `date` plus a confidence. It has to handle spelled-out dates ("August 14, 2027"), relative dates ("tomorrow", "next Thursday"), and the case where the document never states the date at all.

The split of labour is the point: "TypeSafe answers `Choice` questions about the date in one call: what kind of date it is, and which month, day, year, or weekday the text names. Code turns those answers into a `date`. The model reads what the text says and never does the calendar math."

## Inputs / state shape

The state is **the raw document string** — not a dict:

```python
client.system_one(state=document, questions=date_questions(role), model=TYPESAFE_MODEL)
```

The `role` phrase is interpolated into every question's `instructions`, so the same document can be queried for several different dates.

Constants, verbatim:

```python
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
```

The four demo documents:

```python
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."
```

## Questions asked

All seven, verbatim. Note the shared `absent` string used as the `none` option's description:

```python
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},
        ),
    }
```

`criteria` values of `None` mean the option name carries the whole meaning — no description needed. `Choice.criteria` is a dict in both 0.5.7 and 0.6.0; only `Score.criteria` changed.

The cookbook's note on the 151-option `year` question: "`year` lists one option per year from 1900 to 2050, plus two escapes. `none` means the text states no year and code fills one in. `out_of_range` means the text states a year outside the list, and code flags that instead of guessing. If a list that long bothers you, pull the year-like numbers out of the text first and offer the model only those."

## Combining logic in code

```python
import os
from datetime import date, timedelta
from typesafe_sdk import Choice, TypeSafeClient

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),
    base_url=os.environ.get("TYPESAFE_BASE_URL"),
    timeout=30.0,
)


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))
```

Three rules live entirely in code and never reach the model: the missing-year rule ("take the current year and move to the next one only when the date is already more than a month past" — the `today - timedelta(days=31)` test), the weekday convention ("a bare weekday is the next occurrence on or after today; `next` is the following calendar week; `current` is this week"), and the min-confidence aggregation.

## Results the cookbook reports

Numbers came from `jev-1.12`, with `TODAY` pinned to 2026-07-30 (a Thursday). Six extractions across four documents:

| question | expected | got | conf | flags |
|---|---|---|---|---|
| the date the agreement takes effect | 2025-01-01 | 2025-01-01 | 0.97 | |
| the date the agreement expires | 2027-12-31 | 2027-12-31 | 0.91 | |
| the deadline to return the form | 2026-08-14 | 2026-08-14 | 0.95 | |
| the date of the kickoff call | none | none | 0.46 | review (`absolute date incomplete`) |
| the date the survey closes | 2026-07-30 | 2026-07-30 | 0.94 | |
| the date of the design review | 2026-08-06 | 2026-08-06 | 0.92 | |

All six matched the expected value; five auto-accepted, one went to review. Notes from the cookbook: the contract states both of its years, so those came off the text; the form states no year, so code filled in 2026; "today" and "next Thursday" went through the same function as the spelled-out dates.

The kickoff call is the date the form never mentions: "There is a date in that form, just not this one, and the note `absolute date incomplete` means `mode` came back `absolute` with no month to go with it. The date came back empty, the confidence reads 0.46, and the row is flagged for a person."

## Adapting it to a new domain

- Change `role` strings, not the questions. The question set is date-generic; the phrase you pass is how you name which date you want out of a document that may hold several.
- Set `TODAY = date.today()` in production. The cookbook pins it only for reproducible output.
- Shrink `YEAR_WINDOW` (or pre-extract year-like tokens with a regex and offer only those) if the 151-option Choice is more than you want to send.
- Change the weekday convention by editing `resolve_weekday()` — the model's `week_offset` answer only reports the qualifier the text used (`current` / `next` / `none`); what those mean is yours.
- Adjust `REVIEW_BELOW` after measuring on your own documents; `0.60` here is a starting point.
- Adding a time-of-day would mean more Choice questions of the same shape (hour, minute, am/pm) resolved the same way in code (inferred).

## Gotchas

- **`cooksafe` is not publicly installable.** Install line: `pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/`. `cooksafe` is a TypeSafe helper package on a private index, and `pypi.typesafe.ai` returned 404 publicly on 2026-09-17. Use `pip install typesafe-sdk` and reimplement what you need: `JsonCache(Path("json_cache.json"))` is a decorator that memoizes JSON-serializable return values to a file keyed by call arguments (used as `@json_cache` on `read_parts`); `make_playground_link(state, questions, models=[...])` builds a `console.typesafe.ai/playground#share/...` URL.
- **`base_url` env var differs across cookbooks.** This one reads `TYPESAFE_BASE_URL`; several others read `TYPESAFE_ENDPOINT`. Check [[reference/environment-variables]] for what the SDK actually honours.
- **Irrelevant questions still get answered.** Every request asks all seven questions; for an absolute date, `day_anchor`/`weekday`/`week_offset` come back with something. `assemble()` reads only the parts `mode` calls for, and — importantly — only aggregates confidence over those parts. Including irrelevant confidences would poison the gate.
- **Min-confidence is deliberately pessimistic.** "a weak answer on any one part can send the whole date to review."
- **`date()` can still raise.** February 30 is a perfectly consistent set of part answers and an impossible date; both `try/except ValueError` blocks exist for that.
- **`__name__ == "__cookbook__"` guard.** The upstream file runs its demo cells only under the cookbook runner, keeping the constants and pure `resolve`/`assemble` code importable and unit-testable. That guard is a notebook artifact, not SDK behaviour.
- **Model pinning.** Numbers are `jev-1.12`; `jev-latest` now resolves to `jev-1.13.0`.

## Related

- [[cookbooks/overview]] — the cookbook index
- [[cookbooks/pre-parsed-value-extraction]] — the sibling "parse in code, judge with Jev" recipe
- [[concepts/choice]] — options, `criteria`, `confidence`
- [[concepts/confidence]] — what the gate is actually measuring
- [[patterns/confidence-routing]] — auto-accept vs. human review
- [[patterns/fan-out]] — many questions, one request
- [[guides/writing-instructions-and-criteria]] — wording options the model reads correctly

## Sources

- raw/docs/cookbooks__date_extraction_cookbook.md (https://docs.typesafe.ai/cookbooks/date_extraction_cookbook)
