baodan/api/insurance/document/service.py
wsb1224 2422303b36 前工程开发已经推进到 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

212 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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