baodan/tests/document_ir_test.py

419 lines
15 KiB
Python

import importlib.util
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
def test_pdf_classifier_uses_page_signals_and_quality_budget():
from insurance.document.pdf_classifier import PageSignal, classify_pdf, plan_ocr_pages
signals = [
PageSignal(1, 800, 0.95, 0.05, 10_000),
PageSignal(2, 0, 0.0, 0.98, 10_000),
PageSignal(3, 20, 0.3, 0.2, 10_000),
]
assert classify_pdf(signals) == "mixed"
plan = plan_ocr_pages(signals, max_pages=1, max_pixels=100_000)
assert plan["selectedPages"] == [2]
assert plan["skippedPages"] == [3]
assert plan["status"] == "partial"
def test_layout_preserves_real_bbox_and_stable_reading_order():
from insurance.document.layout import order_text_blocks
blocks = order_text_blocks([
(200, 10, 280, 30, "右侧", 0, 0),
(10, 10, 100, 30, "左侧", 0, 0),
(-5, 40, 120, 60, "下一行", 0, 0),
], 1, 300, 400)
assert [item["text"] for item in blocks] == ["左侧", "右侧", "下一行"]
assert blocks[2]["bbox"] == [0.0, 40.0, 120.0, 60.0]
assert all(item["id"].startswith("p1-b") for item in blocks)
def test_table_rows_do_not_merge_only_by_policy_year_and_keep_conflicts():
from insurance.document.tables import merge_row_candidates
rows, conflicts = merge_row_candidates([
{"tableId": "t1", "scenarioType": "base", "policyYear": 10, "rowVariant": "before", "value": 100},
{"tableId": "t1", "scenarioType": "base", "policyYear": 10, "rowVariant": "after", "value": 80},
{"tableId": "t1", "scenarioType": "optimistic", "policyYear": 10, "rowVariant": "before", "value": 130},
{"tableId": "t1", "scenarioType": "base", "policyYear": 20, "rowVariant": "before", "value": 200},
{"tableId": "t1", "scenarioType": "base", "policyYear": 20, "rowVariant": "before", "value": 210},
])
assert len(rows) == 5
assert len(conflicts) == 1
assert sum(1 for row in rows if row.get("status") == "conflict") == 2
def test_cross_page_table_continuation_requires_geometry_and_header_match():
from insurance.document.tables import link_table_continuations
linked = link_table_continuations([
{
"tableId": "t1", "scenarioType": "base", "sourcePages": [3],
"headers": [{"text": "年度"}, {"text": "退保价值"}],
"bbox": [20, 100, 500, 700], "columnX": [20, 200],
},
{
"tableId": "t2", "scenarioType": "base", "sourcePages": [4],
"headers": [{"text": "年度"}, {"text": "退保价值"}],
"bbox": [21, 80, 501, 700], "columnX": [21, 201],
},
])
assert linked[1]["continuationOf"] == "t1"
def test_evidence_ref_matches_frozen_schema_and_supports_reverse_index():
import jsonschema
from insurance.document.evidence import EvidenceIndex, build_evidence_record, build_evidence_ref
evidence = build_evidence_ref(7, 3, [10, 20, 80, 40], "HK$ 100,000", table_id="t1", row_id="r1", column_id="c2")
schema = json.loads(Path("docs/schemas/field-value.schema.json").read_text(encoding="utf-8"))
jsonschema.validate({"status": "extracted", "value": 100000, "currency": "HKD", "evidence": [evidence]}, schema)
record = build_evidence_record("policy.annualPremium", evidence, "HK$ 100,000")
index = EvidenceIndex([record])
assert index.for_field("policy.annualPremium") == [record]
assert index.fields_for_region(7, 3, [10, 20, 80, 40]) == ["policy.annualPremium"]
assert record["snippet"] == "HK$ 100,000"
def test_extraction_evidence_only_uses_value_found_in_real_page_blocks():
from insurance.document.evidence import map_extraction_evidence
document_ir = {
"pages": [{
"pageNumber": 2,
"textBlocks": [
{"id": "p2-b1", "text": "Annual Premium HK$ 100,000", "bbox": [20, 40, 240, 60], "source": "native", "confidence": None},
{"id": "p2-b2", "text": "Policy Year 10 Total Surrender 350,000", "bbox": [20, 100, 300, 120], "source": "native", "confidence": None},
],
"ocrBlocks": [],
}],
}
data = {
"policy": {"annual_premium": 100000, "sum_insured": 999999},
"benefit_illustration": [{
"policy_year": 10,
"total_surrender_value": 350000,
"source_page": 2,
"table_id": "t1",
"scenario_type": "base",
}],
}
evidence = map_extraction_evidence(3, data, document_ir)
paths = {item["fieldPath"] for item in evidence}
assert "policy.annual_premium" in paths
assert "benefit.base.10.default.total_surrender_value" in paths
assert "policy.sum_insured" not in paths
assert all(item["evidence"]["bbox"] for item in evidence)
def test_extraction_evidence_maps_iul_account_and_death_benefit_columns():
from insurance.document.evidence import map_extraction_evidence
document_ir = {
"pages": [{
"pageNumber": 3,
"textBlocks": [{
"id": "p3-b1",
"text": "1 21,100 0 2,000,000",
"bbox": [20, 100, 520, 120],
"source": "native",
"confidence": None,
}],
"ocrBlocks": [],
}],
}
data = {
"benefit_illustration": [{
"policy_year": 1,
"non_guaranteed_account_value": 21100,
"non_guaranteed_death_benefit": 2000000,
"source_page": 3,
"scenario_type": "base",
}],
}
evidence = map_extraction_evidence(9, data, document_ir)
paths = {item["fieldPath"] for item in evidence}
assert "benefit.base.1.default.non_guaranteed_account_value" in paths
assert "benefit.base.1.default.non_guaranteed_death_benefit" in paths
assert all(item["evidence"]["pageNumber"] == 3 for item in evidence)
def test_document_ir_text_is_not_globally_truncated():
from insurance.ppt.extraction import _extract_document_text
long_text = "保单年度 退保价值 100000\n" * 8000
document_ir = {
"document": {"status": "parsed"},
"pages": [{
"pageNumber": 1,
"pageClass": "native",
"nativeTextQuality": 0.99,
"ocrQuality": None,
"textBlocks": [{"text": long_text}],
"ocrBlocks": [],
}],
}
text, _qualities, used_ir, status = _extract_document_text("unused.pdf", document_ir)
assert used_ir is True
assert status == "parsed"
assert len(text) > 120_000
assert text.endswith(long_text.rstrip())
def test_invalid_document_returns_structured_error(tmp_path):
from insurance.document.errors import DocumentIngestError
from insurance.document.ingest import ingest_pdf
invalid = tmp_path / "not-a-pdf.pdf"
invalid.write_bytes(b"plain text")
with pytest.raises(DocumentIngestError) as captured:
ingest_pdf(str(invalid))
payload = captured.value.to_dict()
assert payload["category"] == "quality"
assert payload["userMessage"] == payload["message"]
assert {key: payload[key] for key in ("code", "message", "retryable", "details")} == {
"code": "DOCUMENT_TYPE_INVALID",
"message": "文件内容不是有效 PDF",
"retryable": False,
"details": {"detectedMimeType": "application/pdf"},
}
def test_document_cache_key_changes_with_every_runtime_version():
from insurance.document.ingest import build_cache_key
base = build_cache_key(
"a" * 64,
parser_version="parser-1",
ocr_version="ocr-1",
profile_code="profile-a",
profile_version="1",
rules_version="rules-1",
model_version="model-1",
prompt_version="prompt-1",
)
dimensions = {
"parser_version": "parser-2",
"ocr_version": "ocr-2",
"profile_code": "profile-b",
"profile_version": "2",
"rules_version": "rules-2",
"model_version": "model-2",
"prompt_version": "prompt-2",
}
defaults = {
"parser_version": "parser-1",
"ocr_version": "ocr-1",
"profile_code": "profile-a",
"profile_version": "1",
"rules_version": "rules-1",
"model_version": "model-1",
"prompt_version": "prompt-1",
}
for key, changed in dimensions.items():
options = dict(defaults)
options[key] = changed
assert build_cache_key("a" * 64, **options) != base
def test_public_payload_removes_server_paths_recursively():
from insurance.utils.public_payload import strip_server_paths
cleaned = strip_server_paths({
"files": [{"name": "plan.pdf", "path": "D:/private/plan.pdf"}],
"output": {"filePath": "/app/private/output.pptx", "downloadUrl": "/download/1"},
})
assert cleaned == {
"files": [{"name": "plan.pdf"}],
"output": {"downloadUrl": "/download/1"},
}
def test_model_public_payloads_do_not_expose_local_paths():
from insurance.models.generation_task import GenerationTask
from insurance.models.poster_case_upload import PosterCaseUpload
from insurance.models.ppt_session import PptSession
session = PptSession(
id="session-1",
user_id="user-1",
files_json=json.dumps([{"name": "plan.pdf", "path": "D:/private/plan.pdf"}]),
extractions_json=json.dumps([{"pdfName": "plan.pdf", "pdfPath": "/app/private/plan.pdf"}]),
ppt_path="/app/private/output.pptx",
)
session_payload = session.to_dict()
assert "path" not in session_payload["files"][0]
assert "pdfPath" not in session_payload["extractions"][0]
assert "ppt_path" not in session_payload
assert session_payload["has_ppt"] is True
task = GenerationTask(
id="task-1",
user_id="user-1",
artifact_type="ppt",
operation="generate",
workspace_id="session-1",
output_json=json.dumps({"filePath": "/app/private/output.pptx", "downloadUrl": "/download/1"}),
)
assert task.to_dict()["output"] == {"downloadUrl": "/download/1"}
case = PosterCaseUpload(
id=1,
user_id="user-1",
product_id="product-1",
source_file_url="D:/private/plan.pdf",
)
case_payload = case.to_dict()
assert "sourceFileUrl" not in case_payload
assert case_payload["sourceFileAvailable"] is True
def test_migration_036_creates_document_ir_tables():
from flask import Flask
from sqlalchemy import inspect
from insurance.db.database import db
from insurance.db.migrate_036 import migrate
app = Flask(__name__)
app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db.init_app(app)
with app.app_context():
migrate()
tables = set(inspect(db.engine).get_table_names())
assert {
"insurance_documents",
"insurance_document_pages",
"insurance_document_tables",
} <= tables
def test_persisted_document_ir_can_be_reloaded_against_frozen_schema():
from flask import Flask
import jsonschema
from insurance.db.database import db
from insurance.db.migrate_036 import migrate
from insurance.document.service import load_document_ir
from insurance.models.insurance_document import InsuranceDocument, InsuranceDocumentPage
app = Flask(__name__)
app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db.init_app(app)
with app.app_context():
migrate()
document = InsuranceDocument(
user_id="user-1", sha256="d" * 64, storage_key="/private/plan.pdf",
original_name="plan.pdf", mime_type="application/pdf", file_size=1,
page_count=1, pdf_kind="native", status="parsed", parser_version="test-v1",
quality_json="{}",
)
db.session.add(document)
db.session.flush()
db.session.add(InsuranceDocumentPage(
document_id=document.id, page_number=1, width=595, height=842,
page_class="native", native_text_quality=1.0,
text_blocks_json=json.dumps([{
"id": "p1-b1", "text": "Premium 100000", "bbox": [10, 10, 100, 30],
"source": "native", "confidence": None,
}]),
ocr_blocks_json="[]", images_json="[]",
))
db.session.commit()
payload = load_document_ir(document)
schema = json.loads(Path("docs/schemas/document-ir.schema.json").read_text(encoding="utf-8"))
jsonschema.validate(payload, schema)
assert "documentId" not in payload["pages"][0]
@pytest.mark.skipif(
not (importlib.util.find_spec("reportlab") and importlib.util.find_spec("pdfplumber")),
reason="需要 PDF 生成和版面解析组件验证持久化",
)
def test_document_ingest_persists_pages_without_exposing_storage_key(tmp_path):
from flask import Flask
from reportlab.pdfgen import canvas
from insurance.db.database import db
from insurance.db.migrate_036 import migrate
from insurance.document.service import ingest_for_processing
pdf_path = tmp_path / "persist.pdf"
pdf = canvas.Canvas(str(pdf_path), pagesize=(595, 842))
pdf.drawString(72, 770, "Policy Premium 100000")
pdf.save()
app = Flask(__name__)
app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db.init_app(app)
with app.app_context():
migrate()
document, _result = ingest_for_processing(str(pdf_path), "user-1", original_name="persist.pdf")
payload = document.to_public_dict()
assert document.id
assert payload["pageCount"] == 1
assert "storageKey" not in payload
@pytest.mark.skipif(
not (
importlib.util.find_spec("reportlab")
and (
importlib.util.find_spec("fitz")
or importlib.util.find_spec("pymupdf")
or importlib.util.find_spec("pdfplumber")
)
),
reason="需要 PDF 生成和版面解析组件验证真实坐标摄取",
)
def test_ingest_real_pdf_matches_document_ir_schema(tmp_path):
import jsonschema
from reportlab.pdfgen import canvas
pdf_path = tmp_path / "native.pdf"
document = canvas.Canvas(str(pdf_path), pagesize=(595, 842))
document.drawString(72, 770, "Policy Year Premium Cash Value 100000")
document.save()
from insurance.document.ingest import ingest_pdf
result = ingest_pdf(str(pdf_path), ocr_page_budget=0)
schema = json.loads(Path("docs/schemas/document-ir.schema.json").read_text(encoding="utf-8"))
jsonschema.validate(result.document_ir, schema)
assert result.document_ir["document"]["pageCount"] == 1
assert result.document_ir["pages"][0]["textBlocks"][0]["bbox"]
page_text = " ".join(block["text"] for block in result.document_ir["pages"][0]["textBlocks"])
assert "Policy Year" in page_text