日期抽取
讓 TypeSafe 讀出文件中提到的日期各部分,再在程式碼裡解析並校驗,並用置信度做複核,從而抽出絕對日期和相對日期。
用 TypeSafe 從文本里讀出日期的各個部分,再在程式碼裡把它們解析成一個 date。
你在這裡要構建的函式 extract_date(document, role),接收一個文件和一個
指稱你要的日期的短語(比如 “the deadline to return the form”),返回一個 date 及其置信度。它會標記出
置信度低的讀取,以及各部分根本拼不成一個日期的讀取 —— 包括文件從未提到的日期。日期既可以是
寫全的(“August 14, 2027”),也可以是相對今天來寫的(“tomorrow”、“next
Thursday”)。
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 Thursday” 可能指兩個不同的日子,所以由程式碼來決定是哪個。不帶
限定的星期幾,指今天之後(含今天)最近的那一個。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))
執行
四個短文件上的六個問題:一份寫明各年份的合同裡的兩個日期,一個沒寫年份的表單截止日,
一個 “今天” 結束的調查,一個定在 “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
合同把兩個年份都寫明瞭,所以它們直接從文本里讀出來。表單沒有寫年份, 於是程式碼補上了 2026:它取當前年份,只有當這個日期已經過去一個多月時才挪到下一年。 “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 中開啟這份文件和這些問題 →