日付の抽出
TypeSafeに対して文書内の指定された部分名を問い合わせて絶対日付および相対日付を抽出し、その後コード内で信頼度に基づくレビューを通じてそれらの解決と検証を行う。
本文はenからの機械翻訳です。校正は未実施で、参考情報としてのみご利用ください。

TypeSafeを使ってテキストから日付の各要素を読み取り、コード内でdateに解決します。
ここで構築する関数 extract_date(document, role) は、ドキュメントと、希望する日付を指定するフレーズ(例:「書類を返却する期限」)を受け取り、信頼度スコア付きの date を返します。信頼度が低い読み取り結果や、日付そのものを構成しない部分(ドキュメントに日付が記載されていない場合など)をフラグとして検出します。日付は「2027年8月14日」のように表記される場合もあれば、「明日」「来週木曜日」のように今日を基準とした相対的な表現の場合もあります。
TypeSafeは、1回の呼び出しでChoiceの日付に関する質問に答えます:それがどのような日付なのか、
そして
テキストで名前が指定されている月、日、年、または曜日です。コードはこれらの回答をdateに変換します。
モデルはテキストの内容を読み取り、カレンダーの計算は決して行いません。
以下のセルでは、その関数を4つの短いドキュメントに対して実行し、各日付とその信頼度を出力し、結果をコードが受け入れるものと人が確認すべきものに分割します。
TypeSafeは日付の表記方法と、テキストがどの部分を指しているかを読み取ります。コードはこれらの回答をdateに変換し、日付が相対的な場合は今日からカウントして、受け入れるかレビューに回すかします。
セットアップ
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
その後、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,
)
質問
7つのChoiceの質問が1回の呼び出しで送信されます。modeは日付の表記方法を示します:
absolute
月名を明記する日付の場合、relativeは今日を基準とした相対的な表記の場合、noneは文書に日付が一切記載されていない場合。
他の6名が文章を読む。絶対日付にはmonth、day、yearが必要だ。相対日付にはday_anchorが必要で、今日、明日、明後日、または名付けられた曜日を示す。曜日を指定する場合、weekdayとweek_offsetがどの曜日か、そしてどの週かを示す。コードはmodeが要求する部分のみを読み取る。
yearは1900年から2050年までの各年のオプションと、2つのエスケープをリストします。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に変換する:テキストに年が記載されていない場合はそれを補完し、指定された曜日がどの日付を指すかを算出する。これら2つはTODAYから数えられ、TODAYは固定されているため、相対日付は実行ごとに同じ結果になる。assembleはまた、使用した部分の中で最も低い信頼度も報告するため、1つの部分でも回答が曖昧であれば、日付全体が再検討対象となる可能性がある。
「next Thursday」は2つの異なる日を指す可能性があるため、コードがどちらかを選択します。修飾子のない平日は、今日以降の最初のものを意味します。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))
実行する
4つの短い文書にわたる6つの質問:年数を明記する契約書からの2つの日付、年を記載せずに書かれたフォームの締切、「本日」で締め切られるアンケート、「来週木曜日」に設定されたレビュー、そしてフォームで一切言及されない日付。これらすべては、木曜日である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年を埋め込んだ:これは現在の年を取得し、日付がすでに1ヶ月以上経過している場合にのみ翌年に移動する。「今日」や「来週木曜日」も、スペルで書かれた日付と同じ関数を通った。
キックオフの電話は、フォームが一切言及しないものだ。そのフォームには日付が記載されているが、このフォームにはない。注釈 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)
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})"
)
)