OvisOCR2 负责把扫描件还原成带版面结构的块,但结构化之后的字段能不能直接进业务库,要分两步看:这个字段在解析结果里有没有稳定的来源块,以及它的对错能不能用规则判定。比较稳的分法是,模型输出只到“文本 + 块类型 + 坐标”为止,格式类的判断交给后处理脚本挡在入库之前,语义上的对错交给一层人工抽检。这三层各管一段,不要让脚本去猜语义,也不要让人工去补格式。
适用场景是单据类文档批量结构化后写业务表。操作上先做字段映射表,标出没有来源块的字段;再写只做格式校验的脚本放在入库前;脚本判不了的语义问题由抽检兜住,触发条件写进配置。验证方式是拿一批固定样本反复跑,看失败项是否收敛。边界是:抽检只覆盖被触发的那部分,未触发的语义错误,脚本同样看不见。
先定模型输出到业务字段的映射
模型边界划在“还原”为止:它给出每个块的文本、类型(标题、键值对、表格单元格、页脚段落)和坐标,不判断这个值在业务上是否合理。业务需要的值,要逐个在解析结果里找落点,找不到的要主动标出来,而不是让脚本去文本里猜。
| 业务字段 | 解析结果中的来源块 | 是否有对应块 |
|---|---|---|
| 单据号 | 键值对块 key 命中,或页眉文本正则 | 有,位置不固定 |
| 开票日期 | 键值对块的 value | 有 |
| 金额合计 | 表格末行或“合计”锚点行 | 有 |
| 明细行 | 表格主体行,按表头列名映射 | 有,列序需校正 |
| 销方、购方名称 | 键值对块,或标题附近文本块 | 有,多来源需定优先级 |
| 币种 | 金额字符串中的符号 | 无独立块,需派生 |
| 备注 | 页脚段落块 | 有,非结构化 |
| 业务系统客户编码 | — | 无,按名称查档案 |
| 审批状态 | — | 无,来自业务系统 |
表里“无对应块”的两行要单独处理。业务系统客户编码在解析结果里没有对应块,只能拿购方名称去档案库做匹配,匹配不上就留空并打标记,不要用模型文本硬凑一个编码。审批状态本来就属于业务系统自身的数据,和文档内容无关,不该放进模型的输出范围。
有对应块但位置不固定的字段(单据号、日期),映射时要写清楚定位顺序:先找键值对里 key 命中的项,找不到再退回页眉区域的正则,两条都不中就当空值处理。币种这类派生字段在映射表里单独标注,由脚本按金额字符串解析,不指望模型单独给出一个块。
写字段校验脚本
脚本放在模型输出落库之后、写业务表之前,输入是解析结果的 JSON 记录,输出是失败列表。它只做格式判断,不做业务推理:空值、类型、枚举、长度四类规则能覆盖大部分入库前的低级错误。规则用配置描述,换字段不用改代码。
import decimal, datetime
RULES = {
‘doc_no’: {‘required’: True, ‘type’: ‘str’, ‘max_len’: 32},
‘issue_date’: {‘required’: True, ‘type’: ‘date’, ‘format’: ‘%Y-%m-%d’},
‘total’: {‘required’: True, ‘type’: ‘decimal’},
‘currency’: {‘required’: False, ‘enum’: [‘CNY’, ‘USD’, ‘EUR’]},
‘buyer’: {‘required’: True, ‘type’: ‘str’, ‘max_len’: 64},
}
def validate(record, rules):
errors = []
for field, rule in rules.items():
value = record.get(field)
text = ‘’ if value is None else str(value).strip()
if rule.get(‘required’) and text == ‘’:
errors.append({‘field’: field, ‘rule’: ‘required’, ‘value’: value})
continue
if text == ‘’:
continue
if rule.get(‘type’) == ‘decimal’:
try:
decimal.Decimal(text.replace(‘,’, ‘’))
except decimal.InvalidOperation:
errors.append({‘field’: field, ‘rule’: ‘type:decimal’, ‘value’: value})
continue
elif rule.get(‘type’) == ‘date’:
try:
datetime.datetime.strptime(text, rule.get(‘format’, ‘%Y-%m-%d’))
except ValueError:
errors.append({‘field’: field, ‘rule’: ‘type:date’, ‘value’: value})
continue
if ‘enum’ in rule and text not in rule[‘enum’]:
errors.append({‘field’: field, ‘rule’: ‘enum’, ‘value’: value})
if ‘max_len’ in rule and len(text) > rule[‘max_len’]:
errors.append({‘field’: field, ‘rule’: ‘max_len’, ‘value’: value})
return errors
def report(record_id, errors):
if not errors:
return True
for e in errors:
print(‘[REJECT] record={} field={} rule={} value={!r}’.format(
record_id, e[‘field’], e[‘rule’], e[‘value’]))
return False
失败原因按行打印,一条错误一行,带记录 ID、字段名、命中的规则和原始值,方便直接 grep 日志。原始值不要截断,后面回流样本要用它复现问题。
设一层人工抽检
脚本判断不了的是语义:金额格式对但和明细加总不一致、客户名称写成了别家、同一张单据跨页时合计算了两遍。这类问题只能靠人看,但抽检要有触发条件,不能凭感觉抽样。
- 字段为空:required 规则未覆盖但业务上必填的字段,或映射表里标注“无对应块”的字段最终留空。
- 格式不符:校验脚本返回任意一条错误,记录进抽检队列,而不是直接丢弃。
- 同一单号多页结果冲突:按单号分组后,比较各页的合计金额、日期、销方名称,任一不一致就整组进抽检;只在各页都取到该字段时才比较,缺失的页不算冲突。
def need_review(errors, same_doc_records):
if errors:
return ‘format_or_empty’
for field in (‘total’, ‘issue_date’, ‘seller’):
values = {r.get(field) for r in same_doc_records if r.get(field)}
if len(values) > 1:
return ‘cross_page_conflict:’ + field
return None
抽检结果要落回记录上:标记通过,或写入修正后的值,修正值单独存一列,不覆盖模型原始输出。抽检比例和队列积压上限结合单据量和可投入的人力定,先按当前量跑一段时间再调,不要在没数据支撑时直接拍一个比例。
把校验失败回流成样本
每次失败都存成一个可复跑的样本目录,而不是只留一行日志。样本至少要存这些字段:
sample_id:时间戳加记录 ID,保证可排序file_hash:原始文件哈希,用来识别同一份文件反复失败model_version、rule_version:哪套模型输出和哪套校验规则跑出来的raw_parse:模型原始解析结果 JSON,不做裁剪failed_rules:命中的字段与规则名expected:人工确认后的正确值,抽检前可以为空source:来自脚本拒绝还是人工抽检
samples/
20240601-1032-rd8801/
raw_parse.json
meta.json
expected.json
origin.pdf
复跑时按目录遍历,同一批样本每次全量校验,输出到一份新的报告文件,不要覆盖上一轮结果:
python -m ocr_validate replay `--samples` samples/ `--rules` rules.yaml `--out` report/replay-001.jsonl
改规则或换模型版本时各跑一遍,对比两次报告里新增和消失的失败字段,新增项单独列出来看,避免改一处规则带出另一处退化。expected 为空的样本只验证流程能否跑通、不做对错判定,人工补完正确值后再纳入对比,这样同一批样本才能反复验证。