baodan/api/insurance/poster/tasks.py

242 lines
7.7 KiB
Python
Raw Normal View History

"""海报后台任务管理。
使用与 PPT parse_worker 相同的后台线程 + Redis 锁模式
任务状态通过数据库字段追踪前端轮询获取进度
"""
import json
import logging
import threading
import os
from datetime import datetime
from insurance.db.compat import db
logger = logging.getLogger(__name__)
_local_locks: set = set()
_local_locks_guard = threading.Lock()
_redis_locks: set = set()
# 超过此时间仍为 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
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}")
# 恢复过期的计划书解析任务
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"
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(app, case_upload_id: int) -> bool:
"""启动后台计划书解析任务,返回是否新启动。"""
lock_key = f"poster_case_parse:{case_upload_id}"
if not _acquire_lock(lock_key):
return False
thread = threading.Thread(
target=_run_case_parse_task,
args=(app, case_upload_id, lock_key),
daemon=True,
)
thread.start()
return True
def _run_case_parse_task(app, case_upload_id: int, lock_key: str):
with app.app_context():
try:
_execute_case_parse(case_upload_id)
except Exception as exc:
logger.error(f"计划书解析任务失败 [{case_upload_id}]: {exc}", exc_info=True)
_mark_case_failed(case_upload_id, str(exc))
finally:
_release_lock(lock_key)
db.session.remove()
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):
record.parse_status = "failed"
db.session.commit()
return
record.parse_status = "parsing"
db.session.commit()
from insurance.ppt.extraction import ExtractionOrchestrator
orchestrator = ExtractionOrchestrator(use_cache=False)
import asyncio
2026-07-31 09:00:39 +08:00
parsed = None
# 优先使用完整解析(获取利益演示表)
try:
result = asyncio.run(orchestrator.extract_plan(filepath))
if result.status != "error" and result.data:
data = result.data
parsed = _map_extract_plan_fields(data, result.plan_type, result.status)
logger.info(f"使用 extract_plan 解析成功: case_id={case_upload_id}, type={result.plan_type}")
except Exception as exc:
logger.warning(f"extract_plan 失败,降级到 extract_for_poster: {exc}")
# 降级:使用轻量解析
if not parsed:
try:
parsed = asyncio.run(orchestrator.extract_for_poster(filepath))
logger.info(f"使用 extract_for_poster 降级解析: case_id={case_upload_id}")
except Exception as exc:
logger.error(f"extract_for_poster 也失败: {exc}")
record = PosterCaseUpload.query.get(case_upload_id)
if record:
record.parse_status = "failed"
db.session.commit()
return
2026-07-30 16:07:57 +08:00
2026-07-31 09:00:39 +08:00
record = PosterCaseUpload.query.get(case_upload_id)
if not record:
2026-07-30 16:07:57 +08:00
return
2026-07-31 09:00:39 +08:00
record.parsed_data = json.dumps(parsed, ensure_ascii=False)
record.parse_status = "parsed"
db.session.commit()
def _map_extract_plan_fields(data: dict, plan_type: str, status: str) -> dict:
"""将 extract_plan 的完整数据映射为海报前端所需字段结构。
兼容多种可能的字段命名LLM 输出不固定
"""
# 保额:尝试多个字段名
sum_assured = (
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 = (
data.get("premium_term")
or data.get("payment_period")
or data.get("paying_period")
or data.get("premium_payment_term")
)
# 年缴保费
annual_premium = (
data.get("annual_premium")
or data.get("premium_amount")
or data.get("yearly_premium")
)
# 保障期限
coverage_period = (
data.get("coverage_period")
or data.get("policy_term")
or data.get("coverage_term")
)
2026-07-30 16:07:57 +08:00
2026-07-31 09:00:39 +08:00
# 利益演示表
benefit_table = (
data.get("benefit_illustration")
or data.get("benefit_table")
or data.get("cash_value_projection")
or []
)
return {
2026-07-30 16:07:57 +08:00
"age": data.get("age"),
"gender": data.get("gender"),
"currency": data.get("currency"),
2026-07-31 09:00:39 +08:00
"sum_assured": sum_assured,
"premium_term": premium_term,
"annual_premium": annual_premium,
"coverage_period": coverage_period,
2026-07-30 16:07:57 +08:00
"key_benefits": data.get("key_benefits") or [],
2026-07-31 09:00:39 +08:00
"benefit_table": benefit_table,
"plan_type": plan_type,
"extraction_status": status,
2026-07-30 16:07:57 +08:00
}
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"
db.session.commit()
# ─── 锁管理 ────────────────────────────────────────────────
def _acquire_lock(key: str) -> bool:
"""获取任务锁(优先 Redis降级本地内存"""
redis_key = f"poster_task_lock:{key}"
try:
from insurance.db.compat import redis_client
if redis_client and redis_client.set(redis_key, "1", nx=True, ex=1800):
_redis_locks.add(key)
return True
if redis_client:
return False
except Exception:
pass
with _local_locks_guard:
if key in _local_locks:
return False
_local_locks.add(key)
return True
def _release_lock(key: str):
"""释放任务锁。"""
if key in _redis_locks:
try:
from insurance.db.compat import redis_client
if redis_client:
redis_client.delete(f"poster_task_lock:{key}")
except Exception:
pass
_redis_locks.discard(key)
with _local_locks_guard:
_local_locks.discard(key)