baodan/api/insurance/poster/tasks.py
wsb1224 9939188a4b 主要修复:
海报 PDF 解析从 Gunicorn 后台线程迁移到 Celery Worker,消除 gevent/asyncio.run 冲突和任务卡死。
海报改为紧凑解析,只选择客户资料、保费和核心利益页;排除提领方案、悲观/乐观情景页。
LLM 调用由原来的约 27 次降为 1 次。
补充年缴保费、首年实缴、缴费期、总保费及第 1/5/10/15/20/25/30 年退保价值。
增加真实解析进度、错误信息、任务 ID、心跳和完成时间。
相同用户重复上传同一份计划书时复用现有任务或结果。
前端取消 180 秒本地假超时,改为串行轮询后端真实状态;网络波动不再误判解析失败。
增加服务重启后的过期任务恢复机制。
修复解析结果 JSON 序列化遗漏问题。
关键文件:
[extraction.py](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py)
[tasks.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/tasks.py)
[celery_tasks.py](D:/work/code/python/coding/baodanagent/api/insurance/generation/celery_tasks.py)
[service.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/service.py)
[migrate_032.py](D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_032.py)
[PosterSourcePanel.vue](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/workspace/PosterSourcePanel.vue)
[回归测试](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py)
2026-08-01 03:15:43 +08:00

