第 23 章:日期抽取
第 23 章:日期抽取
本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/04_实战指南/14_日期抽取.ipynb。
针对官方文档 实战指南 · Date Extraction 的可运行实验笔记, 用真实 TypeSafe API(Jev 模型)复刻核心流程并中文化。中文翻译版见 datawhalechina.github.io/jev-cookbook。
笔记本结构
| 章节 | 内容 | 实验 |
|---|---|---|
| 0. 准备 | 安装、客户端、连通性、离线回退 | — |
| 📖 理论速览 | 模型只读文本;日历算术在代码里 | — |
| 1. 定义问题 | mode / month / day / year / weekday 等 Choice | 1 组问题工厂 |
| 2. 解析与门控 | assemble → date;REVIEW_BELOW=0.60 | 纯代码 |
| 3. 中文文档跑通 | 绝对 / 相对 / 缺失日期 | 约 4 次 API 调用 |
每个主题按固定节奏展开:原理 → 理论根基 → 定义数据 → 定义问题 → 调用 → 解读结果, 每个单元格只做一件事,可直接顺着跑完(约 4 次 API 调用)。
运行要求
- Python ≥ 3.10(官方 SDK 要求;macOS 系统自带 python3 是 3.9,装不上 SDK)
- 一个 TypeSafe API Key(console.typesafe.ai/keys 获取)
推荐:一键创建本地环境(在本 notebooks 目录下)
./setup_env.sh # 创建 .venv:Python 3.12 + 全部依赖
export TYPESAFE_API_KEY=你的key
.venv/bin/jupyter lab <本文件>.ipynb或者手动创建:python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
🔑 API Key 安全提示:本笔记从环境变量
TYPESAFE_API_KEY读取密钥, 不要把 Key 硬编码进笔记本(尤其打算提交到公开仓库时)。🈶 关于语言:实验全部使用中文
state与中文提示词。三种原语的选项 key (如billing、verified)属于代码标识符,保持英文以便代码分支判断; 它们的描述文字(criteria 值)均为中文,模型据此理解语义。
0. 准备
0.1 安装所需的库
如果已经用 ./setup_env.sh 创建过环境,本节通常显示“依赖已满足”;在其他环境里首次运行时会自动安装。
%pip install -q -U typesafe-sdk # 本笔记本必需(要求 Python ≥ 3.10)
# %pip install -q -U jupyterlab # 如本机还没有 Jupyter,取消注释运行一次
# %pip install -q -U nbformat nbclient # 仅在需要重新生成/批量执行笔记本时安装0.2 导入库并创建客户端
Choice/Score/Noul:三种问题原语的构造器(对应官方文档原语一章);TypeSafeClient:同步客户端,model="jev-latest"表示使用官方旗舰模型的最新别名;- Key 从环境变量
TYPESAFE_API_KEY读取;临时调试也可以直接赋值给API_KEY(不要提交)。
import os
import time
from typesafe_sdk import (
Choice, # 选择题:从命名选项中选一个,返回 choice + probabilities + confidence
Score, # 打分题:按有序量表打分,返回期望分 + probabilities + confidence
Noul, # 是非题:返回"是"的概率(0~1),本身就是概率所以没有 confidence 字段
TypeSafeClient,
TypeSafeAuthenticationError, # 401 鉴权失败时抛出
)
API_KEY = os.environ.get("TYPESAFE_API_KEY", "")
# API_KEY = "apikey_..." # ← 仅在临时调试时使用,注意不要提交到公开仓库
# Key 为空时不构造客户端:SDK 在无 Key 时抛 TypeSafeError(非 401 的
# TypeSafeAuthenticationError),不会被下面的回退捕获,会让整本笔记本中断。
client = TypeSafeClient(api_key=API_KEY, model="jev-latest") if API_KEY else None0.3 离线回退用的两个替身类
真实 API 不可用时,我们需要一个与官方 SDK 响应对象同构的替身,让后续分析代码不用改。官方 SystemOneResponse 的访问方式是:
| 访问方式 | 返回 |
|---|---|
resp.answers["名称"] |
全部答案(按问题名) |
resp.choices["名称"] |
选择题答案:.choice .confidence .probabilities |
resp.scores["名称"] |
打分题答案:.score .confidence .legend .probabilities |
resp.nouls["名称"] |
是非题答案:.noul(本身就是概率,无 confidence) |
resp.usage.input_tokens |
本次请求计费的 input token 数 |
下面的替身类暴露完全相同的属性,仅用于离线模式。
class _FakeAnswer:
"""单个答案的替身:按需挂属性(choice/score/noul/confidence/...)。"""
def __init__(self, type_, **kw):
self.type = type_
for k, v in kw.items():
setattr(self, k, v)
class _FakeResponse:
"""整个响应的替身:与 SystemOneResponse 同构(answers/nouls/choices/scores/usage)。"""
def __init__(self, answers):
self.answers = answers
self.nouls = {k: v for k, v in answers.items() if v.type == "noul"}
self.choices = {k: v for k, v in answers.items() if v.type == "choice"}
self.scores = {k: v for k, v in answers.items() if v.type == "score"}
self.model = "jev-latest(离线示例)"
self.usage = _FakeAnswer("usage", input_tokens=0, output_tokens=0)0.4 调用助手 ts.call()
统一入口:优先请求真实 API;只有当鉴权失败(401)时,才回退到各实验预置的离线示例数据,并在第一次回退时给出显著警告。这样拿到无效 Key 也能跑通全流程,而有效 Key 下全程真实。
class TS:
real_calls = 0 # 成功的真实调用计数
offline = False # 一旦回退过就置 True,后续单元格据此跳过真实计时等逻辑
_warned = False # 完整警告只打印一次,避免刷屏
def call(self, state, questions, offline_answers=None):
if client is None: # 未配置 Key:直接走离线示例,不触碰任何客户端
TS.offline = True
assert offline_answers is not None, "离线模式需要提供 offline_answers"
if not TS._warned:
TS._warned = True
print("⚠️ 离线示例模式:未设置 TYPESAFE_API_KEY,以下为内置示例数据而非真实模型结果;"
"设置有效的 TYPESAFE_API_KEY 后重跑本笔记本即可得到真实输出。")
else:
print("⚠️ (本次为离线示例数据,非真实 API 输出)")
return _FakeResponse(offline_answers)
try:
resp = client.system_one(state, questions)
TS.real_calls += 1
return resp
except TypeSafeAuthenticationError:
TS.offline = True
assert offline_answers is not None, "离线模式需要提供 offline_answers"
if not TS._warned:
TS._warned = True
print("⚠️ 离线示例模式:API Key 无效(401),以下输出为内置示例数据而非真实模型结果;"
"设置有效的 TYPESAFE_API_KEY 后重跑本笔记本即可得到真实输出。")
else:
print("⚠️ (本次为离线示例数据,非真实 API 输出)")
return _FakeResponse(offline_answers)
ts = TS()0.5 连通性测试
用一条最简单的是非题试连官方 API:Key 有效则提示通过;若返回 401,后面的实验会自动切换到离线示例模式(见下一节说明),流程照样能走通。
if client is None:
TS.offline = True
print("⚠️ 未设置 TYPESAFE_API_KEY,以下实验以【离线示例模式】运行:")
print(" 代码路径与真实调用完全一致,仅数据换成本笔记内置的示例值;")
print(" 在有效 Key 下重跑本笔记本即可得到真实模型输出。")
else:
try:
ping = client.system_one(
"你好",
{"is_greeting": Noul(instructions="这段文字是在打招呼吗?")},
)
print("✅ API 连通正常,Key 有效。将进行真实实验。")
except TypeSafeAuthenticationError:
TS.offline = True
print("⚠️ API Key 无效(401)。以下实验将以【离线示例模式】运行:")
print(" 代码路径与真实调用完全一致,仅数据换成本笔记内置的示例值;")
print(" 在有效 Key 下重跑本笔记本即可得到真实模型输出。")0.6 本章离线示例数据
四份中文文档对应四套 Choice 答案。置信度刻意拉开:缺失日期会低于 REVIEW_BELOW,从而进入人工审核。
def _ch(choice, confidence):
return _FakeAnswer("choice", choice=choice, confidence=confidence,
probabilities={choice: confidence})
# 绝对日期:2027年8月14日
OFFLINE_ABS = {
"mode": _ch("absolute", 0.97),
"month": _ch("August", 0.96),
"day": _ch("14", 0.98),
"year": _ch("2027", 0.95),
"day_anchor": _ch("none", 0.90),
"weekday": _ch("none", 0.90),
"week_offset": _ch("none", 0.90),
}
# 相对:明天(相对 TODAY=2026-07-30 → 2026-07-31)
OFFLINE_TOMORROW = {
"mode": _ch("relative", 0.94),
"month": _ch("none", 0.88),
"day": _ch("none", 0.88),
"year": _ch("none", 0.88),
"day_anchor": _ch("tomorrow", 0.96),
"weekday": _ch("none", 0.90),
"week_offset": _ch("none", 0.90),
}
# 相对:下周四(next Thursday → 2026-08-06)
OFFLINE_NEXT_THU = {
"mode": _ch("relative", 0.93),
"month": _ch("none", 0.88),
"day": _ch("none", 0.88),
"year": _ch("none", 0.88),
"day_anchor": _ch("weekday", 0.95),
"weekday": _ch("Thursday", 0.97),
"week_offset": _ch("next", 0.94),
}
# 缺失:文档未提及该角色日期
OFFLINE_MISSING = {
"mode": _ch("none", 0.46),
"month": _ch("none", 0.40),
"day": _ch("none", 0.40),
"year": _ch("none", 0.40),
"day_anchor": _ch("none", 0.40),
"weekday": _ch("none", 0.40),
"week_offset": _ch("none", 0.40),
}📖 理论速览(精简)
本笔记本默认你已读过 System One / 原语 / 置信度 (中文镜像站有对应页)。此处只提醒实验会反复用到的三点:
- 请求模型:同一个
state+ 一组问题 → 类型化答案;问题彼此独立、按 ID 返回; - 三种原语:
Choice选命名选项;Score返回概率加权期望分(可为小数);Noul返回“是”的概率(无 confidence); - 代码掌控制权:阈值、路由、合并答案都在你的代码里;模型只回答狭窄、原子的判断。
💡 问题 ID 不会发给模型——完整语义写在
instructions/criteria里。
1. 日期抽取:原理
目标函数:extract_date(document, role) —— 给定文档与角色短语(如“交回表格的截止日期”),
返回带置信度的 date,并在低置信或拼不出日期时标记人工审核。
TypeSafe 只读文本说了什么(是绝对日期还是相对日期、几月几日、星期几), 从不做日历计算。拼年、推“明天 / 下周四”全在你的代码里完成。
📖 理论根基
出处:Date Extraction / 中文镜像。
| 概念 | 要点 |
|---|---|
七个 Choice |
mode + 绝对三件套 + 相对三件套,一次请求发出 |
| 置信度门控 | 日期置信度 = 所用各部分中的 min;低于 REVIEW_BELOW → 人工 |
none / out_of_range |
逃生口:未陈述、或不在年份窗口内 → 不瞎猜 |
固定 TODAY |
相对日期可复现;本笔记 TODAY = 2026-07-30(星期四) |
1.1 常量与月份 / 星期映射
from datetime import date, timedelta
TODAY = date(2026, 7, 30) # 固定“今天”,保证相对日期可复现
REVIEW_BELOW = 0.60 # 低于此置信度 → 送人工审核
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",
]
# 演示用年份窗口(完整 cookbook 是 1900–2050;此处收窄以保持单元格可读)
YEAR_WINDOW = list(range(2020, 2031))
print("TODAY =", TODAY, TODAY.strftime("(%A)"))
print("REVIEW_BELOW =", REVIEW_BELOW)1.2 定义日期 Choice 问题工厂
ABSENT = "文档未陈述该项,或不是此类日期。"
def date_questions(role: str) -> dict:
"""七个 Choice:读日期形态与各部分——不做算术。"""
return {
"mode": Choice(
instructions=(
f"{role} 是怎么写的?"
" absolute = 点名月份的日历日期(如 2027年8月14日);"
" relative = 相对今天(明天、下周四等);"
" none = 文档完全未陈述该日期。"
),
criteria={"absolute": None, "relative": None, "none": None},
),
"month": Choice(
instructions=f"若 {role} 是绝对日历日期,它在几月?",
criteria={m: None for m in MONTHS} | {"none": ABSENT},
),
"day": Choice(
instructions=f"若 {role} 是绝对日历日期,是几号(1–31)?",
criteria={str(d): None for d in range(1, 32)} | {"none": ABSENT},
),
"year": Choice(
instructions=(
f"若 {role} 是绝对日历日期,是哪一年?"
" 选 none 表示未写年份(由代码推断);"
" 选 out_of_range 表示写了但不在列表内。"
),
criteria={str(y): None for y in YEAR_WINDOW}
| {
"out_of_range": "写了年份但不在所列范围内。",
"none": "未陈述年份。",
},
),
"day_anchor": Choice(
instructions=(
f"若 {role} 相对今天,它是哪一天?"
" today / tomorrow / day_after / weekday。"
),
criteria={
"today": None,
"tomorrow": None,
"day_after": None,
"weekday": None,
"none": ABSENT,
},
),
"weekday": Choice(
instructions=f"若 {role} 点名了星期几,是哪一天?",
criteria={w: None for w in WEEKDAYS} | {"none": ABSENT},
),
"week_offset": Choice(
instructions=(
f"若 {role} 点名了星期几,指哪一周?"
" next = 下周四 / 下周的周四;"
" current = 本周四;"
" none = 仅写周四、无修饰。"
),
criteria={"current": None, "next": None, "none": ABSENT},
),
}
print("问题数:", len(date_questions("截止日期")))
print("mode 选项:", list(date_questions("截止日期")["mode"].criteria))1.3 代码侧:解析星期与拼装日期
def resolve_weekday(today: date, weekday: str, week_offset: str) -> date:
"""具名星期几的约定:裸星期 = 今天或之后最近一次;next = 下一日历周;current = 本周。"""
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:
"""把 TypeSafe 读到的各部分拼成具体 date;置信度取所用部分的最小值。"""
mode = parts["mode"]["choice"]
confs = [parts["mode"]["confidence"]]
def result(resolved, 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 str(day).isdigit() or month not in MONTHS:
return result(None, "absolute date incomplete")
if year == "out_of_range":
return result(None, f"year outside {YEAR_WINDOW[0]}-{YEAR_WINDOW[-1]}")
if year == "none":
try:
resolved = date(today.year, MONTHS[month], int(day))
except ValueError:
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:
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 = parts["weekday"]["choice"]
offset = 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}")
# 纯代码自检(不调用 API)
assert resolve_weekday(TODAY, "Thursday", "next") == date(2026, 8, 6)
print("resolve_weekday 自检通过: 下周四 =", date(2026, 8, 6))1.4 中文文档数据
DOC_ABS = "本合同的交回截止日期为 2027年8月14日,逾期视为自动放弃。"
DOC_TOMORROW = "请于明天中午前把签字页扫描件发到经办邮箱。"
DOC_NEXT_THU = "设计评审安排在下周四下午两点,会议室 B。"
DOC_MISSING = "请尽快交回签字页。如有问题联系行政前台。" # 未写具体日期
EXAMPLES = [
(DOC_ABS, "交回表格的截止日期", date(2027, 8, 14), OFFLINE_ABS),
(DOC_TOMORROW, "签字页提交日", date(2026, 7, 31), OFFLINE_TOMORROW),
(DOC_NEXT_THU, "设计评审日期", date(2026, 8, 6), OFFLINE_NEXT_THU),
(DOC_MISSING, "交回表格的截止日期", None, OFFLINE_MISSING),
]
for i, (doc, role, expected, _) in enumerate(EXAMPLES, 1):
exp = expected.isoformat() if expected else "none"
print(f"{i}. role={role!r} expected={exp}")
print(f" doc: {doc}")1.5 调用 TypeSafe 并拼装结果
def read_parts(document: str, role: str, offline_answers) -> dict:
"""一次 TypeSafe 调用 → {part: {choice, confidence}}。"""
questions = date_questions(role)
resp = ts.call(document, questions, offline_answers=offline_answers)
out = {}
for part, ans in resp.answers.items():
out[part] = {"choice": ans.choice, "confidence": ans.confidence}
return out
def extract_date(document: str, role: str, offline_answers) -> dict:
return assemble(read_parts(document, role, offline_answers))
print(f"{'':3}{'role':<22}{'expected':<12}{'got':<12}{'conf':>6} flags")
print("-" * 72)
rows = []
for document, role, expected, offline in EXAMPLES:
r = extract_date(document, role, offline)
rows.append((document, role, expected, r))
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:<22}{exp:<12}{got:<12}{conf:>6}{flags}")1.6 按置信度分流:自动接受 vs 人工审核
confident = [(role, r) for _, role, _, r in rows if not r["needs_review"]]
review = [(role, r) for _, role, _, r in rows if r["needs_review"]]
print(f"auto-accept ({len(confident)}):")
for role, r in confident:
print(f" - {role} → {r['date']} (conf {r['confidence']:.2f})")
print(f"\nsend to review ({len(review)}):")
for role, r in review:
note = r["note"] or "low confidence"
conf = f"{r['confidence']:.2f}" if r["confidence"] is not None else "n/a"
print(f" - {role} (conf {conf} / {note})")观察要点
- 2027年8月14日:
mode=absolute,英文月份 keyAugust+ day/year → 代码拼出2027-08-14。 - 明天:相对锚定
tomorrow,相对固定TODAY得到2026-07-31。 - 下周四:
weekday=Thursday+week_offset=next→2026-08-06(日历周约定写在代码里)。 - 缺失日期:
mode=none或各部分不完整 →needs_review=True,置信度常低于 0.60。
小结
| 步骤 | 谁负责 |
|---|---|
| 读 mode / 月日年 / 星期 | TypeSafe Choice |
拼 date、推相对日 |
你的代码 |
| 是否送审 | min(confidence) < REVIEW_BELOW |
延伸阅读:Date Extraction · 置信度。
知识补充
- 先解析后语义:能被正则/日期库解析的直接走代码,只把"下个月中旬""月底前"这类模糊表述交给模型判——官方叫 pre-parsed 思想(完整版见
15_预解析值抽取.ipynb)。 - 输出仍是判断:让模型在给定候选格式里选(Choice),而不是生成日期字符串——杜绝格式崩坏,JevBench 实测 Jev 可解析率 100% 的价值就在这。
- 归一化在代码:时区、格式统一由代码完成后处理。