第 23 章

第 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 None

0.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 / 原语 / 置信度 (中文镜像站有对应页)。此处只提醒实验会反复用到的三点:

  1. 请求模型:同一个 state + 一组问题 → 类型化答案;问题彼此独立、按 ID 返回;
  2. 三种原语:Choice 选命名选项;Score 返回概率加权期望分(可为小数);Noul 返回“是”的概率(无 confidence);
  3. 代码掌控制权:阈值、路由、合并答案都在你的代码里;模型只回答狭窄、原子的判断。

💡 问题 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,英文月份 key August + 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% 的价值就在这。
  • 归一化在代码:时区、格式统一由代码完成后处理。

📑 Jev Cookbook:System One 判断模型实战教程

1 第 1 章:认识 Jev:模型、上手与场景 2 第 2 章:核心概念总览 3 第 3 章:System One:判断的核心心智模型 4 第 4 章:状态:让判断连续可追溯 5 第 5 章:原语:Choice、Score 与 Noul 6 第 6 章:置信度:让概率可信 7 第 7 章:应用构建:从原语到完整系统 8 第 8 章:架构模式 9 第 9 章:实战指南总览 10 第 10 章:自一致性 · Noul 11 第 11 章:自一致性 · Choice 12 第 12 章:并行提问 13 第 13 章:重排序 14 第 14 章:逐行语义搜索 15 第 15 章:结构恢复 16 第 16 章:函数调用 17 第 17 章:技能推荐 18 第 18 章:实体对齐 19 第 19 章:RAG 段落分类 20 第 20 章:引用核查 21 第 21 章:LLM 防护栏 22 第 22 章:SDE 级联 23 第 23 章:日期抽取 24 第 24 章:预解析值抽取 25 第 25 章:层级分类 26 第 26 章:自动研究特征发现 27 第 27 章:基于置信度的分类 28 第 28 章:智能家居实验 29 第 29 章:模型评测总览 30 第 30 章:模型评测实验 31 第 31 章:Laya vs Jev 对比基准 32 第 32 章:JevBench:LLM 评测体系 33 第 33 章:智能家居应用实战 34 第 34 章:Jev-Mem 研究总览 35 第 35 章:Jev-Mem 缩放实验 36 第 36 章:Jev-Mem 完整走查 37 第 37 章:Agent 集成总览 38 第 38 章:Pi 集成实验 39 第 39 章:DSH 决策协作 40 第 40 章:本地模型总览 41 第 41 章:本地模型介绍与对比 42 第 42 章:中文数据集构建方案 43 第 43 章:微调指南 44 第 44 章:RLCD 原理与实验优化 45 第 45 章:中文数据集构建实验 46 第 46 章:中文 Head 微调实验 47 第 47 章:全量 v2 微调实验 48 第 48 章:知识库
← 返回本书大纲