2026-07-29 15:47:50 +08:00
|
|
|
|
"""PPT 异步任务生命周期回归测试。"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import types
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-07-31 14:10:24 +08:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-07-29 15:47:50 +08:00
|
|
|
|
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": "测试储蓄计划",
|
2026-07-31 14:10:24 +08:00
|
|
|
|
"insured": {"age": 41, "smoker": "no"},
|
|
|
|
|
|
"policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5},
|
2026-07-29 15:47:50 +08:00
|
|
|
|
"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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 14:10:24 +08:00
|
|
|
|
def test_savings_rows_without_key_years_are_non_blocking_warnings():
|
2026-07-29 15:47:50 +08:00
|
|
|
|
from insurance.ppt.validator import validate_formal_savings_plan
|
|
|
|
|
|
|
|
|
|
|
|
plan = {
|
|
|
|
|
|
"productName": "测试储蓄计划",
|
2026-07-31 14:10:24 +08:00
|
|
|
|
"insured": {"age": 41, "smoker": "no"},
|
|
|
|
|
|
"policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5},
|
2026-07-29 15:47:50 +08:00
|
|
|
|
"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(
|
2026-07-31 14:10:24 +08:00
|
|
|
|
issue.code == "BENEFIT_ROWS_INCOMPLETE" and issue.level == "warn"
|
2026-07-29 15:47:50 +08:00
|
|
|
|
for issue in issues
|
|
|
|
|
|
)
|
2026-07-31 14:10:24 +08:00
|
|
|
|
assert not [issue for issue in issues if issue.level == "error"]
|
2026-07-29 15:47:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 14:10:24 +08:00
|
|
|
|
def test_split_extraction_merges_identity_and_benefit_result(tmp_path, monkeypatch):
|
2026-07-29 15:47:50 +08:00
|
|
|
|
pdf_path = tmp_path / "iul.pdf"
|
|
|
|
|
|
pdf_path.write_bytes(b"%PDF-1.4")
|
2026-07-31 14:10:24 +08:00
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
extraction_module,
|
|
|
|
|
|
"_extract_pdf_text",
|
|
|
|
|
|
lambda _path: ("有效计划书文本" * 100, []),
|
|
|
|
|
|
)
|
2026-07-29 15:47:50 +08:00
|
|
|
|
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}],
|
|
|
|
|
|
}
|
2026-07-31 14:10:24 +08:00
|
|
|
|
async def fake_structured_output(**kwargs):
|
|
|
|
|
|
prompt = kwargs.get("prompt", "")
|
|
|
|
|
|
if "身份和保单字段" in prompt:
|
|
|
|
|
|
data = {key: value for key, value in complete.items() if key != "benefit_illustration"}
|
|
|
|
|
|
elif "利益演示表" in prompt:
|
|
|
|
|
|
data = {"benefit_illustration": complete["benefit_illustration"]}
|
|
|
|
|
|
elif "提领/提款演示表" in prompt:
|
|
|
|
|
|
data = {"withdrawal_illustration": []}
|
|
|
|
|
|
else:
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
return data, LLMResponse(content="{}", provider="test")
|
2026-07-29 15:47:50 +08:00
|
|
|
|
|
|
|
|
|
|
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"]
|
2026-07-31 14:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_generate_ppt_task_marks_failure_and_syncs_workspace(monkeypatch):
|
|
|
|
|
|
"""生成入口自身异常也必须结束任务,不能永久停在 running。"""
|
|
|
|
|
|
celery_module = types.ModuleType("celery")
|
|
|
|
|
|
|
|
|
|
|
|
def shared_task(*_args, **_kwargs):
|
|
|
|
|
|
return lambda func: func
|
|
|
|
|
|
|
|
|
|
|
|
celery_module.shared_task = shared_task
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "celery", celery_module)
|
|
|
|
|
|
sys.modules.pop("insurance.generation.celery_tasks", None)
|
|
|
|
|
|
from insurance.generation import celery_tasks
|
|
|
|
|
|
|
|
|
|
|
|
task = types.SimpleNamespace(id="task-1", workspace_id="session-1")
|
|
|
|
|
|
session = types.SimpleNamespace(workflow_step="ready")
|
|
|
|
|
|
|
|
|
|
|
|
class TaskQuery:
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get(_task_id):
|
|
|
|
|
|
return task
|
|
|
|
|
|
|
|
|
|
|
|
class SessionQuery:
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get(_session_id):
|
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
generation_model = types.ModuleType("insurance.models.generation_task")
|
|
|
|
|
|
generation_model.GenerationTask = types.SimpleNamespace(query=TaskQuery())
|
|
|
|
|
|
session_model = types.ModuleType("insurance.models.ppt_session")
|
|
|
|
|
|
session_model.PptSession = types.SimpleNamespace(query=SessionQuery())
|
|
|
|
|
|
fake_db = types.SimpleNamespace(session=_Session())
|
|
|
|
|
|
compat_module = types.ModuleType("insurance.db.compat")
|
|
|
|
|
|
compat_module.db = fake_db
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "insurance.models.generation_task", generation_model)
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "insurance.models.ppt_session", session_model)
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
|
|
|
|
|
|
monkeypatch.setattr(celery_tasks, "_claim_task", lambda _task_id: True)
|
|
|
|
|
|
|
|
|
|
|
|
updates = []
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
celery_tasks,
|
|
|
|
|
|
"_update_task_status",
|
|
|
|
|
|
lambda _task_id, **kwargs: updates.append(kwargs),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
celery_tasks,
|
|
|
|
|
|
"_execute_ppt_generate",
|
|
|
|
|
|
lambda _task_id: (_ for _ in ()).throw(RuntimeError("render failed")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
synced = []
|
|
|
|
|
|
monkeypatch.setattr(task_service, "sync_workspace_status", lambda value: synced.append(value))
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="render failed"):
|
|
|
|
|
|
celery_tasks.generate_ppt_task(None, "task-1")
|
|
|
|
|
|
|
|
|
|
|
|
assert session.workflow_step == "generating"
|
|
|
|
|
|
assert any(update.get("status") == "failed" for update in updates)
|
|
|
|
|
|
assert synced == [task]
|