预解析值抽取
正则找出候选值,象信选出问题要的那一个,代码原样复制。
一篇政府采购中标公告里通常有两三个电话、七八个金额,一份部委征求意见通知里常常有两位联系人、各自的电话、传真和邮箱。要从中取出「采购代理机构的电话」「代理服务费」「环境规划院联系人的邮箱」,难点不在于认出什么是电话号码——正则就能做到——而在于认出哪一个号码扮演了问题里的角色。
本篇把这件事拆成三步:
- 正则找候选。 正则调得宁多勿漏,把文中所有像邮箱、电话、金额的片段都找出来。
- 象信做选择。 一道 Choice,选项就是这些候选片段本身,再加一个
none("候选里没有要找的值")。需要时再顺带问一个属性,例如不带区号的座机属于哪个城市。 - 代码复制并规范化。 把选中的片段原样拿过来,转成 E.164 电话号码、以元为单位的
Decimal金额或小写邮箱。
因为象信只能在正则找到的片段之间做选择,最后拿到的值一定是原文里的某个片段,不会凭空编出一个号码,也不会抄错一位数字。
一句话结论(全部来自 2026-09-25 的真实运行):在 32 篇真实公告、134 个字段上,文中确有值的 112 个字段全部选对;「总是取第一个候选」的基线只对 63 个。置信度 ≥ 0.60 的 108 个答案自动通过,全部正确;剩下 26 个送人工复核,其中 22 个正是「文中没有这个值」的问题——象信在这类问题上答得不稳,但它的低置信度把它们都拦了下来。32 次调用共 30,190 个输入 token,约合 ¥0.0013。
环境准备
pip install xiangxin-sdk phonenumbers
export XIANGXIN_API_KEY="sk-xx-..."模型为 xiangxin-latest(本次运行全部返回 xiangxin-1.0.0)。
数据
两类真实公开文本,2026-09-25 抓取,只去掉了网页导航和页脚:
| 来源 | 篇数 | 我们要取的字段 |
|---|---|---|
| 北京市政府采购网 市级、区级中标(成交)公告 | 14 | 中标总金额、代理服务费、采购人电话、代理机构电话、中标供应商电话(文中没有) |
| 北京市政府采购网 公开招标公告 | 3 | 预算金额、采购人 / 代理机构电话、保证金发票咨询电话、技术支持热线、两个用途不同的邮箱 |
| 生态环境部 公开征求意见的通知 / 函(2026 年 8–9 月) | 15 | 联系人邮箱、电话、传真(部分文中没有);两位联系人时按单位分别取 |
合计 134 个字段:电话 79 个、金额 31 个、邮箱 24 个;其中 22 个字段文中没有值(例如中标公告从不公布中标供应商的电话,有的通知没有传真),正确答案是 none。期望值由本文作者对照原文人工标注;一个联系人列了两个号码时(如「(010)65645472、65645453」),两个都算对。文档与标注见 data/documents.json、data/fields.json。
这些公告的写法很不统一,正好考验正则和选择两步:
联系方式:肖老师,66052683 ← 座机不带区号
联系方式:周敏, 17310575369 ← 手机
联系方式:刘佳,67358115-8020 ← 带分机
联系方式:朱晨光、崔文峰、闻爽、高红梅、石罗庚,01064001063 ← 区号和号码连写
联系方式:单楠、……、张书玲,010-60624505转821/807
电话:(010)65645472、65645453 ← 一人两个号码,第二个省略区号第一步:正则,宁多勿漏
import re
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
PHONE_RE = re.compile(
r"(?<![\dA-Za-z])(?:"
r"(?:[((]0\d{2,3}[))]\s*|0\d{2,3}\s*[--]\s*)?(?:1[3-9]\d{9}|[2-9]\d{6,7})" # 座机 / 手机,可带区号
r"|0\d{9,11}" # 区号和号码连写,如 01064001063
r"|400[--]?\d{3}[--]?\d{4}" # 400 热线
r")(?:(?:转|-)\d{1,4})?(?![\dA-Za-z])" # 分机号
)
MONEY_RE = re.compile(
r"[¥¥]\s?\d[\d,]*(?:\.\d+)?\s?(?:万?元)?" # ¥0 元
r"|(?<![\d.])\d[\d,]*(?:\.\d+)?\s?万?元" # 42.681057 万元、2万元
)
def find(pattern: re.Pattern, text: str) -> list[str]:
"""按出现顺序去重;去掉空白后相同的算同一个候选。"""
seen, out = set(), []
for m in pattern.finditer(text):
span = re.sub(r"\s+", "", m.group())
if span and span not in seen:
seen.add(span)
out.append(span)
return out前后的 (?<![\dA-Za-z])、(?![\dA-Za-z]) 让正则不去切 18 位统一社会信用代码或 20 位项目编号的中间几位。除此之外它有意放宽:招标公告里的「CA 数字证书服务热线」「电子营业执照服务热线」「技术支持服务热线」、「售价:¥0 元」、分包单价,统统进入候选。平均每个问题面对 2.4 个电话候选、2.9 个金额候选、1.4 个邮箱候选;最多的一篇(医用设备带量采购中标公告,三家供应商、多个分包)有 10 个金额候选。
第二步:象信只做选择
from xiangxin import Choice, XiangxinClient
NONE = "none"
CITIES = {"北京": "010", "上海": "021", "天津": "022", "重庆": "023", "广州": "020", "其他城市": None}
def pick_question(question: str, candidates: list[str]) -> Choice:
"""选项就是正则找到的原文片段,所以答案只可能是其中之一(或 none)。"""
return Choice(
instructions=question,
criteria={c: None for c in candidates} | {NONE: "候选中没有问题要找的值"},
)
CITY_QUESTION = Choice(
instructions="文中不带区号的固定电话号码,属于哪个城市?",
criteria={c: None for c in CITIES},
)一篇文档的所有字段放进同一次请求(见 并行提问):每个字段一道 pick_question,候选里有不带区号的座机时再加一道 CITY_QUESTION。这样 32 篇文档一共只发了 32 次请求、144 道题。
client = XiangxinClient()
def ask(document: str, questions: dict[str, Choice]) -> dict:
resp = client.system_one(state=document, questions=questions, model="xiangxin-latest")
return {k: {"choice": a.choice, "confidence": a.confidence} for k, a in resp.answers.items()}第三步:代码复制并规范化
from decimal import Decimal
import phonenumbers
def to_yuan(span: str) -> str:
"""'42.681057万元' -> '426810.57'(单位:元)。"""
number = Decimal(re.search(r"\d[\d,]*(?:\.\d+)?", span).group().replace(",", ""))
if "万" in span:
number *= 10000
return format(number.normalize(), "f")
def to_e164(span: str, area_code: str | None) -> str | None:
"""'(010)65646333' / '66052683' + 北京 -> '+861065646333';分机号去掉。"""
s = re.sub(r"(转\d{1,4}|(?<=\d{7})-\d{1,4})$", "", span)
digits = re.sub(r"\D", "", s)
if re.fullmatch(r"1[3-9]\d{9}", digits) or digits.startswith(("0", "400")):
number = digits
elif area_code: # 不带区号的座机,用象信读出的城市补上区号
number = area_code + digits
else:
return None
return phonenumbers.format_number(
phonenumbers.parse(number, "CN"), phonenumbers.PhoneNumberFormat.E164
)数字本身不会告诉你 66052683 在哪个城市,公告里的「北京市东城区中华路 4 号」会。象信读地址回答城市(本次 10 篇需要补区号的文档都答「北京」,置信度 0.90–0.98),代码用 phonenumbers 拼出 +861066052683。
三个例子
金额:十个候选里挑总额和代理费
「市属医院 2026 年医用设备集中带量采购放射组第三批中标公告」有三家中标供应商、多个分包,正则找到 10 个金额:
候选:['1291.8万元', '180.8万元', '431万元', '680万元', '45.2万元', '153万元',
'306万元', '125万元', '170万元', '14.12631万元']
中标(成交)总金额 -> 1291.8万元 规范化 12918000 元 置信 0.99
代理服务费总金额 -> 14.12631万元 规范化 141263.1 元 置信 0.98邮箱和电话:同一家代理机构,不同用途
「北京石油化工学院……复合双臂智能机器人开发平台公开招标公告」里,代理机构留了两个邮箱,一个「仅用于采购文件咨询」,一个管「保证金、发票等咨询」;电话候选有 7 个,包括平台的三条服务热线:
邮箱候选:['xy@zbbmcc.com', 'fc@zbbmcc.com']
对招标文件技术部分有疑问,应该发邮件到哪个邮箱? -> xy@zbbmcc.com 置信 0.97
保证金、发票等事宜应该发邮件到哪个邮箱? -> fc@zbbmcc.com 置信 0.96
电话候选:['010-58511086', '400-699-7000', '010-86483801', '010-82370045',
'010-81292071', '010-61196305', '010-82370045']
北京市政府采购电子交易平台的技术支持服务热线是哪个? -> 010-86483801 置信 0.90
采购人(采购单位)的联系电话是哪个? -> 010-81292071 置信 0.66
中标通知书领取、服务费发票、保证金……应该打哪个电话? -> 010-82370045 置信 0.51三个电话都选对了,但最后一个的置信度只有 0.51:010-82370045 在文中出现了两次(一次是半角连字符,一次是全角「-」),正则把它们当成两个候选,概率被两者分掉(0.57 对 0.42)。这正是选项要可区分的反面例子——在正则阶段把同一号码的不同写法合并,置信度就会回来。
两位联系人:电话和传真,谁的邮箱
生态环境部《地方餐饮油烟排放标准制订技术导则(征求意见稿)》征求意见通知列了两位联系人,各有电话、传真、邮箱:
大气环境司联系人的邮箱是哪个? -> dqszhc@mee.gov.cn 置信 0.97
环境标准研究所联系人的邮箱是哪个? -> wuzh01@craes.org.cn 置信 0.98
大气环境司联系人的联系电话(不是传真)是哪个? -> (010)65645551 置信 0.96另一份通知里,第二位联系人(辐射源安全监管司)只留了电话、没有邮箱,文中唯一的邮箱属于第一位联系人。问「辐射源安全监管司联系人的邮箱」,象信在一篇文档里答 none(置信 0.06),在另一篇几乎相同的文档里选了第一位联系人的邮箱(置信 0.12)。两次答案不同,但置信度都极低。
全部结果
| 字段数 | 正确 | |
|---|---|---|
| 全部 | 134 | 124 |
| 电话 | 79 | 70 |
| 金额 | 31 | 31 |
| 邮箱 | 24 | 23 |
| 文中有值 | 112 | 112 |
文中没有值(应答 none) | 22 | 12 |
| 基线:总是取第一个候选(仅文中有值的字段) | 112 | 63 |
逐字段看,所有「有值」的字段——中标总金额 14/14、代理费 14/14、采购人电话 17/17、代理机构电话 16/16、预算金额 3/3、各类邮箱——全部正确。10 个错误全部出在「文中没有值」的问题上:14 篇中标公告里问中标供应商的电话,7 篇答了 none,另外 7 篇选了代理机构的电话;9 篇只有一位联系人的通知里问传真,其中 6 篇没有传真,有 2 篇把联系电话当成了传真;再加上上文那个邮箱。
用置信度路由
REVIEW_BELOW = 0.60
needs_review = answer["confidence"] < REVIEW_BELOW| 数量 | 其中正确 | |
|---|---|---|
| 置信度 ≥ 0.60,自动通过 | 108 | 108 |
| 置信度 < 0.60,送人工复核 | 26 | 16 |
「文中没有值」的 22 个问题,置信度最高的也只有 0.48,全部落在复核队列——无论象信答的是 none 还是错选了一个号码。文中有值的 112 个字段里,只有 4 个低于 0.60(0.40、0.43、0.51、0.58),都是答对了但概率被分散的情况:同一联系人的两个号码、同一号码的两种写法。
这也说明了象信在这类任务上的一个特点:它更擅长「从几个候选里挑出对的那个」,不太擅长肯定地说「这里没有」。答 none 时概率通常摊在 none 和几个候选之间,置信度偏低。所以在自动化流程里,把低置信度的 none 当成「需要人看一眼」比当成「确认没有」更稳妥。
两个限制
- 一道 Choice 最多 255 个选项。候选更多时分两步:先选段落或表格行,再在那一段里选片段。
- 找候选是最费功夫的一步。邮箱、电话、金额有现成的正则;人名、单位名没有,候选只能来自已有名册、命名实体识别,或者让大模型先列出来,再交给象信挑。另外,正则里没有覆盖到的写法(例如大写金额「壹拾贰万元整」)永远不会成为候选——想支持它们,就在第一步加正则,而不是指望第二步。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/pre_parsed_value_extraction_cookbook

