海报加载失败根因:html-to-image 给 blob: 底图地址追加缓存参数,导致地址失效。现已关闭该行为。 合成失败后再次点击“重新生成”,会复用已有 AI 底图,只重试浏览器合成和上传,避免重复调用 AI。 增加底图加载、图表超时、导出失败、尺寸越界等分阶段错误提示。 修复计划书 (cid:数字) 字体乱码被误判为正常文本的问题,现在会正确转入 OCR。 增加繁体中文 OCR 运行支持。 补齐 SIUL 文件名中的确定字段,并且不会覆盖正文已识别数据。 增加“首期规划保费/償還至形成基金所需保費”等保费标签识别。 修正 IUL 年龄、保额、退保价值、缴费期、公司信息等字段映射。 LLM 返回空对象或缺字段时不再视为成功。 增加错误利益数值和年龄/保单年度错位校验。 修复依赖版本降级导致 API/Worker 无法启动的风险。 真实计划书复验结果: 产品:Manulife SIUL 3 投保年龄:48 岁 性别:女性 吸烟状态:非吸烟 币种:USD 基本保额:3,000,000 年缴保费:80,060 缴费期:5 年 利益演示:识别到 10 行 验证结果: 后端相关回归测试:91 passed 前端生产构建:通过 API、数据库、Redis、存储、数据表健康检查:全部正常 Celery Worker:已重启并连接 Redis 真实 PDF:确认进入 OCR,不再使用 (cid:...) 乱码 前端构建目录由运行容器挂载,修复已生效
381 lines
13 KiB
Python
381 lines
13 KiB
Python
"""PPT 异步任务生命周期回归测试。"""
|
||
import asyncio
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
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,
|
||
_validate_required_fields,
|
||
)
|
||
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_structured_schema_rejects_empty_or_incomplete_success_payload():
|
||
schema = {
|
||
"type": "object",
|
||
"required": ["insured", "policy"],
|
||
"properties": {
|
||
"insured": {"type": "object", "required": ["age"]},
|
||
"policy": {"type": "object", "required": ["annual_premium"]},
|
||
},
|
||
}
|
||
|
||
with pytest.raises(ValueError, match="缺少必填字段"):
|
||
_validate_required_fields({}, schema)
|
||
with pytest.raises(ValueError, match="annual_premium"):
|
||
_validate_required_fields({"insured": {"age": 48}, "policy": {}}, schema)
|
||
_validate_required_fields(
|
||
{"insured": {"age": 48}, "policy": {"annual_premium": 80060}},
|
||
schema,
|
||
)
|
||
|
||
|
||
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
|
||
cid_garbage = "[PAGE 1]\n" + "(cid:4)(cid:17)(cid:238)(cid:99)" * 30
|
||
assert extraction_module._looks_corrupted(cid_garbage) 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-5x_coi__SC_.pdf",
|
||
"iul",
|
||
)
|
||
|
||
assert corrected["product_name"] == "Manulife SIUL 3"
|
||
assert corrected["policy"]["product_name"] == "Manulife SIUL 3"
|
||
assert corrected["insured"] == {"gender": "female", "age": 48, "smoker": "no"}
|
||
assert corrected["policy"]["currency"] == "USD"
|
||
assert corrected["policy"]["sum_insured"] == 3_000_000
|
||
assert corrected["policy"]["premium_payment_period"] == 5
|
||
|
||
|
||
def test_iul_filename_hint_does_not_override_extracted_values():
|
||
data = {
|
||
"product_name": "unknown",
|
||
"insured": {"age": 49, "gender": "female", "smoker": "no"},
|
||
"policy": {"currency": "HKD", "sum_insured": 2_000_000, "premium_payment_period": 8},
|
||
}
|
||
|
||
corrected = extraction_module._apply_filename_hints(
|
||
data,
|
||
"/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x.pdf",
|
||
"iul",
|
||
)
|
||
|
||
assert corrected["insured"]["age"] == 49
|
||
assert corrected["policy"]["currency"] == "HKD"
|
||
assert corrected["policy"]["sum_insured"] == 2_000_000
|
||
assert corrected["policy"]["premium_payment_period"] == 8
|
||
|
||
|
||
def test_iul_ocr_label_extracts_planned_annual_premium():
|
||
from insurance.ppt.regex_extractor import _extract_annual_premium
|
||
|
||
text = "性别 Female 首期规划保费 US$80,060.00\n偿还至形成基金所需保费 US$80,060.00 从第1年至第5年"
|
||
|
||
assert _extract_annual_premium(text) == 80_060
|
||
|
||
|
||
def test_savings_milestone_rows_are_warnings_not_blocking_errors():
|
||
from insurance.ppt.validator import validate_formal_savings_plan
|
||
|
||
plan = {
|
||
"productName": "测试储蓄计划",
|
||
"insured": {"age": 41, "smoker": "no"},
|
||
"policy": {"currency": "USD", "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_are_non_blocking_warnings():
|
||
from insurance.ppt.validator import validate_formal_savings_plan
|
||
|
||
plan = {
|
||
"productName": "测试储蓄计划",
|
||
"insured": {"age": 41, "smoker": "no"},
|
||
"policy": {"currency": "USD", "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 == "warn"
|
||
for issue in issues
|
||
)
|
||
assert not [issue for issue in issues if issue.level == "error"]
|
||
|
||
|
||
def test_split_extraction_merges_identity_and_benefit_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}],
|
||
}
|
||
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")
|
||
|
||
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"]
|
||
|
||
|
||
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]
|