修复 PPT 异步任务无法生成的问题,包括任务变量引用错误、失败状态回写、心跳缺失任务恢复。 脱敏改为保司/产品后台统一配置,生成端不再让用户选择;任务创建时保存策略快照。 保司支持独立控制 PPT、海报 Logo 显示。 PPT 核验新增吸烟状态、币种及三个条件字段。 利益演示、退保提取调整为警告,不再阻止生成。 PPT 生成完成后可以直接返回数据核验页修改。 建立不同险种、单图/长图共六套海报字段画像。 PPT“生成场景”支持后台新增、启停和删除。 保司、产品、PPT 模板、文案模板均支持安全删除。 内置模板禁止删除,只允许停用;存在关联数据时拒绝危险删除。 补充策略变更及删除审计日志。 更新 API 文档、部署文档及修复计划实施记录。 关键交付文件: [数据库迁移 migrate_027.py](D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_027.py) [海报字段画像 field_profiles.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/field_profiles.py) [动态场景服务 scenarios.py](D:/work/code/python/coding/baodanagent/api/insurance/ppt/scenarios.py) [新增回归测试](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) [优化修复计划书](D:/work/code/python/coding/baodanagent/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md) 验证结果: 核心链路测试:37 passed,1 skipped 扩展回归测试:140 passed PPT 渲染器测试:6 passed 前端生产构建:通过 Python 编译检查:通过 完整测试集:190 passed,1 failed 唯一失败为 tests/test_chat_save.py::test_chat_logs_query 未建立 Flask application context,与本次 PPT/海报链路无关。
323 lines
11 KiB
Python
323 lines
11 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
|
||
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, "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]
|