251 lines
7.9 KiB
Python
251 lines
7.9 KiB
Python
|
|
"""PPT 异步任务生命周期回归测试。"""
|
|||
|
|
import asyncio
|
|||
|
|
import sys
|
|||
|
|
import types
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
|
|||
|
|
|
|||
|
|
from insurance.generation import task_service
|
|||
|
|
from insurance.ppt import extraction as extraction_module
|
|||
|
|
from insurance.ppt import regex_extractor
|
|||
|
|
from insurance.ppt.extraction import ExtractionOrchestrator, ExtractionResult
|
|||
|
|
from insurance.ppt.llm_client import LLMResponse, _parse_json_content, _parse_timeout_ms
|
|||
|
|
from insurance.ppt.prompts import select_key_pages
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Field:
|
|||
|
|
def in_(self, _values):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Query:
|
|||
|
|
def filter_by(self, **_kwargs):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def filter(self, *_args):
|
|||
|
|
return self
|
|||
|
|
|
|||
|
|
def first(self):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _Session:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.commits = 0
|
|||
|
|
|
|||
|
|
def add(self, _value):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def commit(self):
|
|||
|
|
self.commits += 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _GenerationTask:
|
|||
|
|
status = _Field()
|
|||
|
|
query = _Query()
|
|||
|
|
|
|||
|
|
def __init__(self, **kwargs):
|
|||
|
|
self.id = "task-1"
|
|||
|
|
self.status = "queued"
|
|||
|
|
self.error_code = ""
|
|||
|
|
self.error_message = None
|
|||
|
|
self.finished_at = None
|
|||
|
|
for key, value in kwargs.items():
|
|||
|
|
setattr(self, key, value)
|
|||
|
|
|
|||
|
|
def to_dict(self):
|
|||
|
|
return {
|
|||
|
|
"id": self.id,
|
|||
|
|
"status": self.status,
|
|||
|
|
"errorCode": self.error_code,
|
|||
|
|
"errorMessage": self.error_message,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_dispatch_failure_finishes_task(monkeypatch):
|
|||
|
|
"""消息队列不可用时,不应留下永久 queued 任务。"""
|
|||
|
|
fake_db = types.SimpleNamespace(session=_Session())
|
|||
|
|
compat_module = types.ModuleType("insurance.db.compat")
|
|||
|
|
compat_module.db = fake_db
|
|||
|
|
model_module = types.ModuleType("insurance.models.generation_task")
|
|||
|
|
model_module.GenerationTask = _GenerationTask
|
|||
|
|
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
|
|||
|
|
monkeypatch.setitem(sys.modules, "insurance.models.generation_task", model_module)
|
|||
|
|
|
|||
|
|
synced = []
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
task_service,
|
|||
|
|
"_dispatch_to_celery",
|
|||
|
|
lambda _task: (_ for _ in ()).throw(ConnectionError("broker unavailable")),
|
|||
|
|
)
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
task_service,
|
|||
|
|
"_sync_failed_ppt_session",
|
|||
|
|
lambda task, message: synced.append((task.id, message)),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result = task_service.create_task(
|
|||
|
|
user_id="user-1",
|
|||
|
|
artifact_type="ppt",
|
|||
|
|
operation="parse",
|
|||
|
|
workspace_id="session-1",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert result["code"] == 9999
|
|||
|
|
assert result["data"]["status"] == "failed"
|
|||
|
|
assert result["data"]["errorCode"] == "dispatch_failed"
|
|||
|
|
assert synced and synced[0][0] == result["data"]["id"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cached_extraction_reports_completion(tmp_path, monkeypatch):
|
|||
|
|
"""重复解析应命中缓存,并向调用方报告完成阶段。"""
|
|||
|
|
pdf_path = tmp_path / "plan.pdf"
|
|||
|
|
pdf_path.write_bytes(b"%PDF-1.4")
|
|||
|
|
cached = ExtractionResult(
|
|||
|
|
pdf_path=str(pdf_path),
|
|||
|
|
product_name="测试产品",
|
|||
|
|
plan_type="savings",
|
|||
|
|
status="success",
|
|||
|
|
data={"product_name": "测试产品"},
|
|||
|
|
)
|
|||
|
|
orchestrator = ExtractionOrchestrator()
|
|||
|
|
monkeypatch.setattr(orchestrator, "_load_from_cache", lambda _path: cached)
|
|||
|
|
updates = []
|
|||
|
|
|
|||
|
|
result = asyncio.run(orchestrator.extract_plan(
|
|||
|
|
str(pdf_path),
|
|||
|
|
progress_callback=lambda progress, message: updates.append((progress, message)),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
assert result is cached
|
|||
|
|
assert updates == [(100, "已使用历史解析结果")]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_default_llm_timeout_is_bounded():
|
|||
|
|
assert _parse_timeout_ms(None) == 180_000
|
|||
|
|
assert _parse_timeout_ms("30000") == 30_000
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_llm_json_parser_accepts_explanation_and_trailing_comma():
|
|||
|
|
content = '结果如下:\n```json\n{"product_name": "测试产品",}\n```\n请核对。'
|
|||
|
|
assert _parse_json_content(content) == {"product_name": "测试产品"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_pdf_pages_keep_page_numbers_and_select_late_benefit_page():
|
|||
|
|
text = extraction_module._format_pdf_pages([
|
|||
|
|
"Product Name: Example IUL",
|
|||
|
|
"general terms",
|
|||
|
|
"Policy Year Account Value Cash Surrender Value Death Benefit 1 1000 900 500000",
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
assert "[PAGE 1]" in text
|
|||
|
|
assert "[PAGE 3]" in text
|
|||
|
|
selected = select_key_pages(text, max_pages=2, max_chars=2000)
|
|||
|
|
assert "Product Name: Example IUL" in selected
|
|||
|
|
assert "Cash Surrender Value" in selected
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_corrupted_pdf_text_detection_accepts_normal_text_and_rejects_font_garbage():
|
|||
|
|
normal = "保险计划书 被保人年龄 48 岁\nPolicy Year 1 Cash Value 100000\n" * 3
|
|||
|
|
corrupted = "\uffff\uffff\x81\x82ĤøùÿxĀ@BQā" * 20
|
|||
|
|
|
|||
|
|
assert extraction_module._looks_corrupted(normal) is False
|
|||
|
|
assert extraction_module._looks_corrupted(corrupted) is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_iul_filename_hint_corrects_ocr_product_name():
|
|||
|
|
data = {
|
|||
|
|
"product_name": "SAR Feel",
|
|||
|
|
"policy": {"product_name": "SAR Feel"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
corrected = extraction_module._apply_filename_hints(
|
|||
|
|
data,
|
|||
|
|
"/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m.pdf",
|
|||
|
|
"iul",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert corrected["product_name"] == "Manulife SIUL 3"
|
|||
|
|
assert corrected["policy"]["product_name"] == "Manulife SIUL 3"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_savings_milestone_rows_are_warnings_not_blocking_errors():
|
|||
|
|
from insurance.ppt.validator import validate_formal_savings_plan
|
|||
|
|
|
|||
|
|
plan = {
|
|||
|
|
"productName": "测试储蓄计划",
|
|||
|
|
"insured": {"age": 41},
|
|||
|
|
"policy": {"annualPremium": 5250, "payYears": 5},
|
|||
|
|
"benefitRows": [
|
|||
|
|
{"policyYear": year, "sourcePage": 3}
|
|||
|
|
for year in [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 65, 70, 75, 80, 85, 90, 95, 100]
|
|||
|
|
],
|
|||
|
|
"withdrawalRows": [],
|
|||
|
|
"source": {"pdfHash": "abc"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
issues = validate_formal_savings_plan(plan)
|
|||
|
|
|
|||
|
|
assert not [issue for issue in issues if issue.level == "error"]
|
|||
|
|
assert any(issue.code == "BENEFIT_ROWS_MILESTONE_ONLY" for issue in issues)
|
|||
|
|
assert any(issue.code == "BENEFIT_ROWS_DISCONTINUOUS" for issue in issues)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_savings_rows_without_key_years_remain_blocking():
|
|||
|
|
from insurance.ppt.validator import validate_formal_savings_plan
|
|||
|
|
|
|||
|
|
plan = {
|
|||
|
|
"productName": "测试储蓄计划",
|
|||
|
|
"insured": {"age": 41},
|
|||
|
|
"policy": {"annualPremium": 5250, "payYears": 5},
|
|||
|
|
"benefitRows": [
|
|||
|
|
{"policyYear": year, "sourcePage": 3}
|
|||
|
|
for year in [1, 2, 3, 4, 5]
|
|||
|
|
],
|
|||
|
|
"withdrawalRows": [],
|
|||
|
|
"source": {"pdfHash": "abc"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
issues = validate_formal_savings_plan(plan)
|
|||
|
|
|
|||
|
|
assert any(
|
|||
|
|
issue.code == "BENEFIT_ROWS_INCOMPLETE" and issue.level == "error"
|
|||
|
|
for issue in issues
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_partial_extraction_rechecks_and_keeps_better_result(tmp_path, monkeypatch):
|
|||
|
|
pdf_path = tmp_path / "iul.pdf"
|
|||
|
|
pdf_path.write_bytes(b"%PDF-1.4")
|
|||
|
|
monkeypatch.setattr(extraction_module, "_extract_pdf_text", lambda _path: "有效计划书文本" * 100)
|
|||
|
|
monkeypatch.setattr(regex_extractor, "extract_insurance_regex", lambda _text: {})
|
|||
|
|
monkeypatch.setattr(regex_extractor, "count_benefit_rows", lambda _data: 0)
|
|||
|
|
|
|||
|
|
complete = {
|
|||
|
|
"product_name": "测试 IUL",
|
|||
|
|
"product_type": "iul",
|
|||
|
|
"insured": {"age": 35},
|
|||
|
|
"policy": {"sum_insured": 500000, "index_account_rate": 0.05},
|
|||
|
|
"index_accounts": [{"name": "S&P 500"}],
|
|||
|
|
"benefit_illustration": [{"policy_year": 1, "account_value": 1000}],
|
|||
|
|
}
|
|||
|
|
responses = iter([
|
|||
|
|
({}, LLMResponse(content="{}", provider="test")),
|
|||
|
|
(complete, LLMResponse(content="{}", provider="test")),
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
async def fake_structured_output(**_kwargs):
|
|||
|
|
return next(responses)
|
|||
|
|
|
|||
|
|
from insurance.ppt.llm_client import llm_client
|
|||
|
|
monkeypatch.setattr(llm_client, "structured_output", fake_structured_output)
|
|||
|
|
|
|||
|
|
result = asyncio.run(
|
|||
|
|
ExtractionOrchestrator(use_cache=False).extract_plan(str(pdf_path), "iul")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert result.status == "success"
|
|||
|
|
assert result.product_name == "测试 IUL"
|
|||
|
|
assert result.data["benefit_illustration"]
|