Извлечение дат
Извлекает абсолютные и относительные даты, запрашивая у TypeSafe части, названные в документе, а затем разрешает и валидирует их в коде с проверкой по уверенности.
Прочитайте части даты из текста с помощью TypeSafe, а затем разрешите их в date в коде.
Функция, которую вы здесь строите, extract_date(document, role), принимает документ и
фразу, называющую нужную вам дату, например «срок возврата формы», и возвращает date с
уверенностью. Она помечает чтение с низкой уверенностью и такое, чьи части вообще не
складываются в дату, в том числе дату, которую документ никогда не называет. Дата может
быть выписана полностью («14 августа 2027 года») или задана относительно сегодняшнего дня
(«завтра», «в следующий четверг»).
TypeSafe отвечает на вопросы Choice о дате за один вызов: какого рода эта дата и какой
месяц, день, год или день недели называет текст. Код превращает эти ответы в date.
Модель читает то, что говорит текст, и никогда не делает календарную арифметику.
Ячейки ниже прогоняют эту функцию по четырём коротким документам, печатают каждую дату с её уверенностью и делят результаты на те, что код принимает, и те, на которые стоит взглянуть человеку.
TypeSafe читает, как записана дата и какие части называет текст. Код превращает эти
ответы в date, отсчитывая от сегодняшнего дня, когда дата относительная, и либо
принимает её, либо отправляет на проверку.
Установка
pip install ipython 'cooksafe>=0.2.0,<0.3.0'
затем задайте 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,
)
Вопросы
Семь вопросов Choice уходят за один вызов. mode говорит, как записана дата: absolute
для даты, называющей месяц, relative для записанной относительно сегодняшнего дня, и
none, когда документ вообще не называет дату.
Остальные шесть читают части. Абсолютной дате нужны month, day и year. Относительной
нужен day_anchor: сегодня, завтра, послезавтра или названный день недели. Когда он
называет день недели, weekday и week_offset говорят, какой именно и какая неделя. Код
читает только те части, которые запрашивает mode.
year перечисляет по одному варианту на каждый год с 1900 по 2050, плюс два выхода. none
означает, что текст не называет год и код подставляет его. out_of_range означает, что
текст называет год вне списка, и код помечает это вместо догадки. Если такой длинный список
вас смущает, сначала вытащите из текста похожие на годы числа и предложите модели только их.
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},
),
}
Разрешение в коде
read_parts делает вызов. assemble превращает ответы в date: он подставляет год, когда
текст его не называет, и вычисляет, на какой день указывает названный день недели. И то и
другое отсчитывается от TODAY, который зафиксирован, чтобы относительные даты выходили
одинаковыми при каждом запуске. assemble также сообщает наименьшую уверенность среди
использованных частей, поэтому слабый ответ по любой одной части может отправить всю дату
на проверку.
«в следующий четверг» может означать два разных дня, поэтому код решает, какой. День
недели без уточнения означает ближайший на сегодня или после. next означает следующую
календарную неделю, а current — текущую.
@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))
Запуск
Шесть вопросов по четырём коротким документам: две даты из договора, который называет свои
годы, срок формы, записанный без года, опрос, закрывающийся «сегодня», проверка, назначенная
на «следующий четверг», и дата, которую форма вообще не упоминает. Все они разрешаются
относительно TODAY = 2026-07-30, четверга.
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
Договор называет оба своих года, поэтому они взяты из текста. Форма не называет год, поэтому код подставил 2026: он берёт текущий год и переходит к следующему только тогда, когда дата уже прошла больше чем на месяц. «сегодня» и «следующий четверг» прошли через ту же функцию, что и выписанные даты.
Вступительный звонок — тот, который форма вообще не упоминает. В этой форме есть дата,
просто не эта, а примечание absolute date incomplete означает, что mode вернулся как
absolute без месяца к нему. Дата вернулась пустой, уверенность читается как 0.46, и
строка помечена для человека.
Уверенность для маршрутизации
Каждый ответ возвращается с калиброванной уверенностью, и уверенность даты — это наименьшая
среди частей, из которых она собрана. Дата ниже REVIEW_BELOW = 0.60 уходит человеку, как
и дата, которую код вообще не смог собрать. Остальные проходят сразу.
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)
Открыть в playground TypeSafe
Ссылка ниже несёт сообщение «следующий четверг» и те же вопросы, что отправляет код. Откройте её, чтобы увидеть ответы и их уверенности и изменить формулировку, не написав ни строчки кода.
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})"
)
)
Открыть этот документ и вопросы в playground TypeSafe →