日付抽出
日付抽出
文書に書かれた日付の各部分を TypeSafe に問い合わせ、コードで解決・検証し、信頼度に基づくレビューで絶対日付と相対日付を抽出します。
TypeSafe でテキストから日付の各部分を読み取り、コードで date に解決します。
ここで作る関数 extract_date(document, role) は、文書と、欲しい日付を指す語句(たとえば “the deadline to return the form”)を受け取り、date とその信頼度を返します。信頼度の低い読み取りと、各部分がそもそも日付として成立しない読み取りをフラグします。文書が一度も述べていない日付も含まれます。日付は明記することも(“August 14, 2027”)、今日を基準に相対的に書くこともできます(“tomorrow”、“next Thursday”)。
TypeSafe は、日付に関する Choice 質問に 1 回の呼び出しで答えます。どんな種類の日付か、そしてテキストがどの月・日・年・曜日を指しているかです。コードがそれらの答えを date に変換します。モデルはテキストに書かれたことを読むだけで、暦の計算は一切しません。
以下のセルは、その関数を 4 つの短い文書に対して実行し、各日付を信頼度とともに表示し、結果をコードが受け入れるものと人が目を通すべきものに分けます。
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,
)
質問
7 つの Choice 質問が 1 回の呼び出しで送られます。mode は日付がどう書かれているかを示します。absolute は月を明記した日付、relative は今日を基準に書かれた日付、none は文書がその日付をまったく述べていない場合です。
残りの 6 つは各部分を読み取ります。絶対日付には month、day、year が必要です。相対日付には day_anchor が必要です。今日、明日、明後日、または名前の付いた曜日です。曜日を明記する場合は、weekday と week_offset がどれを・どの週かを示します。コードは mode が要求する部分だけを読みます。
year は 1900 年から 2050 年までの各年を 1 つずつ選択肢にし、さらに 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 に変換します。テキストが年を述べていなければ年を補い、名前の付いた曜日がどの日を指すかを計算します。どちらも固定された 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” に締め切られるアンケート、“next Thursday” に設定されたレビュー、そしてフォームが一度も触れていない日付です。それらはすべて 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
契約書は 2 つの年をどちらも明記しているので、それらはテキストから読み取られました。フォームは年を述べていないので、コードが 2026 を補いました。現在の年を取り、その日付がすでに 1 か月以上過ぎている場合にだけ翌年に繰り上げます。“today” と “next Thursday” は、明記された日付と同じ関数を通りました。
キックオフの電話は、フォームが一度も触れていない日付です。そのフォームには日付がありますが、これではありません。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 Playground で開く
以下のリンクには、“next Thursday” のメッセージと、コードが送るのと同じ質問が含まれています。開くと、答えとその信頼度を確認でき、コードを一切書かずに文言を変更できます。
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})"
)
)
この文書と質問を TypeSafe Playground で開く →