422 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""海报后台任务管理。"""
import json
import logging
import os
from datetime import datetime
from insurance.db.compat import db
logger = logging.getLogger(__name__)
# 超过此时间仍为 queued/generating 的任务视为过期(秒)
STALE_TASK_TIMEOUT = 600 # 10 分钟
def recover_stale_tasks():
"""启动时恢复过期任务:将长时间 queued/generating 的任务标记为 failed。
应在应用启动时调用。
"""
from insurance.models.poster_record import PosterRecord
from insurance.models.poster_case_upload import PosterCaseUpload
from sqlalchemy import inspect
cutoff = datetime.now().timestamp() - STALE_TASK_TIMEOUT
# 恢复过期的海报生成任务
stale_records = PosterRecord.query.filter(
PosterRecord.task_status.in_(["queued", "generating"]),
PosterRecord.created_at < datetime.fromtimestamp(cutoff),
).all()
for record in stale_records:
record.task_status = "failed"
record.task_error = "任务因服务重启而中断,请重新生成"
record.finished_at = datetime.now()
logger.warning(f"恢复过期海报任务: record_id={record.id}")
# 恢复过期的计划书解析任务
case_columns = {
item["name"] for item in inspect(db.engine).get_columns("poster_case_uploads")
}
stale_cases = []
if {"parse_progress", "parse_message", "parse_error"}.issubset(case_columns):
stale_cases = PosterCaseUpload.query.filter(
PosterCaseUpload.parse_status.in_(["queued", "parsing"]),
PosterCaseUpload.created_at < datetime.fromtimestamp(cutoff),
).all()
for case in stale_cases:
case.parse_status = "failed"
case.parse_progress = 100
case.parse_message = "解析任务已中断"
case.parse_error = "任务因服务重启或 Worker 中断,请重新解析"
case.parse_finished_at = datetime.now()
logger.warning(f"恢复过期解析任务: case_id={case.id}")
if stale_records or stale_cases:
db.session.commit()
logger.info(f"已恢复 {len(stale_records)} 个海报任务, {len(stale_cases)} 个解析任务")
# ─── 计划书解析任务 ───────────────────────────────────────
def start_case_parse_task(case_upload_id: int) -> bool:
"""将计划书解析提交到 Celery Worker。"""
try:
from insurance.generation.celery_tasks import parse_poster_case_task
from insurance.models.poster_case_upload import PosterCaseUpload
result = parse_poster_case_task.apply_async(
args=[case_upload_id],
queue="insurance",
)
record = PosterCaseUpload.query.get(case_upload_id)
if record:
record.parse_task_id = result.id
db.session.commit()
return True
except Exception as exc:
logger.error("海报计划书解析任务提交失败 [%s]: %s", case_upload_id, exc, exc_info=True)
return False
def _execute_case_parse(case_upload_id: int):
from insurance.models.poster_case_upload import PosterCaseUpload
record = PosterCaseUpload.query.get(case_upload_id)
if not record:
return
filepath = record.source_file_url
if not filepath or not os.path.exists(filepath):
raise FileNotFoundError("计划书源文件不存在")
from insurance.ppt.extraction import ExtractionOrchestrator
orchestrator = ExtractionOrchestrator(use_cache=False)
import asyncio
parsed = None
product_context = {}
if record.product_snapshot_json:
try:
product_context = json.loads(record.product_snapshot_json) or {}
except (json.JSONDecodeError, TypeError):
logger.warning("计划书产品快照无效: case_id=%s", case_upload_id)
plan_type = str(product_context.get("planType") or "savings").lower()
product_data = product_context.get("productData") or {}
product_name = (
product_context.get("productName")
or product_data.get("displayName")
or ""
)
product_aliases = product_data.get("aliases") or []
company_id = (
product_context.get("companyId")
or product_data.get("companyId")
or ""
)
def report_progress(progress: int, message: str):
_update_case_progress(case_upload_id, progress, message)
# 优先使用海报紧凑解析,只提取基础字段和代表性利益年度。
try:
compact_data = asyncio.run(orchestrator.extract_for_poster(
filepath,
plan_type=plan_type,
progress_callback=report_progress,
))
compact_data = compact_data or {}
if product_name and not compact_data.get("product_name"):
compact_data["product_name"] = product_name
parsed = _map_extract_plan_fields(
compact_data, plan_type, "poster_compact", product_context=product_context,
)
if (parsed.get("meta") or {}).get("status") == "failed":
parsed = None
else:
logger.info("使用海报紧凑解析成功: case_id=%s, type=%s", case_upload_id, plan_type)
except Exception as exc:
logger.warning("海报紧凑解析失败,降级到完整解析: %s", exc)
# 降级:完整解析仍跳过海报不使用的提领方案和销售分析。
if not parsed:
try:
result = asyncio.run(orchestrator.extract_plan(
filepath,
plan_type=plan_type,
progress_callback=report_progress,
company_id=company_id,
product_id=record.product_id or "",
product_name_hint=product_name,
product_aliases=product_aliases,
include_withdrawal=False,
))
if result.status != "error" and result.data:
parsed = _map_extract_plan_fields(
result.data, result.plan_type, result.status, product_context=product_context,
)
logger.info("使用完整解析降级成功: case_id=%s, type=%s", case_upload_id, result.plan_type)
except Exception as exc:
logger.error("完整解析降级也失败: %s", exc)
if not parsed:
raise ValueError("计划书中未识别到可用于海报的字段")
record = PosterCaseUpload.query.get(case_upload_id)
if not record:
return
parse_status = (parsed.get("meta") or {}).get("status", "failed")
record.parsed_data = json.dumps(parsed, ensure_ascii=False)
record.parse_status = parse_status
record.parse_progress = 100
record.parse_message = "解析完成" if parse_status == "parsed" else "解析完成,部分字段需核对"
record.parse_error = None
record.parse_heartbeat_at = datetime.now()
record.parse_finished_at = datetime.now()
db.session.commit()
def _map_extract_plan_fields(
data: dict,
plan_type: str,
status: str,
product_context: dict | None = None,
) -> dict:
"""将 extract_plan 的完整数据映射为海报前端所需字段结构。
兼容多种可能的字段命名LLM 输出不固定)。
"""
insured = data.get("insured") or {}
policy = data.get("policy") or {}
product_context = product_context or {}
product_data = product_context.get("productData") or {}
company_data = product_context.get("companyData") or {}
def first_value(*values):
for value in values:
if value is not None and value != "":
return value
return None
raw_gender = first_value(insured.get("gender"), data.get("gender"))
gender_map = {
"male": "", "m": "", "": "",
"female": "", "f": "", "": "",
}
gender = gender_map.get(str(raw_gender).strip().lower(), raw_gender) if raw_gender else None
raw_currency = first_value(policy.get("currency"), data.get("currency"))
from insurance.ppt.normalizer import _normalize_currency
currency = _normalize_currency(raw_currency)
# 保额:优先读取正式提取结构中的 policy
sum_assured = (
policy.get("sum_insured")
or policy.get("basic_sum_insured")
or policy.get("sum_assured")
or data.get("sum_insured")
or data.get("basic_sum_insured")
or data.get("sum_assured")
or data.get("face_amount")
or data.get("coverage_amount")
)
# 缴费年期
premium_term = (
policy.get("premium_payment_period")
or policy.get("premium_term")
or data.get("premium_term")
or data.get("payment_period")
or data.get("paying_period")
or data.get("premium_payment_term")
)
# 年缴保费
annual_premium = (
policy.get("annual_premium")
or policy.get("basic_plan_annual_premium")
or policy.get("target_premium")
or data.get("annual_premium")
or data.get("premium_amount")
or data.get("yearly_premium")
)
# 保障期限
coverage_period = (
policy.get("coverage_period")
or data.get("coverage_period")
or data.get("policy_term")
or data.get("coverage_term")
)
# 利益演示表
benefit_table = (
data.get("benefit_illustration")
or data.get("benefit_table")
or data.get("cash_value_projection")
or []
)
smoking_status = first_value(
insured.get("smoking_status"), insured.get("smoker_status"),
insured.get("smoker"), insured.get("is_smoker"),
data.get("smoking_status"), data.get("is_smoker"),
)
if isinstance(smoking_status, bool):
smoking_status = "吸烟" if smoking_status else "非吸烟"
elif smoking_status is not None:
smoking_status = {
"yes": "吸烟", "y": "吸烟", "smoker": "吸烟", "吸烟": "吸烟", "吸煙": "吸烟",
"no": "非吸烟", "n": "非吸烟", "non-smoker": "非吸烟",
"不吸烟": "非吸烟", "不吸煙": "非吸烟", "非吸烟": "非吸烟",
}.get(str(smoking_status).strip().lower(), smoking_status)
explicit_total_premium = first_value(
policy.get("total_premium"), policy.get("total_basic_premium"),
data.get("total_premium"), data.get("total_basic_premium"),
)
total_premium = explicit_total_premium
if total_premium is None and isinstance(annual_premium, (int, float)) and isinstance(premium_term, (int, float)):
total_premium = annual_premium * premium_term
def benefit_value(year: int, *keys: str):
for row in benefit_table:
row_year = first_value(row.get("policy_year"), row.get("policyYear"), row.get("year"))
try:
if int(row_year) != year:
continue
except (TypeError, ValueError):
continue
return first_value(*(row.get(key) for key in keys))
return None
explicit_initial_death_benefit = first_value(
policy.get("initial_death_benefit"), data.get("initial_death_benefit"),
)
initial_death_benefit = first_value(
explicit_initial_death_benefit,
benefit_value(1, "death_benefit", "deathBenefit", "non_guaranteed_death_benefit"),
)
milestone_values = {
year: first_value(
data.get(f"surrender_value_{year}"),
benefit_value(year, "total_surrender_value", "totalSurrenderValue", "total_surrender"),
)
for year in (10, 20, 30)
}
result = {
"age": first_value(insured.get("age"), data.get("age")),
"gender": gender,
"smoking_status": smoking_status,
"currency": currency,
"company_name": first_value(
product_context.get("companyName"), company_data.get("displayName"), data.get("company_name"),
),
"product_name": first_value(
product_context.get("productName"), product_data.get("displayName"), data.get("product_name"),
),
"sum_assured": sum_assured,
"basic_sum_assured": first_value(policy.get("basic_sum_insured"), data.get("basic_sum_insured"), sum_assured),
"premium_term": premium_term,
"annual_premium": annual_premium,
"basic_plan_annual_premium": first_value(
policy.get("basic_plan_annual_premium"), data.get("basic_plan_annual_premium"), annual_premium,
),
"total_premium": total_premium,
"first_year_amount_due": first_value(
policy.get("first_year_amount_due"), data.get("first_year_amount_due"),
),
"coverage_period": coverage_period,
"initial_death_benefit": initial_death_benefit,
"surrender_value_10": milestone_values[10],
"surrender_value_20": milestone_values[20],
"surrender_value_30": milestone_values[30],
"key_benefits": data.get("key_benefits") or [],
"benefit_table": benefit_table,
"plan_type": plan_type,
"extraction_status": status,
}
business_fields = (
"age", "gender", "currency", "sum_assured",
"premium_term", "annual_premium", "coverage_period",
)
valid_count = sum(
1 for key in business_fields
if result.get(key) is not None and result.get(key) != ""
)
required_by_type = {
"savings": ("age", "currency", "annual_premium", "premium_term"),
"ci": ("age", "currency", "sum_assured"),
"iul": ("age", "currency", "sum_assured", "annual_premium"),
}
required = required_by_type.get(str(plan_type or "").lower(), ("age", "currency"))
missing = [
key for key in required
if result.get(key) is None or result.get(key) == ""
]
mapped_status = "failed" if valid_count == 0 else ("partial" if missing else "parsed")
raw_provenance = data.get("_provenance") or data.get("provenance") or {}
provenance = dict(raw_provenance) if isinstance(raw_provenance, dict) else {}
if total_premium is not None and explicit_total_premium is None:
provenance["total_premium"] = {
"source": "system_derived",
"formula": "annual_premium * premium_term",
}
if initial_death_benefit is not None and explicit_initial_death_benefit is None:
provenance["initial_death_benefit"] = {
"source": "system_derived",
"from": "benefit_table.year_1",
}
for year, value in milestone_values.items():
if value is not None and data.get(f"surrender_value_{year}") is None:
provenance[f"surrender_value_{year}"] = {
"source": "system_derived",
"from": f"benefit_table.year_{year}",
}
result["meta"] = {
"planType": plan_type,
"status": mapped_status,
"sourceStatus": status,
"missingFields": missing,
"validFieldCount": valid_count,
"method": (
(data.get("_meta") or {}).get("method")
or (data.get("extraction_meta") or {}).get("method")
or status
),
"provenance": provenance,
"lowQualityPages": (
(data.get("_meta") or {}).get("low_quality_pages")
or (data.get("extraction_meta") or {}).get("lowQualityPages")
or []
),
}
return result
def _mark_case_failed(case_upload_id: int, error: str):
from insurance.models.poster_case_upload import PosterCaseUpload
record = PosterCaseUpload.query.get(case_upload_id)
if not record:
return
record.parse_status = "failed"
record.parse_progress = 100
record.parse_message = "解析失败"
record.parse_error = error[:1000]
record.parse_heartbeat_at = datetime.now()
record.parse_finished_at = datetime.now()
db.session.commit()
def _update_case_progress(case_upload_id: int, progress: int, message: str):
from insurance.models.poster_case_upload import PosterCaseUpload
record = PosterCaseUpload.query.get(case_upload_id)
if not record:
return
record.parse_progress = max(0, min(int(progress), 99))
record.parse_message = message[:500]
record.parse_heartbeat_at = datetime.now()
db.session.commit()