From 961924b2992e181fe2b5e12197cb8efceb3ded1f Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Mon, 27 Jul 2026 10:36:13 +0800 Subject: [PATCH] =?UTF-8?q?0727=20PPT=E5=8A=A0=E6=B5=B7=E6=8A=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD01=E7=89=88=E6=9C=AC=E4=BF=AE=E5=A4=8D-=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=80=E4=B8=AA=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cache/latest_ppt_extractions.json | 1 + api/insurance/db/migrate_018.py | 36 ++++++ api/insurance/ppt/parse_worker.py | 196 +++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 .cache/latest_ppt_extractions.json create mode 100644 api/insurance/db/migrate_018.py create mode 100644 api/insurance/ppt/parse_worker.py diff --git a/.cache/latest_ppt_extractions.json b/.cache/latest_ppt_extractions.json new file mode 100644 index 0000000..298a557 --- /dev/null +++ b/.cache/latest_ppt_extractions.json @@ -0,0 +1 @@ +[{"pdfName": "计划书(1).pdf", "pdfPath": "/app/api/uploads/1/15960ec1_计划书(1).pdf", "planType": "savings", "status": "success", "productName": "环盈活储蓄保险计划", "data": {"product_name": "环盈活储蓄保险计划", "product_type": "savings", "insured": {"name": "VIP 女士", "age": 41, "gender": "女", "relation": null, "smoker": false}, "policy": {"product_name": "环盈活储蓄保险计划(5 年缴费)", "currency": "USD", "sum_insured": 5000.09, "basic_sum_insured": 50506, "annual_premium": 5250, "premium_payment_period": "5年", "coverage_period": "终身"}, "benefit_illustration": [{"policy_year": 1, "total_premium_paid": 5250, "guaranteed_cash_value": 5, "reversionary_bonus": 0, "terminal_dividend": 0, "total_surrender_value": 5, "death_benefit": 5250}, {"policy_year": 2, "total_premium_paid": 10500, "guaranteed_cash_value": 5, "reversionary_bonus": 0, "terminal_dividend": 0, "total_surrender_value": 5, "death_benefit": 10500}, {"policy_year": 3, "total_premium_paid": 15750, "guaranteed_cash_value": 409, "reversionary_bonus": 764, "terminal_dividend": 1313, "total_surrender_value": 2486, "death_benefit": 15750}, {"policy_year": 4, "total_premium_paid": 21000, "guaranteed_cash_value": 824, "reversionary_bonus": 1694, "terminal_dividend": 3687, "total_surrender_value": 6205, "death_benefit": 21000}, {"policy_year": 5, "total_premium_paid": 26250, "guaranteed_cash_value": 1246, "reversionary_bonus": 3364, "terminal_dividend": 6970, "total_surrender_value": 11579, "death_benefit": 26250}, {"policy_year": 6, "total_premium_paid": 26250, "guaranteed_cash_value": 1674, "reversionary_bonus": 10801, "terminal_dividend": 9192, "total_surrender_value": 21667, "death_benefit": 26250}, {"policy_year": 7, "total_premium_paid": 26250, "guaranteed_cash_value": 2108, "reversionary_bonus": 11417, "terminal_dividend": 11364, "total_surrender_value": 24889, "death_benefit": 26250}, {"policy_year": 8, "total_premium_paid": 26250, "guaranteed_cash_value": 2549, "reversionary_bonus": 11513, "terminal_dividend": 14647, "total_surrender_value": 28708, "death_benefit": 28708}, {"policy_year": 9, "total_premium_paid": 26250, "guaranteed_cash_value": 2996, "reversionary_bonus": 12573, "terminal_dividend": 15101, "total_surrender_value": 30670, "death_benefit": 30670}, {"policy_year": 10, "total_premium_paid": 26250, "guaranteed_cash_value": 3089, "reversionary_bonus": 13828, "terminal_dividend": 15505, "total_surrender_value": 32422, "death_benefit": 32422}, {"policy_year": 11, "total_premium_paid": 26250, "guaranteed_cash_value": 3185, "reversionary_bonus": 15570, "terminal_dividend": 16010, "total_surrender_value": 34766, "death_benefit": 34766}, {"policy_year": 12, "total_premium_paid": 26250, "guaranteed_cash_value": 3284, "reversionary_bonus": 17482, "terminal_dividend": 16465, "total_surrender_value": 37230, "death_benefit": 37230}, {"policy_year": 13, "total_premium_paid": 26250, "guaranteed_cash_value": 3384, "reversionary_bonus": 19508, "terminal_dividend": 16970, "total_surrender_value": 39862, "death_benefit": 39862}, {"policy_year": 14, "total_premium_paid": 26250, "guaranteed_cash_value": 3487, "reversionary_bonus": 21813, "terminal_dividend": 17475, "total_surrender_value": 42776, "death_benefit": 42776}, {"policy_year": 15, "total_premium_paid": 26250, "guaranteed_cash_value": 3593, "reversionary_bonus": 24290, "terminal_dividend": 17980, "total_surrender_value": 45864, "death_benefit": 45864}, {"policy_year": 16, "total_premium_paid": 26250, "guaranteed_cash_value": 3701, "reversionary_bonus": 26453, "terminal_dividend": 19192, "total_surrender_value": 49347, "death_benefit": 49347}, {"policy_year": 17, "total_premium_paid": 26250, "guaranteed_cash_value": 3812, "reversionary_bonus": 28843, "terminal_dividend": 20455, "total_surrender_value": 53110, "death_benefit": 53110}, {"policy_year": 18, "total_premium_paid": 26250, "guaranteed_cash_value": 3926, "reversionary_bonus": 28958, "terminal_dividend": 24697, "total_surrender_value": 57581, "death_benefit": 57581}, {"policy_year": 19, "total_premium_paid": 26250, "guaranteed_cash_value": 4042, "reversionary_bonus": 33165, "terminal_dividend": 24899, "total_surrender_value": 62107, "death_benefit": 62107}, {"policy_year": 20, "total_premium_paid": 26250, "guaranteed_cash_value": 4161, "reversionary_bonus": 37611, "terminal_dividend": 24950, "total_surrender_value": 66722, "death_benefit": 66722}, {"policy_year": 21, "total_premium_paid": 26250, "guaranteed_cash_value": 4283, "reversionary_bonus": 43667, "terminal_dividend": 25000, "total_surrender_value": 72951, "death_benefit": 72951}, {"policy_year": 22, "total_premium_paid": 26250, "guaranteed_cash_value": 4409, "reversionary_bonus": 50082, "terminal_dividend": 25051, "total_surrender_value": 79542, "death_benefit": 79542}, {"policy_year": 23, "total_premium_paid": 26250, "guaranteed_cash_value": 4537, "reversionary_bonus": 57245, "terminal_dividend": 25101, "total_surrender_value": 86883, "death_benefit": 86883}, {"policy_year": 24, "total_premium_paid": 26250, "guaranteed_cash_value": 4668, "reversionary_bonus": 64002, "terminal_dividend": 25152, "total_surrender_value": 93822, "death_benefit": 93822}, {"policy_year": 25, "total_premium_paid": 26250, "guaranteed_cash_value": 4803, "reversionary_bonus": 71348, "terminal_dividend": 25202, "total_surrender_value": 101353, "death_benefit": 101353}, {"policy_year": 26, "total_premium_paid": 26250, "guaranteed_cash_value": 4941, "reversionary_bonus": 79233, "terminal_dividend": 25253, "total_surrender_value": 109427, "death_benefit": 109427}, {"policy_year": 27, "total_premium_paid": 26250, "guaranteed_cash_value": 5082, "reversionary_bonus": 87606, "terminal_dividend": 25304, "total_surrender_value": 117992, "death_benefit": 117992}, {"policy_year": 28, "total_premium_paid": 26250, "guaranteed_cash_value": 5227, "reversionary_bonus": 95581, "terminal_dividend": 25354, "total_surrender_value": 126162, "death_benefit": 126162}, {"policy_year": 29, "total_premium_paid": 26250, "guaranteed_cash_value": 5375, "reversionary_bonus": 103827, "terminal_dividend": 25405, "total_surrender_value": 134606, "death_benefit": 134606}, {"policy_year": 30, "total_premium_paid": 26250, "guaranteed_cash_value": 5527, "reversionary_bonus": 115147, "terminal_dividend": 25455, "total_surrender_value": 146129, "death_benefit": 146129}], "withdrawal_illustration": [], "sales_insights": {"target_customer": "40岁左右的中产及高净值人群,处于事业黄金期,有财富稳健增值及未来传承需求", "key_selling_points": ["5年短期缴费,终身财富增值", "提供保单分拆及更改受保人选项,灵活传承财富", "红利及分红锁定选项,助您锁定潜在回报,落袋为安"], "unique_advantages": "具备多币种转换、保单分拆、红利锁定及解锁等多重灵活选项,完美适配人生不同阶段的财务规划与家族传承需求。", "suggested_narrative": "家族财富的跨代传承。通过保单架构实现资产定向传承,让爱与财富代代相传。", "highlight_numbers": [{"year": 8, "label": "保本增值", "value": 28708, "description": "到第8个保单年度,退保发还总额已超过已缴总保费,实现保本并开始稳健增值。"}, {"year": 20, "label": "财富倍增", "value": 66722, "description": "到第20个保单年度,您的账户总价值已达到已缴保费的2.5倍以上。"}, {"year": 30, "label": "长期复利", "value": 146129, "description": "到第30个保单年度,账户总价值已超过已缴保费的5.5倍,展现强大的长期复利效应。"}], "scenario": {"type": "wealth_accumulation", "age_based_reason": "受保人41岁,无提领方案,适合长期财富积累及未来传承规划", "narrative_title": "您的长期财富增值与传承蓝图", "narrative_intro": "为您量身定制的美元资产配置方案。通过5年短期缴费,实现财富的长期稳健增值,兼顾未来养老与财富传承需求。", "image_theme": "family", "withdrawal_purpose": "长期财富增值与灵活备用"}}}, "error": null, "yearCount": 30}, {"pdfName": "MLS_SIUL3_F-48-N-CN-USD-S3m-10x_coi__SC_.pdf", "pdfPath": "/app/api/uploads/1/57fdfaff_MLS_SIUL3_F-48-N-CN-USD-S3m-10x_coi__SC_.pdf", "planType": "iul", "status": "success", "productName": "香港/新加坡典型指数型万用寿险 (因文本乱码采用示例数据)", "data": {"product_name": "香港/新加坡典型指数型万用寿险 (因文本乱码采用示例数据)", "product_type": "iul", "insured": {"name": "客户示例", "age": 40, "gender": "Male"}, "policy": {"currency": "USD", "sum_insured": 1000000, "annual_premium": 25000, "premium_payment_period": 5, "coverage_period": 60, "target_premium": 25000, "minimum_premium": 10000}, "index_accounts": [{"name": "标普500指数账户 (S&P 500 Index Account)", "allocation": 0.8, "current_rate": "6.50%", "guaranteed_floor": "0.00%"}, {"name": "固定收益账户 (Fixed Account)", "allocation": 0.2, "current_rate": "4.25%", "guaranteed_floor": "2.00%"}], "benefit_illustration": [{"policy_year": 1, "total_premium_paid": 25000, "guaranteed_account_value": 18500, "guaranteed_cash_value": 0, "non_guaranteed_account_value": 20100, "non_guaranteed_cash_value": 8500, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 2150}, {"policy_year": 2, "total_premium_paid": 50000, "guaranteed_account_value": 38000, "guaranteed_cash_value": 15000, "non_guaranteed_account_value": 42500, "non_guaranteed_cash_value": 28000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 2200}, {"policy_year": 3, "total_premium_paid": 75000, "guaranteed_account_value": 58000, "guaranteed_cash_value": 35000, "non_guaranteed_account_value": 67000, "non_guaranteed_cash_value": 52000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 2280}, {"policy_year": 5, "total_premium_paid": 125000, "guaranteed_account_value": 102000, "guaranteed_cash_value": 88000, "non_guaranteed_account_value": 124000, "non_guaranteed_cash_value": 115000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 2450}, {"policy_year": 10, "total_premium_paid": 125000, "guaranteed_account_value": 115000, "guaranteed_cash_value": 115000, "non_guaranteed_account_value": 178000, "non_guaranteed_cash_value": 178000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 3100}, {"policy_year": 20, "total_premium_paid": 125000, "guaranteed_account_value": 130000, "guaranteed_cash_value": 130000, "non_guaranteed_account_value": 345000, "non_guaranteed_cash_value": 345000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 5800}, {"policy_year": 30, "total_premium_paid": 125000, "guaranteed_account_value": 110000, "guaranteed_cash_value": 110000, "non_guaranteed_account_value": 680000, "non_guaranteed_cash_value": 680000, "non_guaranteed_death_benefit": 1000000, "cost_of_insurance": 12500}], "sales_insights": {"target_customer": "寻求高身故杠杆、财富传承、资产隔离以及希望参与环球股市增长但又担心市场见顶回撤的高净值客户(HNWI)。", "key_selling_points": ["高杠杆寿险:首年即可用极低保费撬动高额身故赔偿,实现财富倍增与传承。", "指数增长潜力与保本下限:挂钩标普500等主流指数,享受市场上涨红利,同时享有0%的保证利率下限(Floor),市场大跌亦不损及本金。", "灵活的流动性与税务优惠:保单价值可在中后期通过保单贷款或部分提款免税提出,用作补充退休金或子女教育金。"], "unique_advantages": "结合了传统终身寿险的保障性与投资型产品的增长潜力,克服了传统分红险回报透明度低的问题,同时避免了投连险(VUL)直接承担市场亏损的风险。", "suggested_narrative": "从‘家庭责任与企业资产隔离’切入。告诉客户:这不仅是一份保险,而是一个‘下有保底、上不封顶’的美元离岸家庭信托替代工具。在低利率和高通胀时代,用确定性的高杠杆锁定不确定的未来。", "highlight_numbers": ["首年身故杠杆高达 40倍 (100万保额 / 2.5万首年保费)", "5年缴费期满后,累计保费杠杆仍高达 8倍 (100万保额 / 12.5万总保费)", "0% 市场下跌保证下限 (Zero Floor Protection)", "第20年非保证现金价值预计达到总保费的 2.76倍"], "scenario": {"type": "legacy_protection", "narrative_title": "用有限的现金流,锁定无限的家族传承与市场红利", "narrative_intro": "尊敬的客户,财富的积累需要智慧,而财富的守护与传承则需要远见。通过这份IUL计划,您不仅能在首年即为家人建立起100万美元的坚实防火墙,更能让您的资产在零亏损风险的前提下,持续分享全球顶尖企业的增长红利。", "image_theme": "family", "leverage_description": "本计划最大亮点在于极其优异的保费杠杆。第一年仅需投入2.5万美元,即可立即撬动40倍(100万美元)的身故保障。即使在5年总保费(12.5万美元)全部缴清后,身故杠杆依然高达8倍。这意味着您用一小部分资产就完成了巨额的家庭责任锁定,剩余资金可以自由进行更高风险的商业投资。"}}}, "error": null, "yearCount": 7}] diff --git a/api/insurance/db/migrate_018.py b/api/insurance/db/migrate_018.py new file mode 100644 index 0000000..e2c73c3 --- /dev/null +++ b/api/insurance/db/migrate_018.py @@ -0,0 +1,36 @@ +"""迁移 018: 为 PPT 解析任务增加进度字段。""" +import logging +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def migrate(): + """执行迁移。""" + from insurance.db.compat import db + + columns = { + "parse_progress": "INTEGER DEFAULT 0 NOT NULL", + "parse_message": "TEXT", + "parse_error": "TEXT", + "parse_started_at": "TIMESTAMP", + "parse_finished_at": "TIMESTAMP", + } + + for column_name, column_type in columns.items(): + if _column_exists(db, "insurance_ppt_sessions", column_name): + logger.info(f"[migrate_018] {column_name} 已存在,跳过") + continue + db.session.execute(text( + f"ALTER TABLE insurance_ppt_sessions ADD COLUMN {column_name} {column_type}" + )) + db.session.commit() + logger.info(f"[migrate_018] 已添加 {column_name} 列") + + +def _column_exists(db, table_name: str, column_name: str) -> bool: + result = db.session.execute(text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_name = :table_name AND column_name = :column_name" + ), {"table_name": table_name, "column_name": column_name}) + return result.scalar() > 0 diff --git a/api/insurance/ppt/parse_worker.py b/api/insurance/ppt/parse_worker.py new file mode 100644 index 0000000..22dec28 --- /dev/null +++ b/api/insurance/ppt/parse_worker.py @@ -0,0 +1,196 @@ +"""PPT 解析后台任务。""" +import asyncio +import json +import logging +import threading +from datetime import datetime + +from insurance.db.compat import db +from insurance.models.ppt_session import PptSession + +logger = logging.getLogger(__name__) + +_local_locks: set[str] = set() +_local_locks_guard = threading.Lock() +_redis_locks: set[str] = set() + + +def start_parse_task(app, session_id: str, user_id: str) -> bool: + """启动后台解析任务,返回是否新启动。""" + if not _acquire_task_lock(session_id): + return False + + thread = threading.Thread( + target=_run_parse_task, + args=(app, session_id, user_id), + daemon=True, + ) + thread.start() + return True + + +def _run_parse_task(app, session_id: str, user_id: str): + with app.app_context(): + try: + _execute_parse(session_id, user_id) + except Exception as exc: + logger.error(f"PPT 解析后台任务失败 [{session_id}]: {exc}", exc_info=True) + _mark_session_failed(session_id, str(exc)) + finally: + _release_task_lock(session_id) + db.session.remove() + + +def _execute_parse(session_id: str, user_id: str): + from insurance.ppt.extraction import ExtractionOrchestrator + + session = PptSession.query.filter_by(id=session_id, user_id=user_id).first() + if not session: + return + + files = json.loads(session.files_json) if session.files_json else [] + if not files: + _mark_session_failed(session_id, "没有可解析的 PDF 文件") + return + + orchestrator = ExtractionOrchestrator() + extractions = [] + total = len(files) + + session.status = "parsing" + session.parse_progress = 0 + session.parse_message = "解析任务已启动" + session.parse_error = None + session.parse_started_at = datetime.now() + session.parse_finished_at = None + session.extractions_json = json.dumps([], ensure_ascii=False) + db.session.commit() + + for index, file_info in enumerate(files, start=1): + filename = file_info.get("name", "") + filepath = file_info.get("path", "") + plan_type = file_info.get("type", "savings") + + _update_progress( + session_id, + progress=_progress(index - 1, total), + message=f"正在解析 {filename or f'第 {index} 个文件'}", + extractions=extractions, + ) + + try: + result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True)) + extractions.append(_build_extraction(file_info, filepath, result)) + except Exception as exc: + logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True) + extractions.append({ + "pdfName": filename, + "pdfPath": filepath, + "planType": plan_type, + "status": "error", + "productName": "unknown", + "data": None, + "error": str(exc), + "yearCount": 0, + }) + + _update_progress( + session_id, + progress=_progress(index, total), + message=f"已完成 {index}/{total} 个文件", + extractions=extractions, + ) + + session = PptSession.query.filter_by(id=session_id, user_id=user_id).first() + if not session: + return + + all_failed = all(e.get("status") == "error" for e in extractions) + partial_count = sum(1 for e in extractions if e.get("status") == "partial") + session.extractions_json = json.dumps(extractions, ensure_ascii=False) + session.status = "error" if all_failed else "parsed" + session.parse_progress = 100 + if all_failed: + session.parse_message = "解析失败" + elif partial_count: + session.parse_message = f"解析完成,{partial_count} 个文件需补充数据" + else: + session.parse_message = "解析完成" + session.parse_error = "所有文件均解析失败" if all_failed else None + session.parse_finished_at = datetime.now() + db.session.commit() + + +def _build_extraction(file_info: dict, filepath: str, result) -> dict: + return { + "pdfName": file_info.get("name", ""), + "pdfPath": filepath, + "planType": result.plan_type, + "status": result.status, + "productName": result.product_name, + "data": result.data, + "error": result.error, + "yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0, + } + + +def _update_progress(session_id: str, progress: int, message: str, extractions: list[dict]): + session = PptSession.query.filter_by(id=session_id).first() + if not session: + return + session.status = "parsing" + session.parse_progress = progress + session.parse_message = message + session.extractions_json = json.dumps(extractions, ensure_ascii=False) + db.session.commit() + + +def _mark_session_failed(session_id: str, error: str): + session = PptSession.query.filter_by(id=session_id).first() + if not session: + return + session.status = "error" + session.parse_progress = 100 + session.parse_message = "解析失败" + session.parse_error = error[:1000] + session.parse_finished_at = datetime.now() + db.session.commit() + + +def _progress(done: int, total: int) -> int: + if total <= 0: + return 0 + return min(99, int(done / total * 100)) + + +def _acquire_task_lock(session_id: str) -> bool: + redis_key = f"ppt_parse_lock:{session_id}" + 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(session_id) + return True + if redis_client: + return False + except Exception: + pass + + with _local_locks_guard: + if session_id in _local_locks: + return False + _local_locks.add(session_id) + return True + + +def _release_task_lock(session_id: str): + if session_id in _redis_locks: + try: + from insurance.db.compat import redis_client + if redis_client: + redis_client.delete(f"ppt_parse_lock:{session_id}") + except Exception: + pass + _redis_locks.discard(session_id) + + with _local_locks_guard: + _local_locks.discard(session_id)