值提取:日期、邮箱、电话、金额
💡 一句话总结:用 Jev 信息提取搭一条字段流水线——日期拆成部件由代码拼装,邮箱/电话/金额先用正则找候选、Jev 挑对的那个,值只能来自原文。
要把文档里的字段喂给数据库,你要的不是「一段看起来像答案的文本」。官方为此给了两个 cookbook:Date extraction(日期提取)问的是日期的部件——哪个月、几号、年份写没写、是绝对日期还是「下周四」这种相对日期,日历数学全部留给代码;Pre-parsed value extraction(预解析值提取)则是正则找候选、Jev 挑对的那个、代码逐字拷贝。分工只有一条:模型读文本,数学和规范化在代码里。
为什么这条线值得单独一课?官方在 Pre-parsed 里说得很直白:TypeSafe 只在正则已经找到的候选里挑,所以它不可能发明一个值,也不可能把数字转写错位。官方实测结果:六个日期例子全对(包括一个文档里根本没写的日期,被正确判为「拿不出日期」、0.46 送人工);邮件里四个地址挑对收件邮箱 0.97;三个电话挑对手机号并规范化成 +14155550177;发票四个金额挑对总额和优惠,credit / charge 概率一边 0.03 一边 0.98。本课约 15 分钟整套跑通——其中邮箱/电话/金额部分,下载官方缓存后零 Key 复现官方全部数字(本页实测通过;日期部分为什么不行,第 1 步会说明)。
学完你能做什么
- 写出
extract_date(document, role):给它一份文档和一句「表单退还的截止日期是什么时候」,拿回一个date加 confidence;没写年份的补年份、「下周四」解析成具体日期、拼不成的日期报错 - 用
find+pick二人组抽值:正则抓邮箱/电话/金额候选,Jev 挑对的那个,答案是候选的逐字拷贝——不会编造、不会错位转写 - 把挑出的值规范化:邮箱转小写、电话转 E.164 国际格式、金额转
Decimal并标上 credit / charge - 不花一分钱复现官方值提取数字:官方
json_cache.json缓存重放,重跑零请求
为什么用 Jev,而不是让 LLM 直接抽?
「让 LLM 从合同里把日期抽出来」是很多人的第一反应——能跑,但每个字都是它重新打出来的。
| 能力 | 让 LLM 直接抽 | Jev 信息提取 |
|---|---|---|
| 值的来源 | ❌ 模型重新生成一遍,可能发明一个值、把 14 打成 41 | ✅ 选项就是正则找到的候选,答案是其中之一的原文拷贝或 none——官方原话:不能 invent、不能 transpose |
| 日历数学 | ❌ 「下周四是几号」「2 月有没有 30 号」交给模型算,不可靠 | ✅ 模型只读部件(八月 / 14 / 没写年份),拼装、推断、校验全在代码 |
| 文档没写的值 | ❌ 倾向于还是给一个日期出来 | ✅ mode=none、year=none、pick=none 三个逃生舱——官方的口径:缺的部件要被报出来,而不是被猜出来 |
| 把握度 | ❌ 要追问「你确定吗」,答案还不稳定 | ✅ 每个部件带 confidence,日期取用到的部件里最弱的,低于 0.60 送人工 |
| 答案形态 | ❌ 自由文本或 JSON,下游解析会失败 | ✅ 类型化答案 + 概率分布,代码直接用 |
一句话:LLM 会替你重写文档,Jev 只替你读文档——写下来的数字永远出自原文。
你现在的困境
- 「让 LLM 抽发票总额,它偶尔把
$1,315.50转写成$1,351.50——错位一位,下游账单全错,还查不出是哪一步错的」 - 「合同里压根没写交付日期,模型照样编一个给你;我只能再写一层校验去抓它的幻觉」
- 「『下周五前退还』这种相对日期,让模型算日历,每次跑答案还不一样」
什么时候用这一招
当你需要:
- 从合同、邮件、发票、表单里抽结构化字段:日期、邮箱、电话、金额,直接进数据库或账单流
- 值必须逐字来自文档——不能编造、不能错位转写,缺了要明确报缺
- 相对日期(「今天」「下周四」)解析成具体日期,而且解析规则写在你代码里,改规则不改问题
而且不想:
- 解析 LLM 吐出来的 JSON,再写一层「它是不是又编了」的校验
- 让生成模型做日历数学
- 每换一份文档就重调一遍 prompt
🎒 开始前的准备
- 完成了 Noul 是非题:金额的 credit / charge 判定靠 Noul,0.5 = 五五开
- 完成了 Choice 选择题:日期七连问全是 Choice,要会写
criteria选项 map、读得懂choice和confidence - 建议读过 Confidence 置信度:知道 0.60 这条复核线怎么按风险调
- 跑通过安装与第一个请求:
.venv可用。值提取部分不设 Key 也能跑(第 1 步给官方缓存);日期部分需要TYPESAFE_API_KEY - 一杯水 ☕——本课是一个完整小项目:一个日期函数 + 三类值场景,模型只读文本、数学和规范化全在代码
跟我做
先说好怎么跑:新建 value_extraction.py,把下面每一步的代码块按顺序贴进同一个文件。本课两个部分、两种跑法:
- 第 2–4 步(日期):官方缓存与当前代码的缓存键对不上(第 1 步细说),设
TYPESAFE_API_KEY真跑——六个例子共 6 次请求,答案缓存到本地,重跑不重复花钱 - 第 5–6 步(邮箱/电话/金额):下载官方
json_cache.json后零 Key 复现官方全部数字
暂时没有 Key?直接跳到第 5 步先把值提取跑通,日期部分对照页内官方录制数字读——每步都有标注。
第 1 步:装环境、下官方缓存,备好两套提取示例
为什么:官方两个示例各带一份录制好的答案缓存 json_cache.json——值提取那份实测能逐字重放,零 Key;日期那份的键和当前代码对不上,只能 live 跑。先把物料备齐,后面每一步才有答案可对。
安装依赖(官方两个 cookbook 的安装命令合并,typesafe-sdk 的版本区间照 cooksafe 0.2.0 的依赖声明 typesafe-sdk<0.8.0,>=0.7.0 显式钉住;cooksafe 是官方 cookbook 配套工具库,PyPI 自述「Shared helpers for the TypeSafe cookbooks」,本页用到它的 JsonCache 缓存):
pip install 'typesafe-sdk>=0.7.0,<0.8.0' ipython phonenumbers 'cooksafe>=0.2.0,<0.3.0'下载值提取的官方缓存(raw 直链,实测可用):
curl -sL -o values_cache.json https://raw.githubusercontent.com/typesafe-ai/typesafe-public-examples/main/cookbooks/pre_parsed_value_extraction_cookbook/json_cache.json新建 value_extraction.py,贴入文件头——客户端挂两个缓存,日期和值各用各的:
import os
import re
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
import phonenumbers
from cooksafe import JsonCache
from typesafe_sdk import Choice, Noul, TypeSafeClient
# 官方录制版本:Date extraction 用 jev-1.12,
# Pre-parsed value extraction 用 jev-1.14.0。
# live 跑日期部分时换成你锁定的版本 ID;jev-latest 别名会随版本漂移。
DATE_MODEL = "jev-1.14.0"
VALUE_MODEL = "jev-1.14.0"
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,
)
# 日期:缓存你自己 live 跑的答案(官方那份对不上,见下方提示框)
date_cache = JsonCache(Path("date_cache.json"))
# 值:官方缓存,零 Key 重放第 5、6 步全部官方数字
values_cache = JsonCache(Path("values_cache.json"))
print("setup ok")运行 python value_extraction.py。
你应该看到:setup ok——这一步只是装配,还没发请求。
⚠️ 日期缓存为什么不下官方的
官方仓库里同样发布了日期部分的缓存,但我实测:用本页代码(即官方文档现行代码)加 cooksafe 0.2.0 重放,六个调用全部未命中——缓存键对不上(大概率是录制脚本与现行文档代码不同步),SDK 转而尝试真请求,没有 Key 就是 401。所以日期部分用你自己的 date_cache.json;官方数字本页照录,供你对答案。值提取的官方缓存实测逐字重放,放心用。
setup ok打印出来了,values_cache.json就在运行目录里
第 2 步:日期七连问——Jev 信息提取先读部件、不做数学
为什么:让模型直接回答「截止日期是几号」,等于要它做日历数学。官方的拆法是让日期的每个部件都落在小的封闭集里:十二个月、1–31 号、一段年份窗口,于是每个部件是一道 Choice,一次请求并行问完(一次请求多题的机制见并行提问与投机扇出)。模型读文本,代码算日历。
先备好四份官方示例文档,再贴入七道题的定义:
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."
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
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},
),
}七道题的分工——mode 说这个日期怎么写的(绝对 / 相对 / 没写),其余六道读部件:绝对日期要 month + day + year,相对日期要 day_anchor(今天 / 明天 / 后天 / 某个星期几),点名星期几时 weekday 和 week_offset 说哪一个和哪一周。代码只读 mode 用得上的那几道。
注意 year 的两个逃生舱:none = 文本没写年份,代码来补;out_of_range = 写了但不在 1900–2050 里,报出来而不是猜。官方还给了个选项列表太长的替代做法,见进阶技巧。
接着贴入发请求的函数,顺手跑合同生效日期这一条:
@date_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=DATE_MODEL
).answers
return {
part: {"choice": ans.choice, "confidence": ans.confidence}
for part, ans in answers.items()
}
parts = read_parts(CONTRACT, "the date the agreement takes effect")
for name, part in sorted(parts.items()):
print(f"{name:<12}{part['choice']:<11}(conf {part['confidence']:.2f})")运行 python value_extraction.py。设了 Key 会真发一次请求(jev-1.14.0),答案进 date_cache.json。
你应该看到(官方 jev-1.12 录制数字;你 live 跑部件选择应一致,置信度会有小差异):
day 1 (conf 0.98)
day_anchor none (conf 0.89)
mode absolute (conf 1.00)
month January (conf 1.00)
week_offset none (conf 0.99)
weekday none (conf 0.86)
year 2025 (conf 0.97)三个读法:
模型没有做数学:它只说了「January」「1」「2025」,没有说这是星期几、离今天多远——那是第 3 步代码的活
不适用的部件答
none:绝对日期不需要day_anchor/weekday,全部none加高置信度每个部件自带 confidence:这台机器知道自己每个读数有多稳,第 4 步会用它们算整条日期的置信度
我能说出七道题各读什么、
mode决定代码读哪几道
第 3 步:代码拼日期——日历数学不进模型
为什么:部件有了,剩下全是确定性工作:不写的年份按今天补、「next Thursday」按你声明的约定解析、February 30 这种拼不成的组合报错。官方把这段写成纯函数——不发的请求、不打日志,可以单独跑单元测试。
贴入拼装代码:
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
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))
print(extract_date(CONTRACT, "the date the agreement takes effect"))运行。
你应该看到(官方录制:三个用到的部件 1.00 / 0.98 / 0.97,取最弱):
{'date': datetime.date(2025, 1, 1), 'confidence': 0.97, 'needs_review': False, 'note': ''}三段逻辑读一遍,每段都有自己的出口:
- 「next Thursday」的歧义由代码裁决:官方声明的约定——光秃秃的 weekday = 今天起(含今天)下一个;
next= 下一日历周;current= 本周。你可以改成自己团队的约定,改的是代码不是问题 - 没写年份就推断:先按今年拼;已经过去了超过 31 天才跳到明年——8 月 14 日的表单在 7 月 30 日看就是今年
- 拼不成的日期报 impossible,不硬凑:February 30 这种矛盾读数直接返回
None加 note,第 4 步会把它送人工
💡 这就是 Jev 的独有价值
置信度是用到的部件里最弱的那个——绝对日期只看 mode + 月 + 日 + 年四项,day_anchor 答成什么样都不影响。哪个部件拖后腿一目了然,复核的人知道该去看哪半句原文。普通 LLM 给不出这个:它一次性吐出成品日期,你不知道哪一环是猜的。
- 我能说出
year=none时的推断规则(今年起步,超过 31 天前才跳明年),和裸 weekday 的解析约定
第 4 步:六个官方例子对答案,低于 0.60 送人工
为什么:一套提取能不能上线,看它在「文档没写这个日期」时怎么表现。官方的六个例子覆盖了四种写法加一个陷阱:合同写了年份的两个日期、没写年份的表单截止日、「今天」关掉的调研、「下周四」的设计评审,外加一个表单里根本没提的 kickoff call(表单里有日期,只是不是这个日期——专治「看到日期就抽」的惯性)。
贴入示例和记分板:
# (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)),
]
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}")
print()
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'})")运行。六个例子共 6 次调用——第 2 步那条已在缓存里,首跑新发 5 次请求;后面 auto-accept / review 两段再调 extract_date,全部走缓存。
你应该看到(官方 jev-1.12 录制、TODAY = 2026-07-30,一个周四;live 数字会有小差异):
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
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)三件事值得停下来看:
- 没写的日期被报出来,而不是被猜出来:kickoff call 那行,
mode答了absolute但month是none——code 判「absolute date incomplete」,日期为空、confidence 0.46、needs_review。这正是官方给部件加none逃生舱的意义:缺的部件被报告,而不是被脑补 - 没写年份的表单自己补:August 14 → 2026-08-14(
TODAY是 2026-07-30,8 月还没过,不用跳年) - 「今天」和「下周四」都落了地:survey closes → 2026-07-30(当天);design review → 2026-08-06(7 月 30 日是周四,「next」= 下一日历周的第 4 天)。相对日期可复现的前提是
TODAY固定——你自己跑就把TODAY换成date.today(),或在跑批前统一钉死
上线动作由 needs_review 一个字段决定:False 直接进库,True 送人工——置信度怎么按风险调档,见 Confidence 置信度。
- 六行记分板和 auto-accept 5 / send to review 1 都对上了;我能指出 kickoff call 那行为什么不该是 2026-08-14
第 5 步:邮箱提取——正则找候选,Jev 挑对的那个
为什么:从这一步起零 Key 可跑。邮箱的难点不是「找出所有地址」——正则一小时就能写好——而是四个候选里哪个才是你要的那个:收据寄去哪个邮箱,取决于读没读懂正文那句「别用账单别名,寄我私人邮箱」。这正是官方三步法:正则找候选(宁可多抓)→ Jev 挑对的 → 代码逐字拷贝规范化。
贴入四个帮助函数和邮件示例:
NONE = "none" # the escape hatch on every selection: "none of the candidates fits"
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
PHONE_RE = re.compile(r"\(?\+?\d[\d\s()\-.]{6,}\d")
MONEY_RE = re.compile(r"[$€£¥]\s?\d[\d,]*(?:\.\d{2})?")
def find(pattern: re.Pattern, text: str) -> list[str]:
"""Code-side candidate finder: recall-tuned regex, deduped, in document order."""
seen: set[str] = set()
out: list[str] = []
for match in pattern.findall(text):
span = match.strip()
if span and span not in seen:
seen.add(span)
out.append(span)
return out
@values_cache
def pick(document: str, candidates: list[str], question: str) -> dict:
"""TypeSafe selects which found span plays the role. Returns {choice, confidence}.
The options ARE the candidate spans, so ``choice`` is a verbatim copy of one of them (or the
``none`` hatch). The model chooses; code owns the string."""
criteria = {c: None for c in candidates} | {NONE: "None of these is the requested value."}
answer = client.system_one(
state=document,
questions={"pick": Choice(instructions=question, criteria=criteria)},
model=VALUE_MODEL,
).answers["pick"]
return {"choice": answer.choice, "confidence": answer.confidence}
@values_cache
def classify(document: str, question: str, options: list[str]) -> dict:
"""A small Choice over a fixed label set (currency, country, ...). Returns {choice, confidence}."""
answer = client.system_one(
state=document,
questions={"q": Choice(instructions=question, criteria={o: None for o in options})},
model=VALUE_MODEL,
).answers["q"]
return {"choice": answer.choice, "confidence": answer.confidence}
@values_cache
def is_true(document: str, question: str) -> float:
"""A yes/no Noul. Returns P(yes)."""
return (
client.system_one(
state=document,
questions={"q": Noul(instructions=question)},
model=VALUE_MODEL,
)
.answers["q"]
.noul
)
EMAIL_DOC = """From: Jane Doe <jane.doe@example.com>
To: billing@example.com
Cc: orders@example.com
Reply-To: jane.personal@example.net
Hi team - please don't use the billing alias for this one. Send my receipt to my
personal address instead. Thanks, Jane."""
emails = find(EMAIL_RE, EMAIL_DOC)
receipt = pick(EMAIL_DOC, emails, "Which email address does the sender want their receipt sent to?")
sender = pick(EMAIL_DOC, emails, "Which email address did this message come from (the From line)?")
print("candidates :", emails)
# code copies the picked value verbatim and normalizes (lowercase); it never re-types it
print(f"receipt -> : {receipt['choice'].lower():<28} (conf {receipt['confidence']:.2f})")
print(f"sender -> : {sender['choice'].lower():<28} (conf {sender['confidence']:.2f})")运行 python value_extraction.py——全部命中官方缓存,一个请求都不发。
你应该看到(官方 jev-1.14.0 录制;本页实测逐字复现):
candidates : ['jane.doe@example.com', 'billing@example.com', 'orders@example.com', 'jane.personal@example.net']
receipt -> : jane.personal@example.net (conf 0.97)
sender -> : jane.doe@example.com (conf 1.00)两个设计点:
pick的选项就是候选本身:criteria是四个地址字符串加一个none,所以choice只能是其中之一的一字不差的拷贝——想编都编不出第五个地址,想转写错位也不存在,因为根本没有「重新打字」这个环节- 选不中就说选不中:没有一个候选符合时答案是
none,代码把它当「没找到」处理,而不是硬挑一个 - 两个问题、两次请求、同一副候选:收件地址看正文(要私人邮箱),发件人看
From行——判断在字里行间,这正是正则做不到的那部分
💡 这就是 Jev 的独有价值
正则管召回(把可能的全抓出来,多抓没关系),Jev 管选择(读懂正文,挑对的那个),代码管保真(逐字拷贝 + 小写规范化)。三段各司其职,哪一段坏了都能单独修——LLM 直抽把三件事搅在一次生成里,错了都不知道错在哪一段。
- 零 Key 跑出了 receipt 0.97 / sender 1.00;我能说出
pick为什么不可能返回一个不在候选里的地址
第 6 步:电话与金额——规范化成 E.164 和 Decimal
为什么:值挑对了只是上半场,还得变成系统能用的形态。电话的难点:三个号码长得一模一样,哪个是手机、在哪个国家——数字本身不含这些信息,周围的文字才有。金额的难点:挑出总额还不够,得知道它是 charge 还是 credit,不然 Decimal 连符号都定不了。
贴入电话示例:
PHONE_DOC = """Reach our San Francisco office at these numbers: main desk (415) 555-0199,
billing fax (415) 555-0142, and my direct cell (415) 555-0177. Call the cell if it's urgent."""
phones = find(PHONE_RE, PHONE_DOC)
mobile = pick(PHONE_DOC, phones, "Which of these is the direct mobile / cell number?")
region = classify(PHONE_DOC, "In what country is this office located?", ["US", "GB", "DE", "FR", "CA", "AU"])
# code copies the picked value and normalizes it with the model-supplied country
parsed = phonenumbers.parse(mobile["choice"], region["choice"])
e164 = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
print("candidates :", phones)
print(f"mobile -> : {mobile['choice']} (conf {mobile['confidence']:.2f})")
print(f"country -> : {region['choice']} (conf {region['confidence']:.2f})")
print(f"E.164 -> : {e164}")再贴入金额示例:
MONEY_DOC = """Invoice INV-2087.
Subtotal: $1,200.00
Sales tax: $115.50
Total due: $1,315.50
A $50.00 courtesy credit from last month has already been applied."""
amounts = find(MONEY_RE, MONEY_DOC)
currency = classify(MONEY_DOC, "What currency are these amounts in?", ["USD", "EUR", "GBP", "JPY", "CAD"])
total = pick(MONEY_DOC, amounts, "Which amount is the total the customer must pay?")
credit = pick(MONEY_DOC, amounts, "Which amount is the courtesy credit that was applied?")
def to_decimal(value: str) -> Decimal:
"""Copy the picked value and parse the number in code (US grouping/decimal here)."""
return Decimal(re.sub(r"[^\d.]", "", value))
for label, chosen in [("total due", total), ("credit", credit)]:
is_credit = is_true(
MONEY_DOC,
f"Is the amount {chosen['choice']} a credit or refund to the customer, not a charge?",
)
kind = "credit" if is_credit > 0.5 else "charge"
print(
f"{label:<10}: {chosen['choice']:<10} -> {to_decimal(chosen['choice'])} {currency['choice']} "
f"({kind}, P(credit)={is_credit:.2f})"
)
print("\ncandidates :", amounts)运行。
你应该看到(官方 jev-1.14.0 录制;本页实测逐字复现):
candidates : ['(415) 555-0199', '(415) 555-0142', '(415) 555-0177']
mobile -> : (415) 555-0177 (conf 1.00)
country -> : US (conf 1.00)
E.164 -> : +14155550177
total due : $1,315.50 -> 1315.50 USD (charge, P(credit)=0.03)
credit : $50.00 -> 50.00 USD (credit, P(credit)=0.98)
candidates : ['$1,200.00', '$115.50', '$1,315.50', '$50.00']四段拆开看:
- 电话:Jev 挑号码 + 读国家,
phonenumbers负责格式——(415) 555-0177里没有任何一位数字说「我是手机」或「我在美国」,这两件事来自「my direct cell」和「San Francisco office」这些词;E.164 拼装是纯代码,国家作为参数传进去 - 金额:一道 Noul 定符号——「这个金额是给客户的 credit 还是 charge」分别得到 0.03 和 0.98,一边倒的概率让
Decimal的正负号有了依据;0.5一刀切,贴着 0.5 的就该转人工(Noul 没有 confidence) classify的标签是你选定的闭集:国家[US, GB, DE, FR, CA, AU]、币种[USD, EUR, GBP, JPY, CAD]——换成你的业务区域就改这两个列表;官方建议列表可能覆盖不全时加一个other/none of the above选项,让模型有地方说「都不像」pick自带 confidence:官方缓存里 total 这道挑选题录的是 0.56(输出没印,缓存里记着)——答案挑对了,但把握一般。低置信的 pick 要不要复核,规则照第 4 步的REVIEW_BELOW思路写在你代码里
官方在页尾点了两条上限,先记一句,进阶技巧展开:
一道 Choice 最多 255 个选项,候选更多先分两段挑
找候选才是费活的环节——邮箱、电话、金额有幸罩得住,人名没有好正则,候选得从花名册或 NER 来
零 Key 跑出 E.164
+14155550177和 P(credit) 0.03 / 0.98;我能说出手机号和国家分别由谁判定、E.164 由谁拼装
📋 魔法 Prompt
本页的「Prompt」是给 API 的问题与代码模板——抄走、替换【】占位符(带 ? 的是选填)就能接到你自己的文档上。三件套:日期部件题、候选挑选题、规范化流水线。
📝 日期部件七连问(Choice 版)
预期效果:任何「按角色问日期」的需求——合同生效日、表单截止日、会议改期日——同一副题换 role 就能用,答案永远是部件而不是成品日期。
# 必填项:【日期角色】换成你要问的那个日期的自然语言描述
DATE_QUESTIONS = {
"mode": Choice(
instructions="【这个日期是怎么写的?absolute=点名月份的日历日期;relative=相对今天;none=文档没写】",
criteria={"absolute": None, "relative": None, "none": None},
),
"month": Choice(
instructions="【若是绝对日期,是几月?】",
criteria={【m: None for m in 十二个月】} | {"none": "文档没写或不是这种日期"},
),
"day": Choice(
instructions="【若是绝对日期,是几号(1-31)?】",
criteria={【str(d): None for d in range(1, 32)】} | {"none": "文档没写或不是这种日期"},
),
"year": Choice(
instructions="【若是绝对日期,是哪年?没写选 none,超出范围选 out_of_range】",
criteria={【str(y): None for y in 你的年份窗口】}
| {"out_of_range": "写了年份但在列表外", "none": "没写年份,代码来补"},
),
"day_anchor": Choice(
instructions="【若是相对日期,锚在哪天?today / tomorrow / day_after / weekday】",
criteria={"today": None, "tomorrow": None, "day_after": None, "weekday": None,
"none": "文档没写或不是这种日期"},
),
"weekday": Choice(
instructions="【若点名了星期几,是哪一个?】",
criteria={【w: None for w in 七个星期】} | {"none": "文档没写或不是这种日期"},
),
"week_offset": Choice(
instructions="【若点名星期几,是哪一周?next=下一日历周;current=本周;none=光秃秃没限定】",
criteria={"current": None, "next": None, "none": "文档没写或不是这种日期"},
),
}
# 选填项:窗口按你的业务改——1950–2035 或未来十年;列表太长见进阶技巧的预筛做法?
# 输出规范:每题 answer.choice + answer.confidence;代码只读 mode 用得上的部件
# 约束条件:
# ✅ 每道不适用的部件都给 none 出口——缺的部件要被报出来,不是被猜出来
# ✅ 日历数学(补年份、解析「下周四」、校验 2 月 30)全放代码
# ❌ 不要问「截止日期是几月几号」——那是让模型做数学+回答二合一✅ 候选挑选题(pick 版)
预期效果:凡是「正则能找到候选、但要读懂上下文才知道挑哪个」的字段——收件邮箱、手机号、发票总额、客服工号——都套这一个模板。
from typesafe_sdk import Choice
# 必填项:candidates 传正则抓出的候选(去重、按文档序),问题问「角色」不问「形状」
picked = client.system_one(
state=【整份文档原文】,
questions={
"pick": Choice(
instructions="【哪个候选是……?例如:寄件人希望收据寄到的那个邮箱】",
criteria={c: None for c in 【candidates】}
| {"none": "None of these is the requested value."},
)
},
).answers["pick"]
# 选填项:同一批候选换一个 role 再问一次(发件人 vs 收件人)——两次请求、同一副选项?
# 输出规范:answer.choice 是某个候选的逐字拷贝或 "none";answer.confidence 可接复核线
# 约束条件:
# ✅ 选项就是候选字符串本身——答案是拷贝,不是重新生成
# ✅ 正则调召回不调精度:多抓的让 Jev 挑掉,漏抓的谁也救不回来
# ❌ 候选超过 255 个别硬塞——先分区块挑,再挑区块内的 span(见 C4)🔧 规范化与复核(代码版)
预期效果:挑出的值变成系统可直接使用的形态,每一步都是纯代码、可单测、不花钱。
# 必填项:按字段类型选规范化器
normalized = {
"email": lambda v: v.lower(), # 逐字拷贝 + 小写
"phone": lambda v: phonenumbers.format_number( # 读国家 + E.164
phonenumbers.parse(v, region["choice"]),
phonenumbers.PhoneNumberFormat.E164),
"amount": lambda v: Decimal(re.sub(r"[^\d.]", "", v)), # $1,315.50 -> 1315.50
"date": assemble, # 部件 -> date,见第 3 步
}
# 选填项:credit/charge 用一道 Noul 定符号——P(credit) > 0.5 是 credit,贴着 0.5 转人工?
# 输出规范:值 + provenance(来自哪个候选/哪些部件)+ confidence + needs_review
# 约束条件:
# ✅ 规范化只处理「选中的那个字符串」,绝不重新生成
# ✅ 复核线(日期 0.60 起步)按风险调:进账单的金额从严,进统计报表的可松
# ❌ 别假设逗号一定是千分位——欧式格式 €1.315,50 正相反,先问一道 Noul「这份文档用逗号当小数点吗」再分支检查点 ✅
- 第 5、6 步零 Key 复现官方全部数字:receipt 0.97 / sender 1.00 / E.164
+14155550177/ total P(credit)=0.03 / credit P(credit)=0.98 - 我能说出七道日期题各读什么,以及
mode决定代码读哪几道部件 - 我知道日期的 confidence 是用到的部件里最弱的那个;低于 0.60 送人工;文档没写的日期报
none+ note,而不是编一个 - 我能解释
pick为什么不可能编造值或错位转写:选项就是候选本身,答案是逐字拷贝 - 我知道两道上限:Choice 最多 255 个选项(超了分两段挑);人名这类没有好正则的值,候选要从花名册、NER 或 LLM 提议来
踩坑提醒
❌ 让 LLM 把值「打一遍」
LLM 直抽的本质是让它把 $1,315.50 重新打字输出——转写错位一位($1,351.50)、脑补一个文档里没有的值,都发生在「重新生成」这一步。官方三步法把这步整个删掉了:正则找候选 → Jev 在候选里挑 → 代码逐字拷贝再规范化。官方原话说得最狠的一句:TypeSafe 只在正则找到的候选里挑,所以它不可能发明一个值、也不可能把数字转写错位。
❌ 正则调得太严,真值漏在候选外
pick 的选项就是你给它的候选——候选里没有的,它再聪明也挑不出来。官方把第一步的任务定为召回:调正则时宁可多抓(顺带抓到订单号、传真号、小计都没关系,Jev 会挑掉),也别漏掉真值。多抓的成本是一次请求里多几个选项;漏抓的成本是这条记录永远错。
❌ 指望日期缓存零 Key 重放
官方仓库发布的日期 json_cache.json 和当前文档代码的缓存键对不上(本页实测:六个调用全部 miss,SDK 转而真请求,无 Key 报 401)——大概率录制时的函数签名与现行代码不同步。日期部分设 TYPESAFE_API_KEY 用自己的缓存跑;对官方数字时以本页照录的记分板为准。值提取的官方缓存不受影响,实测逐字重放。
❌ 见到金额就 to_decimal,见到中文文档就直接上
两件事都容易栽。其一:Decimal(re.sub(r"[^\d.]", "", value)) 假设「逗号=千分位、点=小数点」——对 $1,315.50 成立,对 €1.315,50 正相反;官方建议先问一道 Noul「这份文档用逗号当小数点吗」,按答案分支。其二:官方说明 Jev 主要用英语训练,其他语言(含中文)能用但准确度较低——中文合同和发票上线前先拿自己的文档跑一批看概率分布,语言支持说明见模型与价格。
进阶技巧(可选)
年份列表太长?先预筛。 year 的 1900–2050 有 151 个选项,官方自己也说「如果这让你不爽,先把文本里像年份的数字抽出来,只把那些给模型当选项」——候选窗口小了,题也更聚焦。这和第 5 步是同一个思想:能交给代码的候选生成,不麻烦模型。
候选超过 255 个:分两段挑。 官方给的打法:先挑区块(哪一段落/哪张表格),再挑区块内的 span——每段 Choice 都在 255 以内。搜索与匹配的逐行搜索展示的就是第一段:给每行标 ID,让 Choice 指向匹配的行。
人名没有好正则。 官方明说:找候选才是费活的环节——邮箱、电话、金额有现成正则,人名没有。候选可以从你已有的花名册(HR 系统、客户名单)来,或让 NER / LLM 提议,Jev 负责最后的挑。
免费玩官方示例。 官方 cookbook 页面底部有做好的 Playground 分享链接——邮件正文 + 四个候选地址那道 pick 题,点开(https://console.typesafe.ai/playground)就能在浏览器里改措辞、看概率分布。想生成自己的链接:
from cooksafe import make_playground_link
link = make_playground_link(EMAIL_DOC, {
"receipt": Choice(
instructions="Which email address does the sender want their receipt sent to?",
criteria={c: None for c in emails} | {"none": "None of these is the requested value."},
),
})
print(link) # 链接里带着完整 state 和问题,别放敏感数据接进你的文档流。 问题固定、只换 state——把 EMAIL_DOC 换成你解析出来的邮件正文、MONEY_DOC 换成 OCR 出来的发票文本,七道日期题和三道值题原样复用。缓存键的构成两边不同,记清楚能省不少排查时间:值部分的键包含文档、候选和问题(pick / classify / is_true 的全部参数)——换文档、改正则、改问题都会 miss,自动真请求;日期部分的键只有文档和 role——改 date_questions 里的题不会 miss(题在函数体内,cooksafe 文档明说改函数体不失效缓存),旧答案照放,改题后想真跑就把 date_cache.json 删掉。提取量大了再看级联提取:便宜模型先干粗活,Jev 只做逐字段核查。
本课小结
✅ Jev 信息提取,日期这样解:七道 Choice 一次请求读出形状和部件,日历数学(补年份、「下周四」解析、2 月 30 校验)全在 assemble();confidence = 用到的部件里最弱的,低于 0.60 送人工。
✅ 值 = 正则候选 + Jev 挑选 + 逐字拷贝:find 管召回、pick 管选择(选项即候选,答案只能是原文拷贝或 none)、代码管规范化——不编造、不错位转写。
✅ 三类值全部规范化落地:邮箱小写 0.97 / 1.00、电话 E.164 +14155550177(国家由 classify 读出)、金额 Decimal 加 credit/charge 符号(Noul 0.03 / 0.98)。
✅ 官方数字零 Key 复现:值提取部分官方缓存实测逐字重放;日期部分实测缓存键对不上,用自己的缓存 live 跑(6 次请求),记分板以官方录制为准。
关键配置字段回顾:
| 字段 | 本页的值 | 说明 |
|---|---|---|
DATE_MODEL / VALUE_MODEL | jev-1.14.0 | 官方录制:日期 jev-1.12、值 jev-1.14.0;live 跑锁你自己的版本 ID,别用会漂移的 jev-latest |
TODAY | date(2026, 7, 30) | 固定「今天」让相对日期可复现;自己跑换 date.today() 或跑批前统一钉死 |
REVIEW_BELOW | 0.60 | 日期置信度复核线;按风险调档 |
YEAR_WINDOW | 1900–2050 + none / out_of_range | 年份选项窗口加两个逃生舱 |
| 三个正则 | EMAIL_RE / PHONE_RE / MONEY_RE | 召回优先;候选去重、按文档序 |
| Choice 选项上限 | 255 | 超了先挑区块再挑 span(见 C4) |
| 每文档请求数 | 日期 1 次(七题并行);每个值 1–3 次(pick / classify / is_true) | 全部走缓存,换文档/改题才 miss |
相关教程:
- 级联提取:便宜模型干活、Jev 核查、贵模型兜底——本页管道的工业化版本:字段多了怎么省掉大模型的逐字段推理
- 搜索与匹配:重排序、逐行搜索、实体对齐——候选超过 255 时的两段收缩实战,218 行文档逐行语义搜索
- Confidence 置信度——0.60 这条复核线的原理,和「高自动执行 / 中标记 / 低转人工」的三档用法
下一课预告
字段抽好了,下一个问题是被丢掉格式的文本怎么办。下一课:结构恢复:把纯文本还原成 Markdown。
你会学到:
- 两次请求把丢了格式的纯文本还原成 Markdown:第一遍每对相邻行一道 Noul,拼回被硬换行切断的句子;第二遍每个块一道 Choice,分成标题 / 段落 / 列表 / 引用 / 代码 / 提示框
- 为什么输出的每个字符都来自输入——模型只做判断,渲染全是代码,重写文本的模型会改你的字
- 和本页同款分工:模型读文本、代码干活,只是这次要恢复的不是值而是结构