diff --git a/api/insurance/db/migrate_033.py b/api/insurance/db/migrate_033.py new file mode 100644 index 0000000..f889f1c --- /dev/null +++ b/api/insurance/db/migrate_033.py @@ -0,0 +1,44 @@ +"""迁移 033:补充宏利 SIUL 3 IUL 产品配置。""" +import json +import logging + +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def migrate(): + from insurance.db.compat import db + + db.session.execute(text( + "INSERT INTO insurance_ppt_products " + "(id, company_id, plan_type, display_name, aliases_json, " + "required_modules_json, status, sort_order) " + "SELECT :id, :company_id, :plan_type, :display_name, :aliases_json, " + ":required_modules_json, 1, 20 " + "WHERE NOT EXISTS (" + "SELECT 1 FROM insurance_ppt_products WHERE id = :id" + ")" + ), { + "id": "manulife-siul3-iul", + "company_id": "manulife", + "plan_type": "iul", + "display_name": "Manulife SIUL 3", + "aliases_json": json.dumps([ + "SIUL 3", + "SIUL3", + "Manulife SIUL", + "Manulife Strategic Indexed Universal Life", + ], ensure_ascii=False), + "required_modules_json": json.dumps([ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing", + ], ensure_ascii=False), + }) + db.session.commit() + logger.info("[migrate_033] 已补充宏利 SIUL 3 产品配置") diff --git a/api/insurance/db/migrate_034.py b/api/insurance/db/migrate_034.py new file mode 100644 index 0000000..a032f35 --- /dev/null +++ b/api/insurance/db/migrate_034.py @@ -0,0 +1,60 @@ +"""迁移 034:补充永明 SBIUL 2 和全美 GIUL 3 产品配置。""" +import json +import logging + +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def migrate(): + from insurance.db.compat import db + + products = [ + { + "id": "sunlife-sbiul2-iul", + "company_id": "sunlife", + "display_name": "Sun Life SBIUL 2", + "aliases": [ + "SBIUL 2", "SBIUL2", "Sun Life SBIUL", + "SunBrilliance Indexed Universal Life II", + ], + }, + { + "id": "transamerica-giul3-iul", + "company_id": "transamerica", + "display_name": "Transamerica GIUL 3", + "aliases": [ + "GIUL 3", "GIUL3", "Transamerica GIUL", + "Global Indexed Universal Life 3", + ], + }, + ] + required_modules = json.dumps([ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing", + ], ensure_ascii=False) + for product in products: + db.session.execute(text( + "INSERT INTO insurance_ppt_products " + "(id, company_id, plan_type, display_name, aliases_json, " + "required_modules_json, status, sort_order) " + "SELECT :id, :company_id, 'iul', :display_name, :aliases_json, " + ":required_modules_json, 1, 20 " + "WHERE NOT EXISTS (" + "SELECT 1 FROM insurance_ppt_products WHERE id = :id" + ")" + ), { + "id": product["id"], + "company_id": product["company_id"], + "display_name": product["display_name"], + "aliases_json": json.dumps(product["aliases"], ensure_ascii=False), + "required_modules_json": required_modules, + }) + db.session.commit() + logger.info("[migrate_034] 已补充永明 SBIUL 2 和全美 GIUL 3 产品配置") diff --git a/api/insurance/db/migrate_035.py b/api/insurance/db/migrate_035.py new file mode 100644 index 0000000..97c1705 --- /dev/null +++ b/api/insurance/db/migrate_035.py @@ -0,0 +1,57 @@ +"""迁移 035:补充富卫 IF 和友邦 PIL2 的 IUL 产品配置。""" +import json +import logging + +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def migrate(): + from insurance.db.compat import db + + products = [ + { + "id": "fwd-if-iul", + "company_id": "fwd", + "display_name": "FWD IF", + "aliases": [ + "FWD IF", "FWDSG IF", "FWD Singapore IF", + "IF Indexed Universal Life", + ], + }, + { + "id": "aia-pil2-iul", + "company_id": "aia", + "display_name": "AIA PIL2", + "aliases": ["PIL2", "PIL 2", "AIA PIL2"], + }, + ] + required_modules = json.dumps([ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing", + ], ensure_ascii=False) + for product in products: + db.session.execute(text( + "INSERT INTO insurance_ppt_products " + "(id, company_id, plan_type, display_name, aliases_json, " + "required_modules_json, status, sort_order) " + "SELECT :id, :company_id, 'iul', :display_name, :aliases_json, " + ":required_modules_json, 1, 20 " + "WHERE NOT EXISTS (" + "SELECT 1 FROM insurance_ppt_products WHERE id = :id" + ")" + ), { + "id": product["id"], + "company_id": product["company_id"], + "display_name": product["display_name"], + "aliases_json": json.dumps(product["aliases"], ensure_ascii=False), + "required_modules_json": required_modules, + }) + db.session.commit() + logger.info("[migrate_035] 已补充富卫 IF 和友邦 PIL2 产品配置") diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index d0a3d8d..9cc2cb8 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -341,6 +341,7 @@ def _execute_ppt_generate(task_id: str): template_id = snapshot.get("templateId", "") template_asset_snapshot = snapshot.get("templateAsset") or {} company_id = snapshot.get("companyId", "") + company_ids = list(snapshot.get("companyIds") or ([company_id] if company_id else [])) brand_policy = snapshot.get("brandPolicy") or {} requested_scenario = snapshot.get("scenario", "") @@ -368,7 +369,8 @@ def _execute_ppt_generate(task_id: str): issues = validate_formal_savings_plan(normalized) normalized["fileId"] = ext.get("fileId") or ext.get("pdfName", "") normalized["pdfName"] = ext.get("pdfName", "") - normalized["companyId"] = ext.get("companyId") or company_id or "" + fallback_company_id = company_ids[0] if len(company_ids) == 1 else company_id + normalized["companyId"] = ext.get("companyId") or fallback_company_id or "" product_id = ext.get("productId") if product_id: configured_product = PptProduct.query.filter_by( @@ -507,27 +509,44 @@ def _execute_ppt_generate(task_id: str): or "clone-edit-v2" ) - if not company_id: - company_id = next( - (item.get("companyId") for item in all_normalized if item.get("companyId")), - "", - ) + company_ids = list(dict.fromkeys( + item.get("companyId") for item in all_normalized if item.get("companyId") + )) or company_ids + company_infos = {} + if company_ids: + from insurance.ppt.masking import apply_brand_policy + for current_company_id in company_ids: + company = PptCompany.query.filter_by( + id=current_company_id, deleted_at=None + ).first() + if not company: + continue + masked_company, _ = apply_brand_policy( + company.to_dict(), None, brand_policy + ) + company_infos[current_company_id] = masked_company + + for item in all_normalized: + item["companyInfo"] = company_infos.get(item.get("companyId"), {}) company_info = None - if company_id: - company = PptCompany.query.filter_by(id=company_id, deleted_at=None).first() - if company: - company_info = company.to_dict() - company_masking = brand_policy.get( - "companyMaskingEnabled", - bool(company.masking_enabled), - ) - logo_enabled = brand_policy.get("logoEnabled", bool(company.logo_enabled)) - if company_masking: - from insurance.ppt.masking import apply_company_mask - apply_company_mask(company_info, True) - if not logo_enabled: - company_info["logoUrl"] = "" + if len(company_infos) == 1: + company_info = next(iter(company_infos.values())) + company_id = company_info.get("id", "") + elif len(company_infos) > 1: + company_info = { + "id": "multi", + "displayName": " / ".join( + item.get("displayName", "") + for item in company_infos.values() + if item.get("displayName") + ), + "companyIntro": "", + "companyHighlights": [], + "rating": "", + "logoUrl": "", + } + company_id = "" _update_task_status(task_id, stage="rendering", progress=50, message="渲染 PPT") diff --git a/api/insurance/ppt/config/products/aia/pil2-iul.json b/api/insurance/ppt/config/products/aia/pil2-iul.json new file mode 100644 index 0000000..b29c0f8 --- /dev/null +++ b/api/insurance/ppt/config/products/aia/pil2-iul.json @@ -0,0 +1,20 @@ +{ + "id": "aia-pil2-iul", + "companyId": "aia", + "planType": "iul", + "displayName": "AIA PIL2", + "aliases": [ + "PIL2", + "PIL 2", + "AIA PIL2" + ], + "requiredModules": [ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing" + ] +} diff --git a/api/insurance/ppt/config/products/fwd/if-iul.json b/api/insurance/ppt/config/products/fwd/if-iul.json new file mode 100644 index 0000000..1235d9e --- /dev/null +++ b/api/insurance/ppt/config/products/fwd/if-iul.json @@ -0,0 +1,21 @@ +{ + "id": "fwd-if-iul", + "companyId": "fwd", + "planType": "iul", + "displayName": "FWD IF", + "aliases": [ + "FWD IF", + "FWDSG IF", + "FWD Singapore IF", + "IF Indexed Universal Life" + ], + "requiredModules": [ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing" + ] +} diff --git a/api/insurance/ppt/config/products/manulife/siul3-iul.json b/api/insurance/ppt/config/products/manulife/siul3-iul.json new file mode 100644 index 0000000..bf398b3 --- /dev/null +++ b/api/insurance/ppt/config/products/manulife/siul3-iul.json @@ -0,0 +1,21 @@ +{ + "id": "manulife-siul3-iul", + "companyId": "manulife", + "planType": "iul", + "displayName": "Manulife SIUL 3", + "aliases": [ + "SIUL 3", + "SIUL3", + "Manulife SIUL", + "Manulife Strategic Indexed Universal Life" + ], + "requiredModules": [ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing" + ] +} diff --git a/api/insurance/ppt/config/products/sunlife/sbiul2-iul.json b/api/insurance/ppt/config/products/sunlife/sbiul2-iul.json new file mode 100644 index 0000000..f10dc6b --- /dev/null +++ b/api/insurance/ppt/config/products/sunlife/sbiul2-iul.json @@ -0,0 +1,21 @@ +{ + "id": "sunlife-sbiul2-iul", + "companyId": "sunlife", + "planType": "iul", + "displayName": "Sun Life SBIUL 2", + "aliases": [ + "SBIUL 2", + "SBIUL2", + "Sun Life SBIUL", + "SunBrilliance Indexed Universal Life II" + ], + "requiredModules": [ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing" + ] +} diff --git a/api/insurance/ppt/config/products/transamerica/giul3-iul.json b/api/insurance/ppt/config/products/transamerica/giul3-iul.json new file mode 100644 index 0000000..3c2e546 --- /dev/null +++ b/api/insurance/ppt/config/products/transamerica/giul3-iul.json @@ -0,0 +1,21 @@ +{ + "id": "transamerica-giul3-iul", + "companyId": "transamerica", + "planType": "iul", + "displayName": "Transamerica GIUL 3", + "aliases": [ + "GIUL 3", + "GIUL3", + "Transamerica GIUL", + "Global Indexed Universal Life 3" + ], + "requiredModules": [ + "cover", + "company", + "index-account-configuration", + "guaranteed-floor-analysis", + "legacy-leverage-analysis", + "recommendation", + "closing" + ] +} diff --git a/api/insurance/ppt/extraction.py b/api/insurance/ppt/extraction.py index 4a89491..496dea0 100644 --- a/api/insurance/ppt/extraction.py +++ b/api/insurance/ppt/extraction.py @@ -42,6 +42,8 @@ def infer_plan_type(raw: dict) -> str: return "ci" if "iul" in t or "universal" in t: return "iul" + if raw.get("index_accounts"): + return "iul" rows = raw.get("benefit_illustration", []) if not isinstance(rows, list): @@ -561,7 +563,7 @@ def _extract_pdf_text_ocr( ) image_path = os.path.join(temp_dir, f"page-{index + 1}.png") pixmap.save(image_path) - # 使用 psm 6(统一文本块),适合表格和表单 + # 使用 psm 4(多列布局),保险计划书表格的行列保留更稳定 completed = subprocess.run( [ tesseract, @@ -570,7 +572,7 @@ def _extract_pdf_text_ocr( "-l", ocr_lang, "--psm", - "6", + "4", "--oem", "3", ], @@ -644,7 +646,7 @@ def _ocr_specific_pages( image_path = os.path.join(temp_dir, f"page-{page_idx + 1}.png") pixmap.save(image_path) completed = subprocess.run( - [tesseract, image_path, "stdout", "-l", ocr_lang, "--psm", "6", "--oem", "3"], + [tesseract, image_path, "stdout", "-l", ocr_lang, "--psm", "4", "--oem", "3"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90, check=False, ) @@ -820,42 +822,73 @@ def _apply_filename_hints(data: dict, pdf_path: str, plan_type: str) -> dict: return data filename = os.path.basename(pdf_path) - if plan_type == "iul" and re.search(r"(?:^|[_-])SIUL3(?:[_-]|$)", filename, re.IGNORECASE): - def set_missing(target: dict, key: str, value) -> None: - if target.get(key) in (None, "", "unknown"): - target[key] = value + product_prefixes = ( + ("MLS_SIUL3", "Manulife SIUL 3", "iul"), + ("SLS_SBIUL2", "Sun Life SBIUL 2", "iul"), + ("TA_GIUL3", "Transamerica GIUL 3", "iul"), + ("FWDSG_IF", "FWD Singapore IF", "iul"), + ("FWD_IF", "FWD IF", "iul"), + ("CL_HISP+", "CL HISP+", "savings"), + ("GE_PLG4", "GE PLG4", "savings"), + ("AIA_PIL2", "AIA PIL2", "iul"), + ) + matched_prefix = None + for prefix, product_name, product_type in product_prefixes: + if filename.lower().startswith(prefix.lower()): + matched_prefix = prefix + data["product_name"] = product_name + data.setdefault("policy", {})["product_name"] = product_name + if product_type: + data["product_type"] = product_type + break + if not matched_prefix: + return data - data["product_name"] = "Manulife SIUL 3" - insured = data.setdefault("insured", {}) - policy = data.setdefault("policy", {}) - if isinstance(policy, dict): - policy["product_name"] = "Manulife SIUL 3" + insured = data.setdefault("insured", {}) + policy = data.setdefault("policy", {}) + def set_missing(target: dict, key: str, value) -> None: + if target.get(key) in (None, "", "unknown"): + target[key] = value - # 标准文件名示例:SIUL3_F-48-N-CN-USD-S3m-5x。 - # 只补齐文件名明确编码且正文未识别的字段,绝不覆盖已识别值。 - identity_match = re.search( - r"(?:^|[_-])(?P[FM])-(?P\d{1,3})-(?P[NS])(?:[_-]|$)", - filename, - re.IGNORECASE, - ) - if identity_match and isinstance(insured, dict): - set_missing(insured, "gender", "female" if identity_match.group("gender").upper() == "F" else "male") - set_missing(insured, "age", int(identity_match.group("age"))) - set_missing(insured, "smoker", "no" if identity_match.group("smoker").upper() == "N" else "yes") + suffix = filename[len(matched_prefix):] + identity_match = re.match( + r"^[\s_-]*(?P[FM])-(?P\d{1,3})-(?P[NS])(?:[\s_-]|$)", + suffix, + re.IGNORECASE, + ) + if identity_match: + set_missing(insured, "gender", "female" if identity_match.group("gender").upper() == "F" else "male") + encoded_age = int(identity_match.group("age")) + if insured.get("age") in (None, "", "unknown") or float(insured.get("age") or 0) <= 5: + insured["age"] = encoded_age + set_missing(insured, "smoker", "no" if identity_match.group("smoker").upper() == "N" else "yes") - if isinstance(policy, dict): - currency_match = re.search(r"(?:^|[_-])(USD|HKD|CNY|RMB)(?:[_-]|$)", filename, re.IGNORECASE) - if currency_match: - set_missing(policy, "currency", currency_match.group(1).upper()) + currency_match = re.search( + r"(?:^|[\s_-])(USD|HKD|CNY|RMB)(?:[\s_-]|$)", filename, re.IGNORECASE + ) + if currency_match: + set_missing(policy, "currency", currency_match.group(1).upper()) - sum_match = re.search(r"(?:^|[_-])S(?P\d+(?:\.\d+)?)(?P[mMkK])(?:[_-]|$)", filename) - if sum_match: - multiplier = 1_000_000 if sum_match.group("unit").lower() == "m" else 1_000 - set_missing(policy, "sum_insured", float(sum_match.group("amount")) * multiplier) + payment_match = re.search( + r"(?:^|[\s_-])(?P\d{1,2})x(?:[\s_+()-]|$)", filename, re.IGNORECASE + ) + encoded_pay_years = int(payment_match.group("years")) if payment_match else None + if encoded_pay_years: + set_missing(policy, "premium_payment_period", encoded_pay_years) - payment_match = re.search(r"(?:^|[_-])(?P\d{1,2})x(?:[_-]|$)", filename, re.IGNORECASE) - if payment_match: - set_missing(policy, "premium_payment_period", int(payment_match.group("years"))) + amount_match = re.search( + r"(?:^|[\s_-])(?P[SP])(?P\d+(?:\.\d+)?)(?P[mMkK])(?:[\s_+()-]|$)", + filename, + re.IGNORECASE, + ) + if amount_match: + multiplier = 1_000_000 if amount_match.group("unit").lower() == "m" else 1_000 + amount = float(amount_match.group("amount")) * multiplier + if amount_match.group("kind").upper() == "S": + set_missing(policy, "sum_insured", amount) + else: + policy.setdefault("contractual_total_premium", amount) + set_missing(policy, "annual_premium", amount / (encoded_pay_years or 1)) return data @@ -871,6 +904,36 @@ def _has_sufficient_benefit_rows(rows: list[dict]) -> bool: return len(rows) >= 20 or (len(years) >= 5 and {10, 20, 30}.issubset(years) and max(years) >= 30) +def _should_use_iul_layout_rows(existing_rows: list[dict], layout_rows: list[dict]) -> bool: + """仅在坐标表格结果确实包含 IUL 核心列时替换文本解析结果。""" + if len(layout_rows) < 3: + return False + + def complete_count(rows: list[dict]) -> int: + return sum( + 1 + for row in rows + if row.get("policy_year") + and row.get("total_surrender_value") is not None + and ( + row.get("non_guaranteed_account_value") is not None + or row.get("account_value") is not None + ) + and ( + row.get("non_guaranteed_death_benefit") is not None + or row.get("death_benefit") is not None + ) + ) + + layout_complete = complete_count(layout_rows) + existing_complete = complete_count(existing_rows) + if layout_complete < 3: + return False + return layout_complete > existing_complete or ( + layout_complete == existing_complete and len(layout_rows) >= len(existing_rows) + ) + + def assess_extraction_payload(data: Optional[dict], plan_type: str) -> tuple[str, str]: """Return extraction status and a user-facing error when data is incomplete.""" if not isinstance(data, dict) or not data: @@ -957,6 +1020,65 @@ def _payload_score(data: Optional[dict]) -> int: ) +def _merge_extraction_data(data: dict, regex_data: dict, plan_type: str) -> dict: + """合并 LLM 与规则结果,并优先采用带明确标签的确定性字段。""" + if not isinstance(data, dict): + data = {} + if not isinstance(regex_data, dict): + regex_data = {} + + def is_missing(value) -> bool: + return value in (None, "", "unknown", [], {}) + + for key in ("product_name", "product_type", "currency"): + if is_missing(data.get(key)) and not is_missing(regex_data.get(key)): + data[key] = regex_data[key] + + for section in ("insured", "policy"): + target = data.setdefault(section, {}) + source = regex_data.get(section) or {} + if not isinstance(target, dict) or not isinstance(source, dict): + continue + for key, value in source.items(): + if is_missing(target.get(key)) and not is_missing(value): + target[key] = value + + from insurance.ppt.normalizer import normalize_smoker + + insured = data.setdefault("insured", {}) + regex_insured = regex_data.get("insured") or {} + current_smoker = normalize_smoker(insured.get("smoker")) + regex_smoker = normalize_smoker(regex_insured.get("smoker")) + if regex_smoker in ("yes", "no"): + if current_smoker in ("yes", "no") and current_smoker != regex_smoker: + data.setdefault("_meta", {}).setdefault("field_conflicts", []).append({ + "field": "insured.smoker", + "llm": current_smoker, + "regex": regex_smoker, + "selected": "regex", + }) + insured["smoker"] = regex_smoker + else: + insured["smoker"] = current_smoker + + policy = data.setdefault("policy", {}) + regex_policy = regex_data.get("policy") or {} + trusted_fields = ( + ("sum_insured", "sum_insured_source_label"), + ("annual_premium", "annual_premium_source_label"), + ("premium_payment_period", "premium_payment_period_source_label"), + ("basic_sum_insured", "basic_sum_insured_source_label"), + ("basic_plan_annual_premium", "basic_plan_annual_premium_source_label"), + ("first_year_amount_due", "first_year_amount_due_source_label"), + ) + for field, source_field in trusted_fields: + if regex_policy.get(source_field) and regex_policy.get(field) is not None: + policy[field] = regex_policy[field] + policy[source_field] = regex_policy[source_field] + + return data + + def _regex_quality_gate(regex_data: dict, plan_type: str) -> tuple[bool, list[str]]: """检查正则提取结果的关键字段完整性。 @@ -1146,13 +1268,14 @@ async def _llm_extract_split( "从以下 PDF 文本中提取保险计划书的身份和保单字段。\n" "只输出以下 JSON,不要输出利益演示表和提领表:\n" '{"product_name": "产品全称", "product_type": "savings/ci/iul", ' - '"insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": null}, ' + '"insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": "yes/no/unknown"}, ' f'{identity_shape}' + '}\n\n' "规则:\n" "1. gender 用 male/female\n" - "2. premium_payment_period 只输出数字(年数)\n" - "3. 无法确定的字段填 null\n" - "4. 只输出 JSON,无 markdown\n\n" + "2. smoker 必须输出 yes/no/unknown;Non-Smoker、非吸烟者、不吸烟输出 no\n" + "3. premium_payment_period 只输出数字(年数)\n" + "4. 无法确定的字段填 null\n" + "5. 只输出 JSON,无 markdown\n\n" f"PDF 文本:\n{identity_text}" ) @@ -1170,7 +1293,10 @@ async def _llm_extract_split( "product_name": {"type": "string"}, "insured": { "type": "object", - "required": ["age", "gender"], + "required": ["age", "gender", "smoker"], + "properties": { + "smoker": {"type": "string", "enum": ["yes", "no", "unknown"]}, + }, }, "policy": { "type": "object", @@ -1414,7 +1540,12 @@ class ExtractionOrchestrator: SAVINGS_PLAN_SYSTEM_PROMPT, CI_PLAN_SYSTEM_PROMPT, IUL_SYSTEM_PROMPT, ANALYSIS_SYSTEM_PROMPT, build_analysis_prompt, select_key_pages, ) - from insurance.ppt.regex_extractor import extract_insurance_regex, count_benefit_rows + from insurance.ppt.regex_extractor import ( + count_benefit_rows, + extract_insurance_regex, + extract_iul_layout, + extract_policy_summary_layout, + ) start = time.time() abs_path = os.path.abspath(pdf_path) @@ -1461,6 +1592,18 @@ class ExtractionOrchestrator: regex_start = time.time() used_ocr = pdf_text.startswith("[OCR]") regex_data = extract_insurance_regex(pdf_text) + layout_policy = extract_policy_summary_layout(abs_path) + if layout_policy: + regex_data.setdefault("policy", {}).update(layout_policy) + if plan_type == "iul" or infer_plan_type(regex_data) == "iul": + iul_layout = extract_iul_layout(abs_path) + for section in ("insured", "policy"): + if iul_layout.get(section): + regex_data.setdefault(section, {}).update(iul_layout[section]) + layout_rows = iul_layout.get("benefit_illustration") or [] + existing_rows = regex_data.get("benefit_illustration") or [] + if _should_use_iul_layout_rows(existing_rows, layout_rows): + regex_data["benefit_illustration"] = layout_rows regex_rows = count_benefit_rows(regex_data) regex_ms = (time.time() - regex_start) * 1000 if progress_callback: @@ -1550,22 +1693,12 @@ class ExtractionOrchestrator: key: value for key, value in split_meta.items() if key != "tokens" } - # 检查完整性,对缺失字段做针对性重试 - initial_status, initial_error = assess_extraction_payload(data, plan_type) - if initial_status == "partial" and initial_error: - if progress_callback: - progress_callback(88, "首次识别不完整,正在补充关键字段") + data = _merge_extraction_data(data, regex_data, plan_type) - # 用正则结果填补 LLM 缺失的字段 - if regex_data: - for key in ("product_name", "insured", "policy", "currency"): - if key in regex_data and (key not in data or not data[key]): - data[key] = regex_data[key] - # 利益表:取行数更多的那个 - regex_benefit = regex_data.get("benefit_illustration", []) - llm_benefit = data.get("benefit_illustration", []) - if len(regex_benefit) > len(llm_benefit) and (quality_passed or not llm_benefit): - data["benefit_illustration"] = regex_benefit + regex_benefit = regex_data.get("benefit_illustration", []) + llm_benefit = data.get("benefit_illustration", []) + if len(regex_benefit) > len(llm_benefit) and (quality_passed or not llm_benefit): + data["benefit_illustration"] = regex_benefit extraction_stats["llm_tokens"] = split_meta.get( "tokens", {"input": 0, "output": 0} @@ -1580,28 +1713,19 @@ class ExtractionOrchestrator: ) extraction_stats["llm_ms"] = round((time.time() - llm_start) * 1000, 1) + data = _merge_extraction_data(data, regex_data, plan_type) data = _apply_filename_hints(data, abs_path, plan_type) # 产品先验匹配:用用户选择的产品信息补正提取结果 if product_name_hint: extracted_name = _normalized_product_name(data) - if extracted_name == "unknown" or _is_obviously_invalid_product_name(extracted_name): - # 用户选择的产品名优先纠正空值和明显误识别(例如 Female)。 - data.setdefault("policy", {})["product_name"] = product_name_hint - data["product_name"] = product_name_hint + if extracted_name != product_name_hint: logger.info( - f"[ExtractionOrchestrator] 提取产品名 '{extracted_name}' 无效," - f"采用用户选择: {product_name_hint}" + f"[ExtractionOrchestrator] 采用用户选择的标准产品名: " + f"extracted='{extracted_name}', selected='{product_name_hint}'" ) - elif product_aliases: - # 检查提取的名称是否与别名匹配 - extracted_lower = extracted_name.lower() - all_names = [product_name_hint.lower()] + [a.lower() for a in product_aliases] - if not any(n in extracted_lower or extracted_lower in n for n in all_names): - logger.info( - f"[ExtractionOrchestrator] 提取产品名 '{extracted_name}' " - f"与用户选择 '{product_name_hint}' 不匹配,请用户确认" - ) + data.setdefault("policy", {})["product_name"] = product_name_hint + data["product_name"] = product_name_hint # 推断产品类型 detected_type = plan_type if plan_type in ("savings", "ci", "iul") else infer_plan_type(data) diff --git a/api/insurance/ppt/masking.py b/api/insurance/ppt/masking.py index 69a9223..665948d 100644 --- a/api/insurance/ppt/masking.py +++ b/api/insurance/ppt/masking.py @@ -91,19 +91,56 @@ def apply_product_mask(product_dict: dict, use_masked: bool) -> dict: return product_dict -def build_brand_policy(company: dict | None, products: list[dict] | None) -> dict: - """从后台配置生成不可由用户覆盖的品牌策略快照。""" - company = company or {} - products = products or [] +def public_company_option(company: dict) -> dict: + """生成用户端保司选项,避免把原始名称和别名发送到浏览器。""" + item = apply_company_mask(dict(company or {}), bool((company or {}).get("maskingEnabled"))) return { - "companyMaskingEnabled": bool(company.get("maskingEnabled")), + "id": item.get("id", ""), + "displayName": item.get("displayName", ""), + } + + +def public_product_option(product: dict) -> dict: + """生成用户端产品选项,仅暴露选择所需字段。""" + item = apply_product_mask(dict(product or {}), bool((product or {}).get("maskingEnabled"))) + return { + "id": item.get("id", ""), + "companyId": item.get("companyId", ""), + "planType": item.get("planType", ""), + "displayName": item.get("displayName", ""), + } + + +def build_brand_policy(company: dict | list[dict] | None, products: list[dict] | None) -> dict: + """从后台配置生成不可由用户覆盖的品牌策略快照。""" + companies = company if isinstance(company, list) else ([company] if company else []) + primary_company = companies[0] if companies else {} + products = products or [] + company_policy_by_id = {} + for item in companies: + company_id = str(item.get("id") or "") + if not company_id: + continue + masking_enabled = bool(item.get("maskingEnabled")) + display_name = item.get("displayName", "") + if masking_enabled: + display_name = item.get("maskedDisplayName") or fallback_mask_name(display_name) + company_policy_by_id[company_id] = { + "maskingEnabled": masking_enabled, + "displayName": display_name, + "logoEnabled": bool(item.get("logoEnabled", True)), + } + return { + # 保留旧字段,兼容历史任务和海报链路。 + "companyMaskingEnabled": bool(primary_company.get("maskingEnabled")), "productMaskingById": { str(product.get("id")): bool(product.get("maskingEnabled")) for product in products if product.get("id") }, - "logoEnabled": bool(company.get("logoEnabled", True)), - "policyVersion": 1, + "logoEnabled": bool(primary_company.get("logoEnabled", True)), + "companyPolicyById": company_policy_by_id, + "policyVersion": 2, } @@ -123,11 +160,20 @@ def apply_brand_policy( bool(product.get("maskingEnabled")), ) apply_product_mask(product, bool(product_masking)) - apply_company_mask(company, bool(policy.get( - "companyMaskingEnabled", - company.get("maskingEnabled"), - ))) - if not bool(policy.get("logoEnabled", company.get("logoEnabled", True))): + company_id = str(company.get("id") or "") + company_policy = (policy.get("companyPolicyById") or {}).get(company_id) or {} + company_masking = company_policy.get( + "maskingEnabled", + policy.get("companyMaskingEnabled", company.get("maskingEnabled")), + ) + if company_policy.get("displayName"): + company["maskedDisplayName"] = company_policy["displayName"] + apply_company_mask(company, bool(company_masking)) + logo_enabled = company_policy.get( + "logoEnabled", + policy.get("logoEnabled", company.get("logoEnabled", True)), + ) + if not bool(logo_enabled): company["logoUrl"] = "" return company, product diff --git a/api/insurance/ppt/normalizer.py b/api/insurance/ppt/normalizer.py index 1a34bf6..ad7e1e3 100644 --- a/api/insurance/ppt/normalizer.py +++ b/api/insurance/ppt/normalizer.py @@ -27,15 +27,29 @@ def _normalize_gender(raw) -> str: return "unknown" -def _normalize_smoker(raw) -> str: +def normalize_smoker(raw) -> str: """统一吸烟状态为 yes/no/unknown。""" if raw is None: return "unknown" value = str(raw).strip().lower() - if value in ("yes", "y", "true", "1", "是", "吸烟", "吸煙", "smoker"): - return "yes" - if value in ("no", "n", "false", "0", "否", "不吸烟", "不吸煙", "non-smoker", "nonsmoker"): + if ( + value in ("no", "n", "false", "0", "否", "不吸烟", "不吸煙", "非吸烟者", "非吸煙者") + or "non-smoker" in value + or "non smoker" in value + or "nonsmoker" in value + or "不吸烟" in value + or "不吸煙" in value + or "非吸烟" in value + or "非吸煙" in value + ): return "no" + if ( + value in ("yes", "y", "true", "1", "是", "吸烟", "吸煙") + or "smoker" in value + or "吸烟" in value + or "吸煙" in value + ): + return "yes" return "unknown" @@ -181,7 +195,7 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j "name": insured.get("name") or "客户", "age": int(insured_age), "gender": _normalize_gender(insured.get("gender")), - "smoker": _normalize_smoker(insured.get("smoker")), + "smoker": normalize_smoker(insured.get("smoker")), }, "policy": { "currency": _normalize_currency(policy.get("currency")), @@ -261,7 +275,7 @@ def normalize_ci_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") "name": insured.get("name") or "客户", "age": int(insured_age), "gender": _normalize_gender(insured.get("gender")), - "smoker": _normalize_smoker(insured.get("smoker")), + "smoker": normalize_smoker(insured.get("smoker")), }, "policy": { "currency": _normalize_currency(policy.get("currency")), @@ -370,7 +384,7 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json" "name": insured.get("name") or "客户", "age": int(insured_age), "gender": _normalize_gender(insured.get("gender")), - "smoker": _normalize_smoker(insured.get("smoker")), + "smoker": normalize_smoker(insured.get("smoker")), }, "policy": { "currency": _normalize_currency(policy.get("currency")), diff --git a/api/insurance/ppt/prompts.py b/api/insurance/ppt/prompts.py index 69e5fbc..dbe5174 100644 --- a/api/insurance/ppt/prompts.py +++ b/api/insurance/ppt/prompts.py @@ -10,7 +10,7 @@ SAVINGS_PLAN_SYSTEM_PROMPT = """你是香港保险计划书数据提取专家。 { "product_name": "产品全称", "product_type": "savings", - "insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": null}, + "insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": "yes/no/unknown"}, "policy": {"product_name": "产品名称", "currency": "ISO 4217代码", "sum_insured": null, "basic_sum_insured": null, "basic_plan_annual_premium": 数字或null, "first_year_amount_due": 数字或null, "annual_premium": 数字, "premium_payment_period": 数字, "coverage_period": "终身"}, "benefit_illustration": [{"policy_year": 数字, "age": 数字或null, "total_premium_paid": 数字, "guaranteed_cash_value": 数字, "reversionary_bonus": 数字, "terminal_dividend": 数字, "total_surrender_value": 数字, "death_benefit": 数字或null, "source_page": 数字或null}], "withdrawal_illustration": [{"policy_year": 数字, "annual_withdrawal": 数字, "total_withdrawn": 数字或null, "surrender_value_before": 数字或null, "surrender_value_after": 数字或null, "source_page": 数字或null}] diff --git a/api/insurance/ppt/regex_extractor.py b/api/insurance/ppt/regex_extractor.py index de367f8..9b0fd90 100644 --- a/api/insurance/ppt/regex_extractor.py +++ b/api/insurance/ppt/regex_extractor.py @@ -96,9 +96,12 @@ def _extract_currency(text: str) -> str | None: def _extract_labeled_money(text: str, labels: list[str]) -> tuple[float | None, str | None]: """提取明确标签后的金额,同时保留原始命中标签。""" - label_pattern = "|".join(re.escape(label) for label in labels) + label_pattern = "|".join( + r"\s*".join(re.escape(char) for char in label if not char.isspace()) + for label in labels + ) match = re.search( - rf"({label_pattern})\s*[::]\s*(?:[A-Z]{{2,3}}|[¥¥$£€]|US\$|HK\$)?\s*([\d,]+(?:\.\d+)?)", + rf"({label_pattern})\s*[::]?\s*(?:[A-Z]{{2,3}}|[¥¥$£€]|US\$|HK\$)?\s*([\d,]+(?:\.\d+)?)", text, re.IGNORECASE, ) @@ -111,6 +114,7 @@ def _extract_smoker(text: str) -> str | None: patterns = [ r"(?:吸烟状态|是否吸烟|吸煙狀態|是否吸煙|Smoking\s*Status|Smoker)\s*[::]\s*(是|否|吸烟|不吸烟|吸煙|不吸煙|Yes|No|Y|N)", r"(?:Non[-\s]?Smoker|Non[-\s]?Smoking)", + r"(?:非吸烟者|非吸煙者|不吸烟|不吸煙)", ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) @@ -129,6 +133,7 @@ def _extract_annual_premium(text: str) -> float | None: r'(?:首期规划保费|首期規劃保費|偿还至形成基金所需保费|償還至形成基金所需保費)\s*[::]?\s*(?:[A-Z]{2,3})?\$?\s*([\d,]+(?:\.\d+)?)', r'(?:每年(?:缴付)?保费|年缴保费|年保费|每年(?:繳付)?保費|年繳保費)\s*[::]\s*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)', r'(?:Annual\s+Premium|每年(?:缴付)?保费|年缴保费)\s*[::]?\s*(?:[A-Z]{2,3}\$?\s*)?([\d,]+(?:\.\d+)?)', + r'(?:投保时|投保時)\s*(?:年缴保费|年繳保費)\s*[::]?\s*(?:[A-Z]{2,3}\$?\s*)?([\d,]+(?:\.\d+)?)', ] for p in patterns: m = re.search(p, text, re.IGNORECASE) @@ -548,16 +553,6 @@ def _parse_data_row( return row if row else None -def _fill_missing_premiums(rows: list[dict], annual_premium: float | None) -> list[dict]: - """填充缺失的 total_premium_paid。""" - if not annual_premium or annual_premium <= 0: - return rows - for row in rows: - if row.get('total_premium_paid') is None and row.get('policy_year') is not None: - row['total_premium_paid'] = annual_premium * row['policy_year'] - return rows - - def _derive_policy_years(rows: list[dict], insured_age: int | float | None) -> list[dict]: """仅在表格只有年龄列时,使用明确投保年龄推算保单年度。""" if insured_age is None: @@ -755,15 +750,23 @@ def _detect_product_type(text: str, benefit_rows: list[dict]) -> str: ci_keywords = ['危疾', '重疾', '重大疾病', 'critical illness', 'ci plan', '严重疾病', '危疾保障', 'dread disease'] iul_keywords = ['iul', 'index universal', 'universal life', '指数型万用', - '万用寿险', '萬用壽險', 'index account', 'index_accounts'] + '指數型萬用', '万用寿险', '萬用壽險', 'index account', + 'indexed account', '指数账户', '指數賬戶', '指數戶口', 'index_accounts'] ci_score = sum(1 for kw in ci_keywords if kw in text_lower) iul_score = sum(1 for kw in iul_keywords if kw in text_lower) + savings_score = 0 # 数据特征判断 for row in benefit_rows: if row.get('account_value') or row.get('non_guaranteed_account_value'): iul_score += 3 + if row.get('total_surrender_value') is not None and ( + row.get('terminal_dividend') is not None + or row.get('reversionary_bonus') is not None + or row.get('guaranteed_cash_value') is not None + ): + savings_score += 3 if row.get('death_benefit') and not row.get('guaranteed_cash_value'): ci_score += 1 @@ -773,11 +776,559 @@ def _detect_product_type(text: str, benefit_rows: list[dict]) -> str: if iul_score > ci_score and iul_score > 0: return 'iul' + if savings_score > ci_score and savings_score > 0: + return 'savings' if ci_score > 0: return 'ci' return 'savings' +def _extract_summary_fields_from_words(words: list[tuple]) -> dict: + """按保单摘要表的横纵坐标提取明确金额列。""" + headers = [] + values = [] + for word in words: + if len(word) < 5: + continue + x0, y0, x1, y1, raw_text = word[:5] + text = re.sub(r"\s+|[((]\d+[))]", "", str(raw_text)) + field = None + label = None + if "基本金额" in text or "基本金額" in text: + field, label = "basic_sum_insured", "投保时基本金额" + elif "投保时保额" in text or "投保時保額" in text: + field, label = "sum_insured", "投保时保额" + elif "年缴保费" in text or "年繳保費" in text: + field, label = "annual_premium", "投保时年缴保费" + if field: + headers.append((field, label, float(x0), float(y0), float(x1), float(y1))) + value = _parse_money(str(raw_text)) + if value is not None and re.fullmatch(r"[\d,]+(?:\.\d+)?", str(raw_text).strip()): + values.append((value, float(x0), float(y0), float(x1), float(y1))) + + result = {} + for field, label, hx0, _, hx1, hy1 in headers: + candidates = [] + header_center = (hx0 + hx1) / 2 + for value, vx0, vy0, vx1, _ in values: + dy = vy0 - hy1 + value_center = (vx0 + vx1) / 2 + if 0 <= dy <= 80 and hx0 - 25 <= value_center <= hx1 + 25: + candidates.append((dy, abs(value_center - header_center), value)) + if candidates and field not in result: + _, _, value = min(candidates) + result[field] = value + result[f"{field}_source_label"] = label + return result + + +def extract_policy_summary_layout(pdf_path: str, max_pages: int = 3) -> dict: + """从 PDF 单词坐标提取摘要金额;解析失败时返回空字典。""" + try: + try: + import fitz + except ImportError: + import pymupdf as fitz + result = {} + with fitz.open(pdf_path) as doc: + for page_index in range(min(len(doc), max_pages)): + page_result = _extract_summary_fields_from_words( + doc[page_index].get_text("words") + ) + for key, value in page_result.items(): + result.setdefault(key, value) + if all(key in result for key in ( + "basic_sum_insured", "sum_insured", "annual_premium" + )): + break + return result + except Exception as exc: + logger.debug("保单摘要坐标提取失败: %s", exc) + return {} + + +def _expand_multiline_table_rows(matrix: list[list]) -> list[list]: + """展开 PyMuPDF 把整列数据压进单个单元格的表格。""" + expanded = [] + for row in matrix: + parts = [str(cell or "").splitlines() for cell in row] + row_count = max((len(items) for items in parts), default=0) + aligned_columns = sum(1 for items in parts if len(items) == row_count) + if row_count >= 3 and aligned_columns >= 5: + for index in range(row_count): + expanded.append([ + items[index] if len(items) == row_count else "" + for items in parts + ]) + else: + expanded.append(row) + return expanded + + +def _parse_iul_year_age(cells: list) -> tuple[int, int | None, set[int]] | None: + """识别 `1/49`、`1 49` 或分列的年度/年龄。""" + for index, cell in enumerate(cells): + text = str(cell or "").strip() + match = re.fullmatch(r"[#|]?\s*(\d{1,3})\s*[/\s]\s*(\d{1,3})\s*", text) + if match: + year, age = int(match.group(1)), int(match.group(2)) + if 1 <= year <= 100 and 1 <= age <= 130: + return year, age, {index} + + small_values = [] + for index, cell in enumerate(cells[:5]): + match = re.fullmatch(r"[#|]?\s*(\d{1,3})\s*", str(cell or "").strip()) + if match: + small_values.append((index, int(match.group(1)))) + for position in range(len(small_values) - 1): + year_index, year = small_values[position] + age_index, age = small_values[position + 1] + if 1 <= year <= 100 and year <= age <= 130: + return year, age, {year_index, age_index} + return None + + +def _iul_header_columns(header_rows: list[list], column_count: int) -> list[str]: + headers = [] + for column in range(column_count): + headers.append(" ".join( + str(row[column] or "").strip() + for row in header_rows + if column < len(row) and str(row[column] or "").strip() + ).lower()) + return headers + + +def _find_iul_column(headers: list[str], patterns: tuple[str, ...], *, rightmost=False) -> int | None: + matches = [] + for index, header in enumerate(headers): + compact = re.sub(r"\s+", "", header) + if any(pattern in compact for pattern in patterns): + matches.append(index) + if not matches: + return None + return matches[-1] if rightmost else matches[0] + + +def _find_iul_account_column(headers: list[str]) -> int | None: + candidates = [] + patterns = ("accountvalue", "accumulationvalue", "账户价值", "賬戶價值", "户口价值", "戶口價值") + for index, header in enumerate(headers): + compact = re.sub(r"\s+", "", header) + if not any(pattern in compact for pattern in patterns): + continue + polluted = any(value in compact for value in ( + "退保", "保证", "保證", "guaranteed", "lesssurrender" + )) + candidates.append((0 if polluted else 10, index)) + return max(candidates)[1] if candidates else None + + +def _parse_iul_table_matrix(matrix: list[list], source_page: int = 1) -> list[dict]: + """把保险公司 IUL 说明表转换为统一利益行。""" + matrix = _expand_multiline_table_rows(matrix or []) + parsed_rows = [] + first_data_index = None + for row_index, cells in enumerate(matrix): + year_age = _parse_iul_year_age(cells) + if year_age: + first_data_index = row_index + break + if first_data_index is None: + return [] + + column_count = max((len(row) for row in matrix), default=0) + headers = _iul_header_columns(matrix[:first_data_index], column_count) + death_col = _find_iul_column( + headers, ("deathbenefit", "身故利益", "身故權益", "身故赔偿", "身故賠償", "身故保险金", "身故保險金"), + rightmost=True, + ) + surrender_col = _find_iul_column( + headers, ("surrendervalue", "退保价值", "退保價值", "现金价值", "現金價值"), + rightmost=True, + ) + account_col = _find_iul_account_column(headers) + total_premium_col = _find_iul_column( + headers, + ("totalpremium", "总年度保费(累计)", "總年度保費(累計)", "缴付保费总额", "繳付保費總額", "累计保费", "累計保費"), + ) + annual_premium_col = _find_iul_column( + headers, ("premiumschedule", "basicpremium", "保费计划", "保費計劃", "保费进度", "保費進度"), + ) + if annual_premium_col is None: + annual_premium_col = next(( + index for index, header in enumerate(headers) + if ("保单年度" in header or "保單年度" in header) and ("保费" in header or "保費" in header) + ), None) + sum_insured_col = _find_iul_column( + headers, ("suminsured", "保单面值", "保單面值", "保障金额", "保障金額", "投保金额", "投保金額"), + rightmost=True, + ) + + # 利益表必须同时有退保价值和身故利益,避免误把收费表识别成利益表。 + if death_col is None or surrender_col is None: + return [] + + for cells in matrix[first_data_index:]: + year_age = _parse_iul_year_age(cells) + if not year_age: + continue + year, age, identity_columns = year_age + + def value_at(column: int | None) -> float | None: + if column is None or column >= len(cells) or column in identity_columns: + return None + return _parse_money(str(cells[column] or "")) + + surrender = value_at(surrender_col) + death = value_at(death_col) + if surrender is None and death is None: + continue + parsed_rows.append({ + "policy_year": year, + "age": age, + "total_premium_paid": value_at(total_premium_col), + "_annual_premium_paid": value_at(annual_premium_col), + "non_guaranteed_account_value": value_at(account_col), + "non_guaranteed_cash_value": surrender, + "total_surrender_value": surrender, + "non_guaranteed_death_benefit": death, + "death_benefit": death, + "_sum_insured": value_at(sum_insured_col), + "source_page": source_page, + }) + return parsed_rows + + +def _has_iul_text(text: str) -> bool: + compact = re.sub(r"\s+", "", (text or "").lower()) + return any(value in compact for value in ( + "iul", "universallife", "indexaccount", "indexedaccount", + "指数账户", "指數賬戶", "指數戶口", "指数型万用", "指數型萬用", "萬用壽險", + )) + + +def _extract_iul_slash_rows(text: str, issue_age: int | None = None) -> list[dict]: + """解析 OCR 后形如 `1/49 80,060 ...` 的 IUL 行。""" + if not _has_iul_text(text): + return [] + selected = {} + scenario_score = 0 + source_page = 1 + cumulative_premium = 0.0 + for line in text.splitlines(): + stripped = line.strip().strip("|") + page_match = re.search(r"(?:\[PAGE|--- Page)\s+(\d+)", stripped, re.IGNORECASE) + if page_match: + source_page = int(page_match.group(1)) + scenario_score = 0 + compact = re.sub(r"\s+", "", stripped).lower() + if any(value in compact for value in ("当前假设", "當前假設", "现时假设", "現時假設", "currentassumed")): + scenario_score = 20 + elif any(value in compact for value in ("保证派息率", "保證派息率", "guaranteedbasis")): + scenario_score = min(scenario_score, -10) + + match = re.match(r"[#|]?\s*(\d{1,3})\s*/\s*(\d{1,3})\s+(.+)$", stripped) + if not match: + continue + year, age = int(match.group(1)), int(match.group(2)) + values = [_parse_money(value) for value in re.findall(r"[\d,]+(?:\.\d+)?", match.group(3))] + values = [value for value in values if value is not None] + if len(values) < 7 or values[-1] < 10_000 or values[-2] < 10_000: + continue + if issue_age and 1 <= age - int(issue_age) <= 100: + year = age - int(issue_age) + if not 1 <= year <= 100: + continue + annual_premium, account_value = values[0], values[1] + surrender_value, sum_insured, death_benefit = values[-3:] + candidate = { + "policy_year": year, + "age": age, + "_annual_premium_paid": annual_premium, + "non_guaranteed_account_value": account_value, + "non_guaranteed_cash_value": surrender_value, + "total_surrender_value": surrender_value, + "non_guaranteed_death_benefit": death_benefit, + "death_benefit": death_benefit, + "_sum_insured": sum_insured, + "source_page": source_page, + } + existing = selected.get(year) + if existing is None or scenario_score >= existing[0]: + selected[year] = (scenario_score, candidate) + + result = [] + for year in sorted(selected): + row = selected[year][1] + cumulative_premium += row.get("_annual_premium_paid") or 0 + row["total_premium_paid"] = cumulative_premium + result.append(row) + return result + + +def _extract_iul_spaced_rows(text: str, issue_age: int | None = None) -> list[dict]: + """解析 OCR 后年度、年龄分列但仍位于同一文本行的 IUL 表格。""" + if not _has_iul_text(text): + return [] + raw_candidates = [] + scenario_score = 0 + source_page = 1 + for line in text.splitlines(): + stripped = line.strip().strip("|") + page_match = re.search(r"(?:\[PAGE|--- Page)\s+(\d+)", stripped, re.IGNORECASE) + if page_match: + source_page = int(page_match.group(1)) + scenario_score = 0 + compact = re.sub(r"\s+", "", stripped).lower() + if any(value in compact for value in ( + "当前假设", "當前假設", "现时假设", "現時假設", + "currentassumed", "currentcharges", + )): + scenario_score = 20 + elif any(value in compact for value in ( + "保证派息率", "保證派息率", "guaranteedcrediting", "guaranteedbasis", + )): + scenario_score = min(scenario_score, -10) + + match = re.match(r"[#|]?\s*(\d{1,3})\s+(\d{1,3})\s+(.+)$", stripped) + if not match: + continue + raw_year, age = int(match.group(1)), int(match.group(2)) + values = [_parse_money(value) for value in re.findall(r"[\d,]+(?:\.\d+)?", match.group(3))] + values = [value for value in values if value is not None] + if len(values) < 7 or values[-1] < 10_000 or values[-2] < 10_000: + continue + raw_candidates.append((scenario_score, source_page, raw_year, age, values)) + + offsets = [age - year for _, _, year, age, _ in raw_candidates if 1 <= year <= 100] + if issue_age: + inferred_issue_age = int(issue_age) + elif offsets: + counts = {value: offsets.count(value) for value in set(offsets)} + inferred_issue_age = max(counts, key=counts.get) + else: + inferred_issue_age = None + + selected = {} + for score, page, raw_year, age, values in raw_candidates: + year = age - inferred_issue_age if inferred_issue_age is not None else raw_year + if not 1 <= year <= 100: + continue + annual_premium, account_value = values[0], values[1] + surrender_value, sum_insured, death_benefit = values[-3:] + row = { + "policy_year": year, + "age": age, + "_annual_premium_paid": annual_premium, + "non_guaranteed_account_value": account_value, + "non_guaranteed_cash_value": surrender_value, + "total_surrender_value": surrender_value, + "non_guaranteed_death_benefit": death_benefit, + "death_benefit": death_benefit, + "_sum_insured": sum_insured, + "source_page": page, + } + existing = selected.get(year) + if existing is None or score >= existing[0]: + selected[year] = (score, row) + + result = [] + cumulative_premium = 0.0 + for year in sorted(selected): + row = selected[year][1] + cumulative_premium += row.get("_annual_premium_paid") or 0 + row["total_premium_paid"] = cumulative_premium + result.append(row) + return result + + +def _extract_savings_scenario_rows(text: str, issue_age: int | None = None) -> list[dict]: + """合并分开列示的身故利益表与退保价值表。""" + raw_candidates = [] + table_mode = None + source_page = 1 + for line in text.splitlines(): + stripped = line.strip().strip("|") + page_match = re.search(r"(?:\[PAGE|--- Page)\s+(\d+)", stripped, re.IGNORECASE) + if page_match: + source_page = int(page_match.group(1)) + compact = re.sub(r"\s+", "", stripped).lower() + if any(value in compact for value in ("deathbenefit", "死亡利益", "身故利益", "身故权益", "身故權益")): + table_mode = "death" + elif any(value in compact for value in ("surrendervalue", "退保价值", "退保價值")): + table_mode = "surrender" + elif any(value in compact for value in ( + "survivalbenefit", "生存利益", "现金红利", "現金紅利", "cashbonus", + )): + # 后续通常是另一张利益表,不能继续沿用上一页的退保表列含义。 + table_mode = None + if not table_mode: + continue + + match = re.match(r"[#|]?\s*(\d{1,3})\s*/\s*(\d{1,3})\s+(.+)$", stripped) + if not match: + # 部分英文计划书将保单年度和年龄渲染为两个独立列,OCR 后没有斜杠。 + match = re.match(r"[#|]?\s*(\d{1,3})\s+(\d{1,3})\s+(.+)$", stripped) + if not match: + continue + raw_year, age = int(match.group(1)), int(match.group(2)) + values = [_parse_money(value) for value in re.findall(r"[\d,]+(?:\.\d+)?", match.group(3))] + values = [value for value in values if value is not None] + if len(values) < 6: + continue + raw_candidates.append((source_page, table_mode, raw_year, age, values)) + + offsets = [age - year for _, _, year, age, _ in raw_candidates if 1 <= year <= 100] + if issue_age and 1 <= int(issue_age) <= 120: + inferred_issue_age = int(issue_age) + elif offsets: + counts = {value: offsets.count(value) for value in set(offsets)} + inferred_issue_age = max(counts, key=counts.get) + else: + inferred_issue_age = None + + by_year = {} + for page, mode, raw_year, age, values in raw_candidates: + year = age - inferred_issue_age if inferred_issue_age is not None else raw_year + if not 1 <= year <= 100: + continue + row = by_year.setdefault(year, { + "policy_year": year, + "age": age, + "total_premium_paid": values[0], + "source_page": page, + }) + row["total_premium_paid"] = values[0] + if mode == "death": + row["death_benefit"] = values[3] + else: + row["guaranteed_cash_value"] = values[1] + row["terminal_dividend"] = values[2] + row["total_surrender_value"] = values[3] + row["source_page"] = page + return [by_year[year] for year in sorted(by_year)] + + +def extract_iul_layout(pdf_path: str, max_pages: int = 12) -> dict: + """按 PDF 表格坐标提取 IUL 摘要与非保证利益表。""" + try: + try: + import fitz + except ImportError: + import pymupdf as fitz + + candidates = [] + with fitz.open(pdf_path) as document: + for page_index in range(min(len(document), max_pages)): + page = document[page_index] + page_text = page.get_text().lower() + compact_page_text = re.sub(r"\s+", "", page_text) + scenario_score = 0 + if any(value in compact_page_text for value in ( + "当前假设", "當前假設", "现时假设", "現時假設", + "non-guaranteed", "nonguaranteed", "currentassumed", "currentcharges", + )): + scenario_score = 20 + elif any(value in compact_page_text for value in ( + "保证基础", "保證基礎", "guaranteedbasis", "maximumcharges", + )): + scenario_score = -10 + + tables = list(page.find_tables().tables) + if not tables: + tables = list(page.find_tables(strategy="text").tables) + parsed_on_page = [] + for table in tables: + parsed_on_page.extend(_parse_iul_table_matrix( + table.extract(), source_page=page_index + 1 + )) + if not parsed_on_page and tables: + for table in page.find_tables(strategy="text").tables: + parsed_on_page.extend(_parse_iul_table_matrix( + table.extract(), source_page=page_index + 1 + )) + for row in parsed_on_page: + candidates.append((scenario_score, row)) + + selected = {} + for scenario_score, row in candidates: + year = row.get("policy_year") + completeness = sum(value is not None for value in row.values()) + existing = selected.get(year) + rank = (scenario_score, completeness) + if existing is None or rank > existing[0]: + selected[year] = (rank, row) + rows = [selected[year][1] for year in sorted(selected) if year] + if len(rows) < 3: + return {} + + previous_total = 0.0 + cumulative_premium = 0.0 + annual_contributions = [] + issue_ages = [] + sum_insured_values = [] + pay_years = [] + for row in rows: + annual = row.get("_annual_premium_paid") + total = row.get("total_premium_paid") + if annual is None and total is not None: + annual = max(0.0, total - previous_total) + if total is not None: + previous_total = total + cumulative_premium = total + else: + cumulative_premium += annual or 0 + row["total_premium_paid"] = cumulative_premium + if annual and annual > 0: + annual_contributions.append(annual) + pay_years.append(row["policy_year"]) + if row.get("age") is not None: + issue_ages.append(int(row["age"]) - int(row["policy_year"])) + if row.get("_sum_insured"): + sum_insured_values.append(row["_sum_insured"]) + row.pop("_annual_premium_paid", None) + row.pop("_sum_insured", None) + + def most_common(values: list[float]) -> float | None: + if not values: + return None + counts = {} + for value in values: + counts[value] = counts.get(value, 0) + 1 + return max(counts, key=counts.get) + + annual_premium = most_common(annual_contributions) + policy = {} + if annual_premium: + policy.update({ + "annual_premium": annual_premium, + "annual_premium_source_label": "IUL利益表保费列", + "initial_premium": annual_contributions[0], + "first_year_amount_due": annual_contributions[0], + "first_year_amount_due_source_label": "IUL利益表首年保费", + }) + if pay_years: + policy["premium_payment_period"] = max(pay_years) + policy["premium_payment_period_source_label"] = "IUL利益表保费列" + sum_insured = most_common(sum_insured_values) + if sum_insured: + policy["sum_insured"] = sum_insured + policy["sum_insured_source_label"] = "IUL利益表保额列" + insured = {} + issue_age = most_common(issue_ages) + if issue_age and 0 < issue_age <= 120: + insured["age"] = int(issue_age) + return { + "insured": insured, + "policy": policy, + "benefit_illustration": rows, + } + except Exception as exc: + logger.debug("IUL 表格坐标提取失败: %s", exc) + return {} + + # ─── 主提取函数 ────────────────────────────────────────── def extract_insurance_regex(pdf_text: str) -> dict: @@ -791,7 +1342,12 @@ def extract_insurance_regex(pdf_text: str) -> dict: """ product_name = _extract_product_name(pdf_text) or 'unknown' currency = _extract_currency(pdf_text) - annual_premium = _extract_annual_premium(pdf_text) + annual_premium, annual_premium_label = _extract_labeled_money( + pdf_text, + ["投保时年缴保费", "投保時年繳保費", "年缴保费", "年繳保費", "Annual Premium"], + ) + if annual_premium is None: + annual_premium = _extract_annual_premium(pdf_text) insured_info = _extract_insured_info(pdf_text) smoker = _extract_smoker(pdf_text) premium_period = _extract_premium_payment_period(pdf_text) @@ -799,6 +1355,14 @@ def extract_insurance_regex(pdf_text: str) -> dict: # 提取利益演示表 benefit_rows = _extract_benefit_rows(pdf_text) + iul_slash_rows = _extract_iul_slash_rows(pdf_text, insured_info.get('age')) + iul_spaced_rows = _extract_iul_spaced_rows(pdf_text, insured_info.get('age')) + iul_line_rows = max((iul_slash_rows, iul_spaced_rows), key=len) + if len(iul_line_rows) >= 3: + benefit_rows = iul_line_rows + savings_scenario_rows = _extract_savings_scenario_rows(pdf_text, insured_info.get('age')) + if not iul_line_rows and len(savings_scenario_rows) >= 3: + benefit_rows = savings_scenario_rows benefit_rows = _derive_policy_years(benefit_rows, insured_info.get('age')) # 提取提领表 @@ -806,9 +1370,6 @@ def extract_insurance_regex(pdf_text: str) -> dict: # 产品类型由上层 infer_plan_type() 推断(基于实际提取数据),此处不重复判断 - # 填充缺失保费 - benefit_rows = _fill_missing_premiums(benefit_rows, annual_premium) - # 确保字段完整 benefit_rows = [_ensure_required_fields(r) for r in benefit_rows] withdrawal_rows = [_ensure_withdrawal_fields(r) for r in withdrawal_rows] @@ -834,13 +1395,18 @@ def extract_insurance_regex(pdf_text: str) -> dict: withdrawal_rows = deduped_wd # 提取保额(如果有) - sum_insured = None - si_match = re.search( - r'(?:保额|投保额|保額|sum\s*insured)\s*[::]\s*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)', - pdf_text, re.IGNORECASE, + sum_insured, sum_insured_label = _extract_labeled_money( + pdf_text, + ["投保时保额", "投保時保額", "Sum Insured", "Sum Assured", "Face Amount"], ) - if si_match: - sum_insured = _parse_money(si_match.group(1)) + if sum_insured is None: + si_match = re.search( + r'(?:保额|投保额|保額|sum\s*(?:insured|assured))\s*[::]\s*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)', + pdf_text, re.IGNORECASE, + ) + if si_match: + sum_insured = _parse_money(si_match.group(1)) + sum_insured_label = si_match.group(0).split(":", 1)[0].split(":", 1)[0] basic_plan_annual_premium, basic_premium_label = _extract_labeled_money( pdf_text, @@ -848,12 +1414,76 @@ def extract_insurance_regex(pdf_text: str) -> dict: ) basic_sum_insured, basic_sum_label = _extract_labeled_money( pdf_text, - ["基本计划名义金额", "基本計劃名義金額", "Basic Sum Assured", "Basic Notional Amount"], + [ + "投保时基本金额", "投保時基本金額", + "基本计划名义金额", "基本計劃名義金額", + "Basic Sum Assured", "Basic Notional Amount", + ], ) first_year_amount_due, first_year_label = _extract_labeled_money( pdf_text, - ["首年应缴金额", "首年應繳金額", "First Year Amount Due"], + [ + "投保时年缴总保费", "投保時年繳總保費", + "首年应缴金额", "首年應繳金額", "First Year Amount Due", + ], ) + if sum_insured is None and basic_sum_insured is not None: + sum_insured = basic_sum_insured + + if iul_line_rows: + annual_values = [ + row.get("_annual_premium_paid") + for row in iul_line_rows + if (row.get("_annual_premium_paid") or 0) > 0 + ] + if annual_values: + annual_counts = {value: annual_values.count(value) for value in set(annual_values)} + annual_premium = max(annual_counts, key=annual_counts.get) + annual_premium_label = "IUL利益表保费列" + first_year_amount_due = annual_values[0] + first_year_label = "IUL利益表首年保费" + premium_period = max( + row["policy_year"] + for row in iul_line_rows + if (row.get("_annual_premium_paid") or 0) > 0 + ) + sum_values = [ + row.get("_sum_insured") + for row in iul_line_rows + if (row.get("_sum_insured") or 0) > 0 + ] + if sum_values: + sum_counts = {value: sum_values.count(value) for value in set(sum_values)} + sum_insured = max(sum_counts, key=sum_counts.get) + sum_insured_label = "IUL利益表保额列" + for row in iul_line_rows: + row.pop("_annual_premium_paid", None) + row.pop("_sum_insured", None) + elif savings_scenario_rows: + premiums_by_year = [ + (row.get("policy_year"), row.get("total_premium_paid")) + for row in savings_scenario_rows + if (row.get("total_premium_paid") or 0) > 0 + ] + increasing_years = [] + previous_total = 0.0 + for year, total in premiums_by_year: + if total > previous_total: + increasing_years.append(year) + previous_total = total + if increasing_years: + premium_period = max(increasing_years) + total_at_period = next( + total for year, total in premiums_by_year if year == premium_period + ) + annual_premium = total_at_period / premium_period + annual_premium_label = "利益表累计保费列" + first_year_amount_due = premiums_by_year[0][1] + first_year_label = "利益表首年累计保费" + + for row in benefit_rows: + row.pop("_annual_premium_paid", None) + row.pop("_sum_insured", None) logger.info( f"[RegexExtractor] 提取完成: product={product_name}, " @@ -864,7 +1494,8 @@ def extract_insurance_regex(pdf_text: str) -> dict: data = { 'product_name': product_name, - 'product_type': None, # 由上层 infer_plan_type() 基于实际数据推断 + # 保持底层提取器中立;险种由 infer_plan_type 或文件名先验在上层确定。 + 'product_type': None, 'insured': { 'name': None, 'age': insured_info.get('age'), @@ -876,9 +1507,11 @@ def extract_insurance_regex(pdf_text: str) -> dict: 'product_name': product_name, 'currency': currency, 'sum_insured': sum_insured, + 'sum_insured_source_label': sum_insured_label, 'basic_sum_insured': basic_sum_insured, 'basic_sum_insured_source_label': basic_sum_label, 'annual_premium': annual_premium, + 'annual_premium_source_label': annual_premium_label, 'basic_plan_annual_premium': basic_plan_annual_premium, 'basic_plan_annual_premium_source_label': basic_premium_label, 'first_year_amount_due': first_year_amount_due, diff --git a/api/insurance/ppt/renderer.py b/api/insurance/ppt/renderer.py index b47965b..26cbe19 100644 --- a/api/insurance/ppt/renderer.py +++ b/api/insurance/ppt/renderer.py @@ -74,6 +74,7 @@ def _build_deck_contract( "productName": data.get("productName", ""), "rawProductName": data.get("rawProductName", ""), "productConfig": data.get("productConfig", {}), + "company": data.get("companyInfo", {}), "insured": data.get("insured", {}), "policy": data.get("policy", {}), "benefitRows": data.get("benefitRows", []), @@ -102,6 +103,33 @@ def _build_deck_contract( if company_info: company.update(company_info) + companies = [] + seen_company_ids = set() + for product in products: + product_company = product.get("company") or {} + company_key = product_company.get("id") or product.get("companyId") + if not company_key or company_key in seen_company_ids: + continue + seen_company_ids.add(company_key) + companies.append(product_company or { + "id": company_key, + "displayName": company_key, + }) + if not company_info and companies: + if len(companies) == 1: + company.update(companies[0]) + else: + company.update({ + "id": "multi", + "displayName": " / ".join( + item.get("displayName", "") for item in companies if item.get("displayName") + ), + "companyIntro": "", + "companyHighlights": [], + "rating": "", + "logoUrl": "", + }) + from insurance.ppt.comparison import ( build_scenario_slides, detect_generation_scenario, @@ -130,6 +158,7 @@ def _build_deck_contract( "comparison": comparison or {}, "scenarioSlides": build_scenario_slides(products, resolved_scenario), "company": company, + "companies": companies, "templateConfig": template_config or {}, "meta": normalized_data.get("source", { "pdfHash": "", diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py index 65c4cae..323220c 100644 --- a/api/insurance/ppt/routes.py +++ b/api/insurance/ppt/routes.py @@ -66,9 +66,10 @@ def render_options(): item["generationMode"] = scenario_config.get("generationMode") item["scenarioName"] = scenario_config.get("name") template_items.append(item) + from insurance.ppt.masking import public_company_option, public_product_option return success({ - "companies": [c.to_dict() for c in companies], - "products": [p.to_dict() for p in products], + "companies": [public_company_option(c.to_dict()) for c in companies], + "products": [public_product_option(p.to_dict()) for p in products], "templates": template_items, "scenarios": [item.to_dict() for item in scenarios], }) @@ -429,22 +430,31 @@ def generate_ppt(session_id): data = request.get_json(silent=True) or {} theme = data.get("theme") or data.get("style") or "broker" - company_id = data.get("companyId", "") + requested_company_id = data.get("companyId", "") template_id = data.get("templateId", "") files = json.loads(session.files_json) if session.files_json else [] product_ids = [item.get("productId") for item in files if item.get("productId")] - if not company_id: - company_id = next( - (item.get("companyId") for item in files if item.get("companyId")), "" - ) - company = None - if company_id: - from insurance.models.ppt_config import PptCompany - company = PptCompany.query.filter_by( - id=company_id, status=1, deleted_at=None - ).first() - if not company: - return error(ErrorCode.PARAM_ERROR, "所选保司不存在或已停用") + company_ids = list(dict.fromkeys( + item.get("companyId") for item in files if item.get("companyId") + )) + if not company_ids and requested_company_id: + company_ids = [requested_company_id] + + from insurance.models.ppt_config import PptCompany + configured_companies = ( + PptCompany.query.filter( + PptCompany.id.in_(company_ids), + PptCompany.status == 1, + PptCompany.deleted_at.is_(None), + ).all() + if company_ids else [] + ) + companies_by_id = {item.id: item for item in configured_companies} + missing_company_ids = [item for item in company_ids if item not in companies_by_id] + if missing_company_ids: + return error(ErrorCode.PARAM_ERROR, "所选保司不存在或已停用") + configured_companies = [companies_by_id[item] for item in company_ids] + company_id = company_ids[0] if len(company_ids) == 1 else "" from insurance.models.ppt_config import PptProduct configured_products = ( @@ -457,7 +467,7 @@ def generate_ppt(session_id): ) from insurance.ppt.masking import build_brand_policy brand_policy = build_brand_policy( - company.to_dict() if company else None, + [company.to_dict() for company in configured_companies], [product.to_dict() for product in configured_products], ) @@ -515,7 +525,9 @@ def generate_ppt(session_id): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前险种") applicable_companies = template_data.get("applicableCompanyIds") or [] applicable_products = template_data.get("applicableProductIds") or [] - if applicable_companies and company_id not in applicable_companies: + if applicable_companies and any( + item not in applicable_companies for item in company_ids + ): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前保司") if applicable_products and not set(product_ids).intersection(applicable_products): return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不适用于当前产品") @@ -546,7 +558,7 @@ def generate_ppt(session_id): "theme": theme, "templateId": template_id, "templateName": (template_data or {}).get("name") or template_id, "stylePreset": (template_data or {}).get("stylePreset") or theme, - "companyId": company_id, "productIds": product_ids, + "companyId": company_id, "companyIds": company_ids, "productIds": product_ids, "brandPolicy": brand_policy, "scenario": scenario, }, ensure_ascii=False) @@ -565,6 +577,7 @@ def generate_ppt(session_id): "theme": theme, "templateId": template_id, "companyId": company_id, + "companyIds": company_ids, "productIds": product_ids, "brandPolicy": brand_policy, "scenario": scenario, diff --git a/api/insurance/ppt/scripts/fast_pptx_renderer.py b/api/insurance/ppt/scripts/fast_pptx_renderer.py index 1b54135..1f7c2b0 100644 --- a/api/insurance/ppt/scripts/fast_pptx_renderer.py +++ b/api/insurance/ppt/scripts/fast_pptx_renderer.py @@ -401,6 +401,7 @@ def _product_summary(product): "paybackYear": payback, "finalValue": final, "multiple": mult, "benefitRows": br, "withdrawalRows": product.get("withdrawalRows", []), "productName": product.get("productName", ""), + "companyName": (product.get("company") or {}).get("displayName", ""), "insured": product.get("insured", {})} @@ -833,7 +834,10 @@ def add_slide_policy_summary(prs, deck, colors, meta): summary = _product_summary(product) mode = meta.get("summaryMode", "no_withdraw") title = meta.get("title") or f"{summary['productName']} 保单摘要" - add_title(slide, title, summary["productName"], colors=colors) + subtitle = summary["productName"] + if summary.get("companyName"): + subtitle = f"{summary['companyName']}|{summary['productName']}" + add_title(slide, title, subtitle, colors=colors) policy = product.get("policy", {}) currency = policy.get("currency") or "USD" diff --git a/api/insurance/ppt/validator.py b/api/insurance/ppt/validator.py index 426fefb..f5830a0 100644 --- a/api/insurance/ppt/validator.py +++ b/api/insurance/ppt/validator.py @@ -189,6 +189,28 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]: if benefit_rows and not is_continuous: warn("IUL_BENEFIT_ROWS_DISCONTINUOUS", "利益演示保单年度不连续,请确认 PDF 是否只提供里程碑年度") + sum_insured = _safe_number(policy.get("sumInsured")) + collision_rows = 0 + comparable_rows = 0 + for row in benefit_rows: + surrender = _safe_number(row.get("totalSurrenderValue")) + death = _safe_number( + row.get("nonGuaranteedDeathBenefit") or row.get("deathBenefit") + ) + if surrender > 0 and death > 0: + comparable_rows += 1 + if ( + sum_insured > 0 + and abs(surrender - sum_insured) < 0.01 + and abs(death - sum_insured) < 0.01 + ): + collision_rows += 1 + if comparable_rows >= 3 and collision_rows / comparable_rows >= 0.5: + err( + "IUL_SURRENDER_EQUALS_DEATH_BENEFIT", + "多数年度的退保价值与保额/身故利益完全相同,疑似表格列映射错误", + ) + source = plan.get("source", {}) if not source.get("pdfHash"): err("IUL_SOURCE_HASH_MISSING", "缺少 PDF 哈希") diff --git a/frontend/src/pages/components/ppt/PptDataReview.vue b/frontend/src/pages/components/ppt/PptDataReview.vue index 2ce6d3b..51320b4 100644 --- a/frontend/src/pages/components/ppt/PptDataReview.vue +++ b/frontend/src/pages/components/ppt/PptDataReview.vue @@ -656,7 +656,7 @@ onMounted(async () => { if (ext.data) { ext.data.insured = ext.data.insured || {} ext.data.policy = ext.data.policy || {} - ext.data.insured.smoker = ext.data.insured.smoker || 'unknown' + ext.data.insured.smoker = normalizeSmoker(ext.data.insured.smoker) ext.data.benefit_illustration = ext.data.benefit_illustration || [] ext.data.withdrawal_illustration = ext.data.withdrawal_illustration || [] normalizeEditableRows(ext) @@ -928,7 +928,7 @@ function trackModifications() { // 对比标量字段 const scalarFields = ['product_name', 'product_type'] - const insuredFields = ['age', 'gender', 'name'] + const insuredFields = ['age', 'gender', 'name', 'smoker'] const policyFields = ['currency', 'sum_insured', 'annual_premium', 'premium_payment_period', 'coverage_period'] for (const f of scalarFields) { @@ -971,6 +971,28 @@ function _logModification(ext: any, path: string, oldValue: any, newValue: any) } } +function normalizeSmoker(raw: unknown): 'yes' | 'no' | 'unknown' { + if (raw === null || raw === undefined || raw === '') return 'unknown' + const value = String(raw).trim().toLowerCase() + if ( + ['no', 'n', 'false', '0', '否', '不吸烟', '不吸煙', '非吸烟者', '非吸煙者'].includes(value) + || value.includes('non-smoker') + || value.includes('non smoker') + || value.includes('nonsmoker') + || value.includes('不吸烟') + || value.includes('不吸煙') + || value.includes('非吸烟') + || value.includes('非吸煙') + ) return 'no' + if ( + ['yes', 'y', 'true', '1', '是', '吸烟', '吸煙'].includes(value) + || value.includes('smoker') + || value.includes('吸烟') + || value.includes('吸煙') + ) return 'yes' + return 'unknown' +} + function normalizeEditableRows(ext: any) { for (const row of getWithdrawalRows(ext)) { row.annual_withdrawal = row.annual_withdrawal ?? row.withdrawal_amount ?? 0 diff --git a/frontend/src/pages/components/ppt/PptGenerate.vue b/frontend/src/pages/components/ppt/PptGenerate.vue index 49e9141..f5d04c3 100644 --- a/frontend/src/pages/components/ppt/PptGenerate.vue +++ b/frontend/src/pages/components/ppt/PptGenerate.vue @@ -60,9 +60,7 @@
保险公司 - - - + {{ companySummary || '未选择' }}
@@ -121,19 +119,17 @@ const emit = defineEmits<{ const templates = ref([]) const sessionFiles = ref([]) const selectedTemplateId = ref('') -const companyId = ref('') const companies = ref([]) const loadingOptions = ref(true) const generating = ref(false) // 监听配置变更,触发自动保存 -watch([selectedTemplateId, companyId], () => { +watch(selectedTemplateId, () => { if (autoSave && selectedTemplateId.value) { autoSave.scheduleSave({ workflow_step: 'ready', draft_options: { templateId: selectedTemplateId.value, - companyId: companyId.value, }, }) } @@ -149,6 +145,13 @@ let pollTimer: ReturnType | null = null const selectedProductIds = computed(() => sessionFiles.value.map(file => file.productId).filter(Boolean) ) +const selectedCompanyIds = computed(() => + [...new Set(sessionFiles.value.map(file => file.companyId).filter(Boolean))] +) +const companySummary = computed(() => selectedCompanyIds.value + .map(id => companies.value.find(company => company.id === id)?.displayName || id) + .join(' / ') +) const selectedPlanTypes = computed(() => [...new Set(sessionFiles.value.map(file => file.type).filter(Boolean))] ) @@ -192,7 +195,7 @@ const availableTemplates = computed(() => templates.value.filter(template => { } } const companyIds = template.applicableCompanyIds || [] - if (companyIds.length && (!companyId.value || !companyIds.includes(companyId.value))) { + if (companyIds.length && selectedCompanyIds.value.some(id => !companyIds.includes(id))) { return false } const productIds = template.applicableProductIds || [] @@ -244,7 +247,6 @@ onMounted(async () => { companies.value = optionsRes?.data?.companies || [] sessionFiles.value = sessionRes?.data?.files || [] const savedOptions = sessionRes?.data?.draft_options || {} - companyId.value = savedOptions.companyId || '' // 优先恢复用户上次明确保存的模板,不在返回步骤时重置为第一个 if (savedOptions.templateId && availableTemplates.value.some(t => t.id === savedOptions.templateId)) { @@ -293,7 +295,6 @@ async function handleGenerate() { try { const res: any = await pptApi.generate(props.sessionId, { templateId: selectedTemplateId.value, - companyId: companyId.value, }) const data = res?.data if (data?.taskId) { diff --git a/tests/ppt_poster_optimization_test.py b/tests/ppt_poster_optimization_test.py index ab73ee6..86660d0 100644 --- a/tests/ppt_poster_optimization_test.py +++ b/tests/ppt_poster_optimization_test.py @@ -40,6 +40,277 @@ def test_optional_review_fields_are_normalized_without_becoming_required(): assert plan["policy"]["firstYearAmountDue"] == 9800 +def test_smoker_aliases_are_canonical_before_review(): + from insurance.ppt.normalizer import normalize_smoker + + assert normalize_smoker("Non-Smoker Standard") == "no" + assert normalize_smoker(False) == "no" + assert normalize_smoker("非吸烟者") == "no" + assert normalize_smoker("Smoker Standard") == "yes" + assert normalize_smoker(None) == "unknown" + + +def test_labeled_savings_amounts_are_not_confused_with_first_year_premium(): + from insurance.ppt.regex_extractor import extract_insurance_regex + + data = extract_insurance_regex(""" + 投保时 基本金额 50,506 + 投保时 保额 5,250 + 投保时 年缴保费 5,000.09 + 投保时 年缴总保费 5,005.09 + 保费供款年期 5 + """) + + assert data["policy"]["sum_insured"] == 5250 + assert data["policy"]["basic_sum_insured"] == 50506 + assert data["policy"]["annual_premium"] == 5000.09 + assert data["policy"]["annual_premium_source_label"] + assert data["policy"]["first_year_amount_due"] == 5005.09 + + +def test_summary_word_coordinates_keep_amounts_in_their_columns(): + from insurance.ppt.regex_extractor import _extract_summary_fields_from_words + + words = [ + (192, 362, 241, 376, "基本金额(1)"), + (262, 356, 321, 370, "投保时保额(2)"), + (360, 362, 401, 376, "年缴保费"), + (201, 383, 232, 397, "50,506"), + (279, 383, 304, 397, "5,250"), + (386, 383, 425, 397, "5,000.09"), + ] + + policy = _extract_summary_fields_from_words(words) + + assert policy["basic_sum_insured"] == 50506 + assert policy["sum_insured"] == 5250 + assert policy["annual_premium"] == 5000.09 + + +def test_iul_slash_rows_use_current_scenario_and_keep_year_age_alignment(): + from insurance.ppt.regex_extractor import _extract_iul_slash_rows + + rows = _extract_iul_slash_rows(""" + [PAGE 2] + 指数账户 + 以保证派息率计算 + 1/49 80,060 56,101 0 0 0 3,000,000 3,000,000 + 2/50 80,060 111,582 37,601 67,284 67,284 3,000,000 3,000,000 + [PAGE 3] + 以当前假设派息率计算 + 1/49 80,060 61,120 0 0 0 3,000,000 3,000,000 + 2/50 80,060 126,558 52,576 67,432 67,432 3,000,000 3,000,000 + """, issue_age=48) + + assert [row["policy_year"] for row in rows] == [1, 2] + assert [row["age"] for row in rows] == [49, 50] + assert rows[1]["total_premium_paid"] == 160120 + assert rows[1]["total_surrender_value"] == 67432 + assert rows[1]["non_guaranteed_death_benefit"] == 3000000 + + +def test_iul_spaced_rows_recover_ocr_dropped_year_digits(): + from insurance.ppt.regex_extractor import _extract_iul_spaced_rows + + rows = _extract_iul_spaced_rows(""" + [PAGE 4] + Indexed Account + Illustrated at the current assumed crediting interest rate and current charges. + 10 61 0 367,545 337,925 198,191 337,925 2,000,000 2,000,000 + 1 62 0 382,057 357,097 194,602 357,097 2,000,000 2,000,000 + 12 63 0 397,325 377,005 190,533 377,005 2,000,000 2,000,000 + """, issue_age=51) + + assert [row["policy_year"] for row in rows] == [10, 11, 12] + assert rows[1]["age"] == 62 + assert rows[1]["total_surrender_value"] == 357097 + + +def test_savings_scenario_rows_join_death_and_surrender_pages(): + from insurance.ppt.regex_extractor import _extract_savings_scenario_rows + + rows = _extract_savings_scenario_rows(""" + [PAGE 2] + DEATH BENEFIT + 1/26 60,480 65,319 0 65,319 0 65,319 + 2/27 120,961 130,638 453 131,091 907 131,545 + [PAGE 4] + SURRENDER VALUE + 1/26 60,480 0 0 0 0 0 + 2/27 120,961 6,048 453 6,501 907 6,955 + """) + + assert [row["policy_year"] for row in rows] == [1, 2] + assert rows[1]["age"] == 27 + assert rows[1]["total_premium_paid"] == 120961 + assert rows[1]["death_benefit"] == 131091 + assert rows[1]["guaranteed_cash_value"] == 6048 + assert rows[1]["total_surrender_value"] == 6501 + + +def test_savings_scenario_rows_accept_separate_year_and_age_columns(): + from insurance.ppt.regex_extractor import _extract_savings_scenario_rows + + rows = _extract_savings_scenario_rows(""" + [PAGE 2] + DEATH BENEFIT + 1 61 400,000 400,000 0 400,000 0 400,000 + 2 62 400,000 400,000 11,200 411,200 20,000 420,000 + [PAGE 4] + SURRENDER VALUE + 1 61 400,000 0 0 0 0 0 + 2 62 400,000 40,000 11,200 51,200 20,000 60,000 + [PAGE 6] + SURVIVAL BENEFIT AND CASH BONUS + 2 62 6,403 6,403 6,253 12,623 19,026 340,114 423,090 + """) + + assert [row["policy_year"] for row in rows] == [1, 2] + assert rows[1]["age"] == 62 + assert rows[1]["death_benefit"] == 411200 + assert rows[1]["total_surrender_value"] == 51200 + + +def test_savings_rows_are_not_misclassified_as_iul_rows(): + from insurance.ppt.regex_extractor import _extract_iul_spaced_rows + + rows = _extract_iul_spaced_rows(""" + DEATH BENEFIT + 1 61 400,000 400,000 0 400,000 0 400,000 + 2 62 400,000 400,000 11,200 411,200 20,000 420,000 + """, issue_age=60) + + assert rows == [] + + +def test_iul_table_matrix_expands_vertical_columns(): + from insurance.ppt.regex_extractor import _parse_iul_table_matrix + + matrix = [ + [ + "保单年度结束", "年龄", "保费计划", "账户价值", + "账户价值减去退保费用", "累计保证账户价值减去退保费用", + "退保价值", "保障金额", "身故赔偿", + ], + [ + "1\n2\n3", "50\n51\n52", "113,340\n88,900\n88,900", + "92,237\n166,221\n244,170", "36,523\n110,507\n188,456", + "31,779\n97,461\n163,241", "36,523\n110,507\n188,456", + "3,000,000\n3,000,000\n3,000,000", "3,000,000\n3,000,000\n3,000,000", + ], + ] + + rows = _parse_iul_table_matrix(matrix, source_page=4) + + assert len(rows) == 3 + assert rows[0]["policy_year"] == 1 + assert rows[0]["age"] == 50 + assert rows[0]["_annual_premium_paid"] == 113340 + assert rows[0]["non_guaranteed_account_value"] == 92237 + assert rows[2]["total_surrender_value"] == 188456 + assert rows[2]["non_guaranteed_death_benefit"] == 3000000 + + +def test_iul_table_matrix_prefers_rightmost_non_guaranteed_columns(): + from insurance.ppt.regex_extractor import _parse_iul_table_matrix + + matrix = [ + ["年度/年龄", "总年度保费(累计)", "现金价值", "户口价值", "身故利益", + "现金价值", "户口价值", "身故利益"], + ["1 49", "96,637", "498", "66,690", "3,000,000", + "4,586", "72,326", "3,000,000"], + ["2 50", "193,274", "80,603", "138,981", "3,000,000", + "92,783", "155,663", "3,000,000"], + ["3 51", "289,911", "161,279", "210,412", "3,000,000", + "185,993", "244,043", "3,000,000"], + ] + + rows = _parse_iul_table_matrix(matrix, source_page=2) + + assert rows[0]["total_premium_paid"] == 96637 + assert rows[0]["total_surrender_value"] == 4586 + assert rows[0]["non_guaranteed_account_value"] == 72326 + assert rows[0]["non_guaranteed_death_benefit"] == 3000000 + + +def test_iul_layout_rows_do_not_replace_better_ocr_rows(): + from insurance.ppt.extraction import _should_use_iul_layout_rows + + existing = [ + { + "policy_year": year, + "total_surrender_value": year * 1000, + "non_guaranteed_account_value": year * 1200, + "non_guaranteed_death_benefit": 3000000, + } + for year in range(1, 21) + ] + false_positive_layout = [ + {"policy_year": year, "total_surrender_value": year * 10} + for year in range(1, 7) + ] + + assert _should_use_iul_layout_rows(existing, false_positive_layout) is False + + +def test_filename_hints_cover_uploaded_iul_and_premium_named_samples(): + from insurance.ppt.extraction import _apply_filename_hints + + iul = _apply_filename_hints( + {"insured": {"age": 1}, "policy": {}}, + "/tmp/SLS_SBIUL2_F-49-N-CN-USD-S3m-10x__coi__SC.pdf", + "iul", + ) + savings = _apply_filename_hints( + {"insured": {}, "policy": {}}, + "/tmp/CL_HISP+M-25-N-CN-USD-P0.3m-5x+(EN).pdf", + "savings", + ) + + assert iul["product_name"] == "Sun Life SBIUL 2" + assert iul["product_type"] == "iul" + assert iul["insured"] == {"age": 49, "gender": "female", "smoker": "no"} + assert iul["policy"]["sum_insured"] == 3000000 + assert iul["policy"]["premium_payment_period"] == 10 + assert savings["product_name"] == "CL HISP+" + assert savings["product_type"] == "savings" + assert savings["insured"] == {"gender": "male", "age": 25, "smoker": "no"} + assert savings["policy"]["annual_premium"] == 60000 + assert savings["policy"]["contractual_total_premium"] == 300000 + assert savings["policy"]["premium_payment_period"] == 5 + + +def test_trusted_labeled_amounts_and_regex_smoker_fill_llm_result(): + from insurance.ppt.extraction import _merge_extraction_data + + llm_data = { + "product_name": "储蓄计划", + "insured": {"age": 41, "gender": "female", "smoker": None}, + "policy": {"sum_insured": 5000.09, "annual_premium": 5250}, + } + regex_data = { + "insured": {"smoker": "no"}, + "policy": { + "basic_sum_insured": 50506, + "basic_sum_insured_source_label": "投保时基本金额", + "sum_insured": 5250, + "sum_insured_source_label": "投保时保额", + "annual_premium": 5000.09, + "annual_premium_source_label": "投保时年缴保费", + "first_year_amount_due": 5005.09, + "first_year_amount_due_source_label": "投保时年缴总保费", + }, + } + + merged = _merge_extraction_data(llm_data, regex_data, "savings") + + assert merged["insured"]["smoker"] == "no" + assert merged["policy"]["sum_insured"] == 5250 + assert merged["policy"]["basic_sum_insured"] == 50506 + assert merged["policy"]["annual_premium"] == 5000.09 + assert merged["policy"]["first_year_amount_due"] == 5005.09 + + def test_iul_key_fields_accept_raw_and_normalized_names_without_data_loss(): from insurance.ppt.normalizer import normalize_iul_plan @@ -100,8 +371,9 @@ def test_ppt_generation_preserves_company_selected_for_uploaded_file(): / "api/insurance/generation/celery_tasks.py" ).read_text(encoding="utf-8") - assert 'normalized["companyId"] = ext.get("companyId") or company_id or ""' in source - assert "if not company_id:" in source + assert 'normalized["companyId"] = ext.get("companyId") or fallback_company_id or ""' in source + assert 'company_ids = list(snapshot.get("companyIds")' in source + assert 'item["companyInfo"] = company_infos.get(item.get("companyId"), {})' in source def test_benefit_and_withdrawal_issues_never_block_savings_generation(): @@ -173,6 +445,82 @@ def test_brand_policy_controls_company_product_and_logo_independently(): assert masked_product["displayName"] == "真实产品" +def test_public_render_options_only_expose_effective_masked_names(): + from insurance.ppt.masking import public_company_option, public_product_option + + company = public_company_option({ + "id": "aia", + "displayName": "友邦保险", + "maskedDisplayName": "友X保险", + "maskingEnabled": True, + "aliases": ["AIA", "友邦"], + }) + product = public_product_option({ + "id": "p1", + "companyId": "aia", + "planType": "savings", + "displayName": "真实产品", + "maskedDisplayName": "产X", + "maskingEnabled": True, + }) + + assert company == {"id": "aia", "displayName": "友X保险"} + assert product == { + "id": "p1", "companyId": "aia", "planType": "savings", + "displayName": "产X", + } + + +def test_brand_policy_keeps_company_rules_per_company(): + from insurance.ppt.masking import build_brand_policy + + policy = build_brand_policy([ + { + "id": "aia", "displayName": "友邦保险", + "maskedDisplayName": "友X保险", "maskingEnabled": True, + "logoEnabled": False, + }, + { + "id": "manulife", "displayName": "宏利", + "maskedDisplayName": "宏X", "maskingEnabled": True, + "logoEnabled": False, + }, + ], []) + + assert policy["policyVersion"] == 2 + assert policy["companyPolicyById"]["aia"]["maskingEnabled"] is True + assert policy["companyPolicyById"]["manulife"]["displayName"] == "宏X" + + +def test_iul_surrender_value_cannot_repeat_death_benefit_for_most_rows(): + from insurance.ppt.validator import validate_formal_iul_plan + + issues = validate_formal_iul_plan({ + "productName": "Manulife SIUL 3", + "insured": {"age": 48, "smoker": "no"}, + "policy": { + "currency": "USD", "sumInsured": 3000000, + "annualPremium": 80060, "paymentPeriod": "5", + }, + "indexAccounts": [{"name": "S&P 500"}], + "benefitRows": [ + { + "policyYear": year, + "age": 48 + year, + "totalSurrenderValue": 3000000, + "nonGuaranteedDeathBenefit": 3000000, + } + for year in range(1, 21) + ], + "source": {"pdfHash": "hash"}, + }) + + assert any( + issue.code == "IUL_SURRENDER_EQUALS_DEATH_BENEFIT" and issue.level == "error" + for issue in issues + ) + + def test_all_six_core_poster_profiles_have_distinct_content_budgets(): from insurance.poster.field_profiles import get_field_profile @@ -312,6 +660,18 @@ def test_regex_benefit_table_keeps_age_separate_from_money(): assert first["total_surrender_value"] == 31600 +def test_regex_extracts_english_sum_assured_label(): + from insurance.ppt.regex_extractor import extract_insurance_regex + + data = extract_insurance_regex(""" + Your plan + 1Sum assured: 5,336 + Currency: USD + """) + + assert data["policy"]["sum_insured"] == 5336 + + def test_regex_age_only_table_derives_policy_year_from_explicit_issue_age(): from insurance.ppt.regex_extractor import extract_insurance_regex