baodan/api/insurance/document/ocr.py

136 lines
4.7 KiB
Python
Raw Normal View History

前工程开发已经推进到 Phase 6 基础能力,但正式验收还没有完成。更准确地说:Phase 0~5 的主要代码链路已经落地,Phase 6 完成了治理框架,尚缺生产化和真实数据验收。 阶段 当前状态 说明 Phase 0~3 基本完成 Document IR、证据链、人工确认、不可变 PlanData Snapshot、海报/PPT 投影已实现 Phase 4 代码完成 场景、策略、模板版本,校验/发布门禁,确定性 resolver 和严格槽位合并已实现 Phase 5 代码完成 输入冻结、PPTX/海报对账、失败关闭、幂等、心跳、重试和冻结输入重放已实现 Phase 6 基础完成 质量看板、结构化告警、Golden 审批、留存清理、孤儿检查和灰度开关已实现 正式上线 未完成 缺真实样本、生产模板、业务规则签字和灰度观察 目前验证基线: PPT/海报专项测试:107 passed, 1 skipped Vue TypeScript 检查:通过 前端生产构建:通过 代码变更仍在工作区,尚未提交 仓库全量测试仍有既有失败/挂起项,暂时不能宣称全仓测试完全绿色 仍未完成的代码任务主要有: 影子解析差异流水线 目前有灰度开关,但还没有完整的“新旧解析同时运行、字段差异入库、按保司/profile 聚合”的影子比较任务。 自动视觉回归 目前实现的是 DOM 模块、溢出、尺寸、文本和数值检查;还缺基于真实模板和标准图片的像素差异、字体缺失、遮挡和裁切回归。 告警通道接入 后台已经能产生结构化质量告警,但尚未自动推送到邮件、企微或其他通知通道。 留存任务生产化 dry-run、实删服务和失败审计已经具备,但尚未接入周期性 Celery/定时任务,也没有自动重试失败清理批次。 旧链路最终下线 旧 PPT 解析器和海报紧凑解析仍保留为回滚路径。需要全量灰度稳定后才能删除或彻底关闭写入口。 全仓测试收口 需要处理现有无关失败和挂起测试,建立真正全绿的 CI 基线。 仍需外部输入和生产环境完成的事项: 至少 30 份脱敏 Golden PDF,并完成双人标注和精确率验收。 业务专家确认派生公式、缺失值、可比较性和结论策略。 上传并标注真实生产 PPTX 的语义 shape、页面类型和容量。 安装生产字体并建立视觉基准图片。 实际执行数据库迁移 038~040。 完成留存 dry-run、实删演练以及 10% → 30% → 100% 灰度。 观察期通过后开启 SCENARIO_ENGINE_V2,目前它仍默认关闭;真实清理开关也默认关闭。 完整状态记录在 [PPT与海报Phase4至6补充实施记录](D:/work/code/python/coding/baodanagent/docs/PPT与海报Phase4至6补充实施记录_20260802.md)。
2026-08-02 16:34:06 +08:00
"""按页预算执行 Tesseract OCR并保留真实词块坐标。"""
from __future__ import annotations
import csv
import io
import os
import shutil
import subprocess
import tempfile
from collections import defaultdict
def default_ocr_provider():
return tesseract_ocr_blocks if shutil.which("tesseract") else None
def tesseract_ocr_blocks(pdf_path: str, page_numbers: list[int]) -> dict[int, list[dict]]:
executable = shutil.which("tesseract")
if not executable or not page_numbers:
return {}
language = _available_language(executable)
results: dict[int, list[dict]] = {}
scale = 3.0
with tempfile.TemporaryDirectory(prefix="insurance-document-ocr-") as temp_dir:
rendered = _render_pages(pdf_path, page_numbers, temp_dir, scale)
for page_number, image_path in rendered.items():
completed = subprocess.run(
[executable, image_path, "stdout", "-l", language, "--psm", "4", "--oem", "3", "tsv"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=90,
check=False,
)
if completed.returncode != 0 or not completed.stdout.strip():
continue
blocks = _tsv_to_blocks(completed.stdout, scale)
if blocks:
results[page_number] = blocks
return results
def _render_pages(pdf_path: str, page_numbers: list[int], temp_dir: str, scale: float) -> dict[int, str]:
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
rendered = {}
with fitz.open(pdf_path) as document:
for page_number in page_numbers:
if page_number < 1 or page_number > len(document):
continue
page = document[page_number - 1]
pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), colorspace=fitz.csGRAY)
image_path = os.path.join(temp_dir, f"page-{page_number}.png")
pixmap.save(image_path)
rendered[page_number] = image_path
return rendered
except ImportError:
pass
try:
import pypdfium2 as pdfium
except ImportError:
return {}
rendered = {}
document = pdfium.PdfDocument(pdf_path)
try:
for page_number in page_numbers:
if page_number < 1 or page_number > len(document):
continue
page = document[page_number - 1]
image = page.render(scale=scale, grayscale=True).to_pil()
image_path = os.path.join(temp_dir, f"page-{page_number}.png")
image.save(image_path)
rendered[page_number] = image_path
page.close()
finally:
document.close()
return rendered
def _available_language(executable: str) -> str:
try:
completed = subprocess.run(
[executable, "--list-langs"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
check=False,
)
available = set(completed.stdout.lower().split())
except Exception:
available = set()
languages = [name for name in ("chi_tra", "chi_sim", "eng") if name in available]
return "+".join(languages) or "eng"
def _tsv_to_blocks(payload: str, scale: float) -> list[dict]:
grouped = defaultdict(list)
reader = csv.DictReader(io.StringIO(payload), delimiter="\t")
for row in reader:
text = str(row.get("text") or "").strip()
if not text or str(row.get("level") or "") != "5":
continue
try:
confidence = float(row.get("conf") or -1)
left = float(row.get("left") or 0) / scale
top = float(row.get("top") or 0) / scale
width = float(row.get("width") or 0) / scale
height = float(row.get("height") or 0) / scale
except (TypeError, ValueError):
continue
if confidence < 0 or width <= 0 or height <= 0:
continue
key = (row.get("block_num"), row.get("par_num"), row.get("line_num"))
grouped[key].append((left, top, left + width, top + height, text, confidence))
blocks = []
for words in grouped.values():
words.sort(key=lambda item: item[0])
blocks.append({
"bbox": [
min(word[0] for word in words),
min(word[1] for word in words),
max(word[2] for word in words),
max(word[3] for word in words),
],
"text": " ".join(word[4] for word in words),
"confidence": round(sum(word[5] for word in words) / len(words) / 100.0, 4),
})
return blocks