Dokumentation

Datumsextraktion

Extrahiert absolute und relative Datumsangaben, indem TypeSafe nach den im Dokument genannten Teilen gefragt wird; der Code setzt sie zusammen und prüft sie mit konfidenzbasierter Nachprüfung.

Lies die Teile eines Datums mit TypeSafe aus dem Text und setze sie im Code zu einem date zusammen.

Die Funktion, die du hier baust, extract_date(document, role), nimmt ein Dokument und eine Phrase, die das gewünschte Datum benennt, etwa „die Frist zur Rücksendung des Formulars“, und gibt ein date samt Konfidenz zurück. Sie markiert einen konfidenzarmen Lesevorgang und einen, dessen Teile sich gar nicht zu einem Datum zusammensetzen, auch ein Datum, das das Dokument nie nennt. Das Datum kann ausgeschrieben sein („14. August 2027“) oder relativ zu heute geschrieben („morgen“, „nächsten Donnerstag“).

TypeSafe beantwortet Choice-Fragen zum Datum in einem Aufruf: welche Art von Datum es ist und welchen Monat, Tag, Jahr oder Wochentag der Text nennt. Der Code verwandelt diese Antworten in ein date. Das Modell liest, was der Text sagt, und rechnet nie selbst am Kalender.

Die Zellen unten führen diese Funktion über vier kurze Dokumente aus, geben jedes Datum mit seiner Konfidenz aus und teilen die Ergebnisse in die auf, die der Code akzeptiert, und die, die ein Mensch prüfen sollte.

Übersichtsdiagramm

TypeSafe liest, wie das Datum geschrieben ist und welche Teile der Text nennt. Der Code verwandelt diese Antworten in ein date, rechnet bei einem relativen Datum von heute aus und akzeptiert es entweder oder schickt es zur Prüfung.

Einrichtung

pip install ipython 'cooksafe>=0.2.0,<0.3.0'

Lege dann TYPESAFE_API_KEY fest.

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

Die Fragen

Sieben Choice-Fragen gehen in einem Aufruf hinaus. mode sagt, wie das Datum geschrieben ist: absolute für ein Datum, das einen Monat nennt, relative für eines, das relativ zu heute geschrieben ist, und none, wenn das Dokument das Datum gar nicht angibt.

Die anderen sechs lesen die Teile. Ein absolutes Datum braucht month, day und year. Ein relatives braucht day_anchor: heute, morgen, den Tag danach oder einen benannten Wochentag. Wenn es einen Wochentag nennt, sagen weekday und week_offset, welchen und welche Woche. Der Code liest nur die Teile, die mode verlangt.

year listet eine Option pro Jahr von 1900 bis 2050 auf, plus zwei Auswege. none bedeutet, der Text nennt kein Jahr und der Code trägt eines ein. out_of_range bedeutet, der Text nennt ein Jahr außerhalb der Liste, und der Code markiert das, statt zu raten. Wenn dich eine so lange Liste stört, zieh die jahresartigen Zahlen zuerst aus dem Text und biete dem Modell nur diese an.

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

Im Code auflösen

read_parts macht den Aufruf. assemble verwandelt die Antworten in ein date: Es trägt das Jahr ein, wenn der Text keines nennt, und ermittelt, auf welchen Tag ein benannter Wochentag zeigt. Beides rechnet von TODAY aus, das festgelegt ist, damit relative Datumsangaben bei jedem Durchlauf gleich herauskommen. assemble meldet außerdem die niedrigste Konfidenz unter den verwendeten Teilen, sodass eine schwache Antwort bei einem einzelnen Teil das ganze Datum zur Prüfung schicken kann.

„nächsten Donnerstag“ kann zwei verschiedene Tage bedeuten, also entscheidet der Code, welchen. Ein Wochentag ohne Zusatz bedeutet den nächsten ab heute. next bedeutet die folgende Kalenderwoche, und current bedeutet diese Woche.

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

Ausführen

Sechs Fragen über vier kurze Dokumente: zwei Datumsangaben aus einem Vertrag, der seine Jahre nennt, eine Frist in einem Formular ohne Jahr, eine Umfrage, die „heute“ schließt, ein Review, angesetzt für „nächsten Donnerstag“, und ein Datum, das das Formular nie erwähnt. Alle lösen sich gegen TODAY = 2026-07-30 auf, einen Donnerstag.

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

Der Vertrag nennt beide Jahre, also stammen die aus dem Text. Das Formular nennt kein Jahr, also hat der Code 2026 eingetragen: Er nimmt das aktuelle Jahr und geht nur dann zum nächsten über, wenn das Datum bereits mehr als einen Monat zurückliegt. „heute“ und „nächsten Donnerstag“ liefen durch dieselbe Funktion wie die ausgeschriebenen Daten.

Der Kickoff-Call ist der, den das Formular nie erwähnt. Es gibt ein Datum in diesem Formular, nur nicht dieses, und der Hinweis absolute date incomplete bedeutet, dass mode als absolute zurückkam, ohne dass ein Monat dazugehörte. Das Datum kam leer zurück, die Konfidenz liegt bei 0.46, und die Zeile wird für einen Menschen markiert.

Konfidenz für die Weiterleitung

Jede Antwort kommt mit einer kalibrierten Konfidenz zurück, und die Konfidenz eines Datums ist die niedrigste unter den Teilen, die in es eingegangen sind. Ein Datum unter REVIEW_BELOW = 0.60 geht an einen Menschen, und ebenso eines, das der Code gar nicht zusammensetzen konnte. Der Rest geht direkt durch.

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)

Im TypeSafe-Playground öffnen

Der Link unten trägt die Nachricht „nächsten Donnerstag“ und dieselben Fragen, die der Code sendet. Öffne ihn, um die Antworten und ihre Konfidenzen zu sehen und den Wortlaut zu ändern, ohne Code zu schreiben.

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})"
        )
    )
Öffne dieses Dokument + die Fragen im TypeSafe-Playground →