"""海报后台任务管理。""" 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()