날짜 추출
문서에 명시된 구성 요소를 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))
실행하기
네 개의 짧은 문서에 걸쳐 여섯 개의 질문입니다. 연도를 명시한 계약서에서 나온 날짜 두 개, 연도 없이 쓰인 양식 마감일, “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
계약서는 두 연도를 모두 명시하므로, 그것들은 텍스트에서 그대로 나왔습니다. 양식은 연도를 명시하지 않으므로, 코드가 2026을 채워 넣었습니다. 현재 연도를 취하고, 날짜가 이미 한 달 이상 지났을 때만 다음 해로 옮깁니다. “today”와 “next Thursday”는 풀어 쓴 날짜들과 같은 함수를 거쳤습니다.
kickoff 통화는 양식이 전혀 언급하지 않는 그 날짜입니다. 그 양식에는 날짜가 있지만, 이 날짜는 아닙니다. absolute date incomplete라는 note는 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에서 이 문서 + 질문 열기 →