阶段 当前状态 说明 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)。
136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""按页预算执行 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
|