阅读文本 / Reading text
把发票或报价单交给解析器,拿到一个合法 JSON,只能说明它可以被程序读取。
1200
是单价还是总额?币种在哪里?
1.200,50
应该按什么地区规则理解?返回的页号真的存在吗?这些都不是 JSON 语法能回答的问题。
本文用一个合成报价片段演示更严格的边界:字段不仅要有值,还要带单位、来源页、原文和坐标。解析器或模型只负责提出候选;确定性的校验器负责决定是否可以入库。缺失和不确定应当显式保留,不能默认填零。
这是下面程序的真实测试输出,不是识别准确率报告。示例输入的原文由我们手写,未对真实票据做 OCR 或模型评测。
将值与证据一起传递
字段
约束
为什么需要
amount
十进制字符串,示例限定非负且两位小数
避免无意引入二进制浮点近似
currency
本场景限定 USD
别在没有币种时偷偷假设人民币
source.page
从 1 开始,且不能超出页数
便于用户回到原文
source.quote
必须是调用方提供的页内片段
给复核者可见依据
source.bbox
有限数值,位于页面范围内
防止空框和无效坐标冒充证据
这里的坐标单位是 PDF point,原点在当前约定页面的左上角。应由上游先统一旋转、裁切及坐标系,不能把不同解析器的坐标直接混用。这个示例只演示约定,不证明框里确实包含该文字。
完整校验与拒绝测试
只用 Python 标准库即可运行。
Decimal
从字符串构造;不要先变成
float
再传进去。金额的精确十进制处理可参考
Python decimal 文档
。
from copy import deepcopy
from decimal import Decimal
import math
import re
pages = [{"width": 595, "height": 842,
"text": "Total: USD 1,200.50\nDelivery within 10 days"}]
def validate(candidate, pages):
if set(candidate) != {"amount", "currency", "source"}:
raise ValueError("unexpected or missing field")
raw = candidate["amount"]
if not isinstance(raw, str) or not re.fullmatch(r"(?:0|[1-9][0-9]*)\.[0-9]{2}", raw):
raise ValueError("amount must be a two-decimal string")
if candidate["currency"] != "USD":
raise ValueError("unsupported currency for this contract")
src = candidate["source"]
if not isinstance(src, dict) or set(src) != {"page", "quote", "bbox"}:
raise ValueError("source contract mismatch")
number = src["page"]
if type(number) is not int or not 1 <= number <= len(pages):
raise ValueError("page outside document")
page = pages[number - 1]
quote = src["quote"]
if not isinstance(quote, str) or not quote or quote not in page["text"]:
raise ValueError("source quote not found")
match = re.fullmatch(r"Total: USD ([0-9]{1,3}(?:,[0-9]{3})*\.[0-9]{2})", quote)
if not match or Decimal(match[1].replace(",", "")) != Decimal(raw):
raise ValueError("amount does not agree with source")
box = src["bbox"]
if (not isinstance(box, list) or len(box) != 4
or any(type(v) not in (int, float) or not math.isfinite(v) for v in box)):
raise ValueError("invalid box values")
x0, y0, x1, y1 = box
if not (0 <= x0 < x1 <= page["width"] and 0 <= y0 < y1 <= page["height"]):
raise ValueError("box outside page or empty")
return {"amount": str(Decimal(raw)), "currency": "USD", "source": deepcopy(src)}
good = {"amount": "1200.50", "currency": "USD", "source": {
"page": 1, "quote": "Total: USD 1,200.50", "bbox": [40, 70, 260, 90]}}
assert validate(good, pages)["amount"] == "1200.50"
print("PASS valid source-linked amount: USD 1200.50")
bad_cases = []
wrong = deepcopy(good); wrong["amount"] = "12000.50"; bad_cases.append(wrong)
wrong = deepcopy(good); wrong["source"]["page"] = 2; bad_cases.append(wrong)
wrong = deepcopy(good); wrong["source"]["bbox"][2] = float("nan"); bad_cases.append(wrong)
wrong = deepcopy(good); wrong["source"]["bbox"][0] = 300; bad_cases.append(wrong)
for index, wrong in enumerate(bad_cases, 1):
try:
validate(wrong, pages)
raise AssertionError("bad candidate accepted")
except ValueError as error:
print(f"PASS rejection {index}: {error}")
正则严格,是因为场景严格
这个正则只接受示例中的英文金额格式。它会拒绝欧式小数、负数、括号表示的退款、其他币种和没有千分位的四位数原文。真实系统应按文档类型和地区选择独立契约,不能随手把所有逗号、点和空格删掉。
进一步扩大输入范围前,先为每一种允许的格式加正向样例和拒绝样例。如果是多币种账单,字段中应分别保留原币金额、币种以及汇率来源;没有汇率就不能把金额偷偷转换。
“有出处”也不等于“已证明”
程序验证了 quote 存在于当前页文字中,并且金额与这个受限格式相等;它没有验证坐标框精确包住原文,也没有判断该行是不是业务上真正的最终总额。上游文字层可能来自错误 OCR,同一页也可能出现多个 Total。
因此,字段契约只是第一道门。后续还需要定位一致性、同页候选消歧、小计税额总额关系,以及人工抽检。重要的业务数据在这些门没有通过前,应以“待核对”而不是“已确认”状态保存。金额缺失、验证失败和确实为零是三种不同状态。
在文档工作流里如何落地
保留原文件摘要,给每次提取记录处理版本,并保存拒绝原因。翻译流程也应使用相同的字段审计:正文可以换语言,重要金额与单位的对应关系不能丢失。你可以通过
OmniDoc 产品入口
了解相关文档服务;本文的契约和代码独立于产品 API,不宣称已有未公开接口。
最终交付应当让人能从一个字段追溯回一页原文。JSON 是交换格式,契约、证据和验收才决定这份数据能否使用。
- 1 -
更新于 2026-10-09