阶段 当前状态 说明 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)。
212 lines
9.1 KiB
Python
212 lines
9.1 KiB
Python
"""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
|