baodan/api/insurance/document/service.py

212 lines
9.1 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
"""Document IR 摄取、持久化与授权查询。"""
from __future__ import annotations
import json
import os
from insurance.db.compat import db
from insurance.document.ingest import IngestResult, ingest_pdf
from insurance.models.insurance_document import InsuranceDocument, InsuranceDocumentPage, InsuranceDocumentTable
def ingest_and_persist(file_path: str, user_id: str, *, original_name: str = "", **ingest_options) -> InsuranceDocument:
if "ocr_provider" not in ingest_options:
from insurance.document.ocr import default_ocr_provider
ingest_options["ocr_provider"] = default_ocr_provider()
result = ingest_pdf(file_path, storage_key=os.path.abspath(file_path), **ingest_options)
try:
return persist_ingest_result(result, user_id, original_name=original_name or os.path.basename(file_path))
except Exception:
db.session.rollback()
raise
def ingest_for_processing(file_path: str, user_id: str, *, original_name: str = "", **ingest_options):
"""摄取并持久化,同时把本次不可变 IR 返回给当前解析任务。"""
if "ocr_provider" not in ingest_options:
from insurance.document.ocr import default_ocr_provider
ingest_options["ocr_provider"] = default_ocr_provider()
result = ingest_pdf(file_path, storage_key=os.path.abspath(file_path), **ingest_options)
try:
document = persist_ingest_result(
result,
user_id,
original_name=original_name or os.path.basename(file_path),
)
except Exception:
db.session.rollback()
raise
return document, result
def persist_ingest_result(result: IngestResult, user_id: str, *, original_name: str) -> InsuranceDocument:
ir = result.document_ir
metadata = ir["document"]
document = InsuranceDocument.query.filter_by(user_id=str(user_id), sha256=metadata["sha256"]).first()
if document is None:
document = InsuranceDocument(user_id=str(user_id), sha256=metadata["sha256"])
db.session.add(document)
document.storage_key = result.storage_key
document.original_name = os.path.basename(str(original_name or "document.pdf"))[:255]
document.mime_type = metadata["mimeType"]
document.file_size = os.path.getsize(result.storage_key)
document.page_count = metadata["pageCount"]
document.document_type = "plan_illustration"
document.pdf_kind = metadata["pdfKind"]
document.status = metadata["status"]
document.parser_version = metadata["parserVersion"]
document.ocr_version = metadata.get("ocrVersion")
document.profile_code = metadata.get("profileCode")
document.profile_version = metadata.get("profileVersion")
document.quality_json = json.dumps(ir["quality"], ensure_ascii=False)
document.processing_log_json = json.dumps(result.processing_log, ensure_ascii=False)
db.session.flush()
InsuranceDocumentPage.query.filter_by(document_id=document.id).delete(synchronize_session=False)
InsuranceDocumentTable.query.filter_by(document_id=document.id).delete(synchronize_session=False)
for page in ir["pages"]:
db.session.add(InsuranceDocumentPage(
document_id=document.id,
page_number=page["pageNumber"],
width=page["width"],
height=page["height"],
native_text_quality=page.get("nativeTextQuality"),
ocr_quality=page.get("ocrQuality"),
page_class=page["pageClass"],
text_blocks_json=json.dumps(page.get("textBlocks") or [], ensure_ascii=False),
ocr_blocks_json=json.dumps(page.get("ocrBlocks") or [], ensure_ascii=False),
images_json="[]",
))
for table in ir["tables"]:
db.session.add(InsuranceDocumentTable(
document_id=document.id,
table_id=table["tableId"],
scenario_type=table.get("scenarioType") or "unknown",
headers_json=json.dumps(table.get("headers") or [], ensure_ascii=False),
rows_json=json.dumps(table.get("rows") or [], ensure_ascii=False),
source_pages_json=json.dumps(table.get("sourcePages") or [], ensure_ascii=False),
continuation_of=table.get("continuationOf"),
bbox_json=json.dumps(table.get("bbox"), ensure_ascii=False),
conflicts_json=json.dumps(table.get("conflicts") or [], ensure_ascii=False),
))
db.session.commit()
return document
def get_owned_document(document_id: int, user_id: str) -> InsuranceDocument | None:
return InsuranceDocument.query.filter_by(id=document_id, user_id=str(user_id)).first()
def get_owned_page(document_id: int, page_number: int, user_id: str) -> tuple[InsuranceDocument | None, InsuranceDocumentPage | None]:
document = get_owned_document(document_id, user_id)
if not document:
return None, None
page = InsuranceDocumentPage.query.filter_by(document_id=document.id, page_number=page_number).first()
return document, page
def load_document_ir(document: InsuranceDocument) -> dict:
"""从持久化记录恢复不可变 Document IR供异步提取任务消费。"""
pages = InsuranceDocumentPage.query.filter_by(document_id=document.id).order_by(
InsuranceDocumentPage.page_number
).all()
tables = InsuranceDocumentTable.query.filter_by(document_id=document.id).order_by(
InsuranceDocumentTable.id
).all()
page_payloads = []
for item in pages:
payload = item.to_public_dict()
payload.pop("documentId", None)
payload.pop("images", None)
page_payloads.append(payload)
table_payloads = []
for item in tables:
payload = item.to_public_dict()
payload.pop("documentId", None)
table_payloads.append(payload)
try:
quality = json.loads(document.quality_json or "{}")
except (TypeError, ValueError):
quality = {}
quality.setdefault("nativePageCount", sum(1 for item in pages if item.page_class == "native"))
quality.setdefault("ocrPageCount", sum(1 for item in pages if item.ocr_blocks_json not in (None, "", "[]")))
quality.setdefault("lowQualityPages", [
item.page_number for item in pages if item.page_class in {"scanned", "low_quality"}
])
return {
"schemaVersion": "document-ir-v1",
"document": {
"sha256": document.sha256,
"mimeType": document.mime_type,
"pageCount": document.page_count,
"pdfKind": document.pdf_kind,
"status": document.status,
"parserVersion": document.parser_version,
"ocrVersion": document.ocr_version,
"profileCode": document.profile_code,
"profileVersion": document.profile_version,
},
"pages": page_payloads,
"tables": table_payloads,
"quality": quality,
}
def find_owned_evidence(document_id: int, user_id: str, field_path: str) -> tuple[InsuranceDocument | None, list[dict]]:
document = get_owned_document(document_id, user_id)
if not document:
return None, []
try:
from insurance.models.plan_snapshot import InsuranceFieldEvidence, InsurancePlanSnapshot
structured = db.session.query(InsuranceFieldEvidence).join(
InsurancePlanSnapshot,
InsurancePlanSnapshot.id == InsuranceFieldEvidence.snapshot_id,
).filter(
InsurancePlanSnapshot.user_id == str(user_id),
InsuranceFieldEvidence.document_id == document_id,
InsuranceFieldEvidence.field_path == field_path,
).order_by(InsuranceFieldEvidence.id.desc()).limit(200).all()
if structured:
return document, [item.to_public_dict() for item in structured]
except Exception:
db.session.rollback()
entries = []
from insurance.models.ppt_session import PptSession
sessions = PptSession.query.filter_by(user_id=str(user_id)).order_by(PptSession.updated_at.desc()).limit(100).all()
for session in sessions:
try:
extractions = json.loads(session.extractions_json or "[]")
except (TypeError, ValueError):
continue
for extraction in extractions:
if _safe_int(extraction.get("documentId")) == document_id:
entries.extend(extraction.get("evidence") or [])
from insurance.models.poster_case_upload import PosterCaseUpload
cases = PosterCaseUpload.query.filter_by(user_id=str(user_id)).order_by(PosterCaseUpload.created_at.desc()).limit(100).all()
for case in cases:
try:
parsed = json.loads(case.parsed_data or "{}")
except (TypeError, ValueError):
continue
meta = parsed.get("meta") or {}
if _safe_int(meta.get("documentId")) == document_id:
entries.extend(meta.get("evidence") or [])
unique = {}
for entry in entries:
if not isinstance(entry, dict) or entry.get("fieldPath") != field_path:
continue
key = json.dumps(entry.get("evidence") or {}, sort_keys=True, ensure_ascii=False)
unique[key] = entry
return document, list(unique.values())
def _safe_int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None