结构化提取

提取到 JSON 不等于提取正确:给金额、单位和出处建立字段契约

用 Decimal 和字段契约核对金额、页号、原文与坐标,附正常和异常输入的确定性测试。

阅读文本 / 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