From caee27b0d3f811183e09a352ce6cd23a0b62ea50 Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Fri, 31 Jul 2026 23:35:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E8=A6=81=E4=BF=AE=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=20=E6=B5=B7=E6=8A=A5=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=EF=BC=9Ahtml-to-image=20=E7=BB=99=20blob:=20?= =?UTF-8?q?=E5=BA=95=E5=9B=BE=E5=9C=B0=E5=9D=80=E8=BF=BD=E5=8A=A0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=8F=82=E6=95=B0=EF=BC=8C=E5=AF=BC=E8=87=B4=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=A4=B1=E6=95=88=E3=80=82=E7=8E=B0=E5=B7=B2=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E8=AF=A5=E8=A1=8C=E4=B8=BA=E3=80=82=20=E5=90=88?= =?UTF-8?q?=E6=88=90=E5=A4=B1=E8=B4=A5=E5=90=8E=E5=86=8D=E6=AC=A1=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E2=80=9C=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=E2=80=9D?= =?UTF-8?q?=EF=BC=8C=E4=BC=9A=E5=A4=8D=E7=94=A8=E5=B7=B2=E6=9C=89=20AI=20?= =?UTF-8?q?=E5=BA=95=E5=9B=BE=EF=BC=8C=E5=8F=AA=E9=87=8D=E8=AF=95=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=90=88=E6=88=90=E5=92=8C=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E8=B0=83=E7=94=A8?= =?UTF-8?q?=20AI=E3=80=82=20=E5=A2=9E=E5=8A=A0=E5=BA=95=E5=9B=BE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E3=80=81=E5=9B=BE=E8=A1=A8=E8=B6=85=E6=97=B6=E3=80=81?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=A4=B1=E8=B4=A5=E3=80=81=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E8=B6=8A=E7=95=8C=E7=AD=89=E5=88=86=E9=98=B6=E6=AE=B5=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA=E3=80=82=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=B9=A6=20(cid:=E6=95=B0=E5=AD=97)=20?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E4=B9=B1=E7=A0=81=E8=A2=AB=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=B8=BA=E6=AD=A3=E5=B8=B8=E6=96=87=E6=9C=AC=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E7=8E=B0=E5=9C=A8=E4=BC=9A=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=BD=AC=E5=85=A5=20OCR=E3=80=82=20=E5=A2=9E=E5=8A=A0=E7=B9=81?= =?UTF-8?q?=E4=BD=93=E4=B8=AD=E6=96=87=20OCR=20=E8=BF=90=E8=A1=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E3=80=82=20=E8=A1=A5=E9=BD=90=20SIUL=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E4=B8=AD=E7=9A=84=E7=A1=AE=E5=AE=9A=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=EF=BC=8C=E5=B9=B6=E4=B8=94=E4=B8=8D=E4=BC=9A=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=AD=A3=E6=96=87=E5=B7=B2=E8=AF=86=E5=88=AB=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E3=80=82=20=E5=A2=9E=E5=8A=A0=E2=80=9C=E9=A6=96?= =?UTF-8?q?=E6=9C=9F=E8=A7=84=E5=88=92=E4=BF=9D=E8=B4=B9/=E5=84=9F?= =?UTF-8?q?=E9=82=84=E8=87=B3=E5=BD=A2=E6=88=90=E5=9F=BA=E9=87=91=E6=89=80?= =?UTF-8?q?=E9=9C=80=E4=BF=9D=E8=B2=BB=E2=80=9D=E7=AD=89=E4=BF=9D=E8=B4=B9?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E8=AF=86=E5=88=AB=E3=80=82=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20IUL=20=E5=B9=B4=E9=BE=84=E3=80=81=E4=BF=9D=E9=A2=9D?= =?UTF-8?q?=E3=80=81=E9=80=80=E4=BF=9D=E4=BB=B7=E5=80=BC=E3=80=81=E7=BC=B4?= =?UTF-8?q?=E8=B4=B9=E6=9C=9F=E3=80=81=E5=85=AC=E5=8F=B8=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=AD=89=E5=AD=97=E6=AE=B5=E6=98=A0=E5=B0=84=E3=80=82=20LLM=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=A9=BA=E5=AF=B9=E8=B1=A1=E6=88=96=E7=BC=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=97=B6=E4=B8=8D=E5=86=8D=E8=A7=86=E4=B8=BA?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E3=80=82=20=E5=A2=9E=E5=8A=A0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=88=A9=E7=9B=8A=E6=95=B0=E5=80=BC=E5=92=8C=E5=B9=B4?= =?UTF-8?q?=E9=BE=84/=E4=BF=9D=E5=8D=95=E5=B9=B4=E5=BA=A6=E9=94=99?= =?UTF-8?q?=E4=BD=8D=E6=A0=A1=E9=AA=8C=E3=80=82=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E9=99=8D=E7=BA=A7=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20API/Worker=20=E6=97=A0=E6=B3=95=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E9=A3=8E=E9=99=A9=E3=80=82=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=B9=A6=E5=A4=8D=E9=AA=8C=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=EF=BC=9A=20=E4=BA=A7=E5=93=81=EF=BC=9AManulife=20SIUL=203=20?= =?UTF-8?q?=E6=8A=95=E4=BF=9D=E5=B9=B4=E9=BE=84=EF=BC=9A48=20=E5=B2=81=20?= =?UTF-8?q?=E6=80=A7=E5=88=AB=EF=BC=9A=E5=A5=B3=E6=80=A7=20=E5=90=B8?= =?UTF-8?q?=E7=83=9F=E7=8A=B6=E6=80=81=EF=BC=9A=E9=9D=9E=E5=90=B8=E7=83=9F?= =?UTF-8?q?=20=E5=B8=81=E7=A7=8D=EF=BC=9AUSD=20=E5=9F=BA=E6=9C=AC=E4=BF=9D?= =?UTF-8?q?=E9=A2=9D=EF=BC=9A3,000,000=20=E5=B9=B4=E7=BC=B4=E4=BF=9D?= =?UTF-8?q?=E8=B4=B9=EF=BC=9A80,060=20=E7=BC=B4=E8=B4=B9=E6=9C=9F=EF=BC=9A?= =?UTF-8?q?5=20=E5=B9=B4=20=E5=88=A9=E7=9B=8A=E6=BC=94=E7=A4=BA=EF=BC=9A?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=88=B0=2010=20=E8=A1=8C=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=BB=93=E6=9E=9C=EF=BC=9A=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=9A?= =?UTF-8?q?91=20passed=20=E5=89=8D=E7=AB=AF=E7=94=9F=E4=BA=A7=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=EF=BC=9A=E9=80=9A=E8=BF=87=20API=E3=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E3=80=81Redis=E3=80=81=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E8=A1=A8=E5=81=A5=E5=BA=B7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=9A=E5=85=A8=E9=83=A8=E6=AD=A3=E5=B8=B8=20Celery?= =?UTF-8?q?=20Worker=EF=BC=9A=E5=B7=B2=E9=87=8D=E5=90=AF=E5=B9=B6=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=20Redis=20=E7=9C=9F=E5=AE=9E=20PDF=EF=BC=9A=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E8=BF=9B=E5=85=A5=20OCR=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20(cid:...)=20=E4=B9=B1=E7=A0=81=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=9E=84=E5=BB=BA=E7=9B=AE=E5=BD=95=E7=94=B1=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=AE=B9=E5=99=A8=E6=8C=82=E8=BD=BD=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=B7=B2=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/generation/celery_tasks.py | 7 ++ api/insurance/ppt/extraction.py | 72 +++++++++++++++++++- api/insurance/ppt/llm_client.py | 26 +++++++- api/insurance/ppt/normalizer.py | 55 ++++++++++------ api/insurance/ppt/regex_extractor.py | 1 + api/insurance/ppt/validator.py | 23 +++++++ api/insurance/requirements.txt | 36 +++++----- frontend/src/pages/PosterPage.vue | 84 +++++++++++++++++++----- frontend/src/utils/poster-exporter.ts | 28 +++++--- tests/poster_render_validation_test.py | 14 ++++ tests/ppt_poster_optimization_test.py | 64 ++++++++++++++++++ tests/ppt_task_lifecycle_test.py | 62 ++++++++++++++++- 12 files changed, 402 insertions(+), 70 deletions(-) diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index 03b8be9..f6e0b71 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -349,6 +349,7 @@ 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 "" product_id = ext.get("productId") if product_id: configured_product = PptProduct.query.filter_by( @@ -487,6 +488,12 @@ 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_info = None if company_id: company = PptCompany.query.filter_by(id=company_id, deleted_at=None).first() diff --git a/api/insurance/ppt/extraction.py b/api/insurance/ppt/extraction.py index 9f52948..e327b4d 100644 --- a/api/insurance/ppt/extraction.py +++ b/api/insurance/ppt/extraction.py @@ -231,6 +231,13 @@ def _looks_corrupted(text: str) -> bool: """检测 PDF 文本是否乱码。""" if not text or len(text) < 50: return True + # 缺少 ToUnicode 映射时,部分解析器会返回大量 ``(cid:123)``。 + # 这些占位符是 ASCII,不能只靠下方的可读字符率判断。 + cid_placeholders = re.findall(r"\(cid:\d+\)", text, re.IGNORECASE) + if len(cid_placeholders) >= 10: + cid_chars = sum(len(value) for value in cid_placeholders) + if cid_chars / max(len(text), 1) >= 0.02: + return True visible = [c for c in text if not c.isspace()] if not visible: return True @@ -606,16 +613,47 @@ def _build_provenance( def _apply_filename_hints(data: dict, pdf_path: str, plan_type: str) -> dict: - """用文件名中的明确产品编码纠正 OCR 容易误读的产品名。""" + """用标准计划书文件名中的明确字段补齐 OCR 容易误读的数据。""" if not isinstance(data, 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 + data["product_name"] = "Manulife SIUL 3" - policy = data.get("policy") - if isinstance(policy, dict) and "product_name" in policy: + insured = data.setdefault("insured", {}) + policy = data.setdefault("policy", {}) + if isinstance(policy, dict): policy["product_name"] = "Manulife SIUL 3" + + # 标准文件名示例: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") + + 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()) + + 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"(?:^|[_-])(?P\d{1,2})x(?:[_-]|$)", filename, re.IGNORECASE) + if payment_match: + set_missing(policy, "premium_payment_period", int(payment_match.group("years"))) return data @@ -766,6 +804,24 @@ async def _llm_extract_split( identity_data, last_response = await llm_client.structured_output( prompt=identity_prompt, system_prompt="你是保险计划书数据提取专家。只输出 JSON。", + schema={ + "type": "object", + "required": ["product_name", "insured", "policy"], + "properties": { + "product_name": {"type": "string"}, + "insured": { + "type": "object", + "required": ["age", "gender"], + }, + "policy": { + "type": "object", + "required": [ + "currency", "sum_insured", "annual_premium", + "premium_payment_period", + ], + }, + }, + }, temperature=0, ) if isinstance(identity_data, dict): @@ -796,6 +852,11 @@ async def _llm_extract_split( benefit_data, benefit_resp = await llm_client.structured_output( prompt=benefit_prompt, system_prompt="你是保险计划书数据提取专家。只输出 JSON。", + schema={ + "type": "object", + "required": ["benefit_illustration"], + "properties": {"benefit_illustration": {"type": "array"}}, + }, temperature=0, ) if isinstance(benefit_data, dict) and "benefit_illustration" in benefit_data: @@ -827,6 +888,11 @@ async def _llm_extract_split( withdrawal_data, withdrawal_resp = await llm_client.structured_output( prompt=withdrawal_prompt, system_prompt="你是保险计划书数据提取专家。只输出 JSON。", + schema={ + "type": "object", + "required": ["withdrawal_illustration"], + "properties": {"withdrawal_illustration": {"type": "array"}}, + }, temperature=0, ) if isinstance(withdrawal_data, dict) and "withdrawal_illustration" in withdrawal_data: diff --git a/api/insurance/ppt/llm_client.py b/api/insurance/ppt/llm_client.py index 20a84d2..f5e433b 100644 --- a/api/insurance/ppt/llm_client.py +++ b/api/insurance/ppt/llm_client.py @@ -163,6 +163,24 @@ def _parse_json_content(content: str): raise ValueError(f"模型输出不是有效 JSON({detail})") +def _validate_required_fields(data, schema: Optional[dict], path: str = "$") -> None: + """校验 JSON Schema 中声明的必填容器和字段,避免把空对象当成成功。""" + if not schema: + return + schema_type = schema.get("type") + if schema_type == "object": + if not isinstance(data, dict): + raise ValueError(f"{path} 应为对象") + missing = [key for key in schema.get("required", []) if key not in data] + if missing: + raise ValueError(f"{path} 缺少必填字段: {', '.join(missing)}") + for key, child_schema in schema.get("properties", {}).items(): + if key in data and data[key] is not None: + _validate_required_fields(data[key], child_schema, f"{path}.{key}") + elif schema_type == "array" and not isinstance(data, list): + raise ValueError(f"{path} 应为数组") + + # ─── 单供应商调用 ───────────────────────────────────────── async def _call_provider( @@ -479,7 +497,9 @@ class LLMClient: response = await self._call(messages, json_mode=True, temperature=temperature) try: - return _parse_json_content(response.content), response + parsed = _parse_json_content(response.content) + _validate_required_fields(parsed, schema) + return parsed, response except ValueError as first_error: logger.warning( "[LLMClient] 首次结构化输出无效,正在请求模型纠正: %s", @@ -498,7 +518,9 @@ class LLMClient: ] repaired_response = await self._call(repair_messages, json_mode=True, temperature=temperature) try: - return _parse_json_content(repaired_response.content), repaired_response + parsed = _parse_json_content(repaired_response.content) + _validate_required_fields(parsed, schema) + return parsed, repaired_response except ValueError as repair_error: raise ValueError(f"[LLMClient] JSON 解析失败,纠正重试仍无效: {repair_error}") diff --git a/api/insurance/ppt/normalizer.py b/api/insurance/ppt/normalizer.py index 1526851..8cd6034 100644 --- a/api/insurance/ppt/normalizer.py +++ b/api/insurance/ppt/normalizer.py @@ -68,6 +68,15 @@ def _optional_number(value): return _safe_number(value) +def _first_value(mapping: dict, *keys): + """从 snake_case/camelCase 字段中读取第一个非空值。""" + for key in keys: + value = mapping.get(key) + if value not in (None, ""): + return value + return None + + def _normalize_pay_years(raw) -> int: """统一缴费年期为 int。兼容旧数据中的 '5年' 字符串。""" if raw is None: @@ -118,7 +127,7 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j policy_year = _safe_number(row.get("policy_year")) if policy_year <= 0: continue - age = _safe_number(row.get("age")) or (insured_age + policy_year) + age = _safe_number(row.get("age")) or (insured_age + policy_year - 1) benefit_rows.append({ "policyYear": int(policy_year), "age": int(age), @@ -160,9 +169,9 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j # 保单信息 policy = raw.get("policy", {}) - annual_premium = _safe_number(policy.get("annual_premium")) - pay_years = _extract_years(policy.get("premium_payment_period")) - raw_product_name = raw.get("product_name") or policy.get("product_name", "") + annual_premium = _safe_number(_first_value(policy, "annual_premium", "annualPremium")) + pay_years = _extract_years(_first_value(policy, "premium_payment_period", "payYears", "paymentPeriod")) + raw_product_name = _first_value(raw, "product_name", "productName") or _first_value(policy, "product_name", "productName") or "" return { "kind": "savings", @@ -176,10 +185,11 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j }, "policy": { "currency": _normalize_currency(policy.get("currency")), + "sumInsured": _safe_number(_first_value(policy, "sum_insured", "sumInsured")), "annualPremium": annual_premium, - "basicPlanAnnualPremium": _optional_number(policy.get("basic_plan_annual_premium")), - "basicSumInsured": _optional_number(policy.get("basic_sum_insured")), - "firstYearAmountDue": _optional_number(policy.get("first_year_amount_due")), + "basicPlanAnnualPremium": _optional_number(_first_value(policy, "basic_plan_annual_premium", "basicPlanAnnualPremium")), + "basicSumInsured": _optional_number(_first_value(policy, "basic_sum_insured", "basicSumInsured")), + "firstYearAmountDue": _optional_number(_first_value(policy, "first_year_amount_due", "firstYearAmountDue")), "annualPremiumWithLevy": policy.get("total_premium_with_levy"), "payYears": pay_years, "contractualTotalPremium": annual_premium * pay_years, @@ -236,8 +246,8 @@ def normalize_ci_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") }) benefit_rows.sort(key=lambda r: r["policyYear"]) - annual_premium = _safe_number(policy.get("annual_premium")) - pay_years = _extract_years(policy.get("premium_payment_period")) + annual_premium = _safe_number(_first_value(policy, "annual_premium", "annualPremium")) + pay_years = _extract_years(_first_value(policy, "premium_payment_period", "payYears", "paymentPeriod")) base_sum_insured = _safe_number( raw.get("base_sum_insured") or policy.get("basic_sum_insured") @@ -255,7 +265,7 @@ def normalize_ci_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json") }, "policy": { "currency": _normalize_currency(policy.get("currency")), - "sumInsured": _safe_number(policy.get("sum_insured")), + "sumInsured": _safe_number(_first_value(policy, "sum_insured", "sumInsured")), "baseSumInsured": base_sum_insured, "basicPlanAnnualPremium": _optional_number(policy.get("basic_plan_annual_premium")), "basicSumInsured": _optional_number(policy.get("basic_sum_insured")), @@ -319,11 +329,18 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json" policy_year = _safe_number(row.get("policy_year")) if policy_year <= 0: continue - age = _safe_number(row.get("age")) or (insured_age + policy_year) + age = _safe_number(row.get("age")) or (insured_age + policy_year - 1) # 回填非保证字段 non_guaranteed_account = _safe_number(row.get("non_guaranteed_account_value") or row.get("account_value")) non_guaranteed_cash = _safe_number(row.get("non_guaranteed_cash_value") or row.get("cash_value")) + total_surrender = _safe_number( + row.get("total_surrender_value") + or row.get("totalSurrenderValue") + or row.get("non_guaranteed_cash_value") + or row.get("cash_value") + or row.get("account_value") + ) non_guaranteed_death = _safe_number(row.get("non_guaranteed_death_benefit") or row.get("death_benefit")) benefit_rows.append({ @@ -332,20 +349,20 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json" "totalPremiumPaid": _safe_number(row.get("total_premium_paid")), "guaranteedCashValue": _safe_number(row.get("guaranteed_cash_value")), "nonGuaranteedCashValue": non_guaranteed_cash, - "totalSurrenderValue": non_guaranteed_cash, # 渲染器统一字段 + "totalSurrenderValue": total_surrender, "guaranteedDeathBenefit": _safe_number(row.get("guaranteed_death_benefit")), "nonGuaranteedDeathBenefit": non_guaranteed_death, "sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None, }) benefit_rows.sort(key=lambda r: r["policyYear"]) - annual_premium = _safe_number(policy.get("annual_premium")) - payment_period = policy.get("premium_payment_period", "") + annual_premium = _safe_number(_first_value(policy, "annual_premium", "annualPremium")) + payment_period = _first_value(policy, "premium_payment_period", "payYears", "paymentPeriod") or "" pay_years = _extract_years(payment_period) return { "kind": "iul", - "productName": raw.get("product_name", ""), + "productName": _first_value(raw, "product_name", "productName") or "", "insured": { "name": insured.get("name") or "客户", "age": int(insured_age), @@ -354,14 +371,14 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json" }, "policy": { "currency": _normalize_currency(policy.get("currency")), - "sumInsured": _safe_number(policy.get("sum_insured")), + "sumInsured": _safe_number(_first_value(policy, "sum_insured", "sumInsured")), "initialPremium": _safe_number(policy.get("initial_premium")), "annualPremium": annual_premium, "targetPremium": _optional_number(policy.get("target_premium")), "minimumPremium": _optional_number(policy.get("minimum_premium")), - "basicPlanAnnualPremium": _optional_number(policy.get("basic_plan_annual_premium")), - "basicSumInsured": _optional_number(policy.get("basic_sum_insured")), - "firstYearAmountDue": _optional_number(policy.get("first_year_amount_due")), + "basicPlanAnnualPremium": _optional_number(_first_value(policy, "basic_plan_annual_premium", "basicPlanAnnualPremium")), + "basicSumInsured": _optional_number(_first_value(policy, "basic_sum_insured", "basicSumInsured")), + "firstYearAmountDue": _optional_number(_first_value(policy, "first_year_amount_due", "firstYearAmountDue")), "payYears": pay_years, "totalPremium": annual_premium * pay_years, "paymentPeriod": str(payment_period), diff --git a/api/insurance/ppt/regex_extractor.py b/api/insurance/ppt/regex_extractor.py index 05ee58c..05e3efb 100644 --- a/api/insurance/ppt/regex_extractor.py +++ b/api/insurance/ppt/regex_extractor.py @@ -126,6 +126,7 @@ def _extract_smoker(text: str) -> str | None: def _extract_annual_premium(text: str) -> float | None: """提取年缴保费。""" patterns = [ + 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+)?)', ] diff --git a/api/insurance/ppt/validator.py b/api/insurance/ppt/validator.py index cd55e26..c4becb6 100644 --- a/api/insurance/ppt/validator.py +++ b/api/insurance/ppt/validator.py @@ -199,6 +199,20 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]: if benefit_rows and not any(r.get("sourcePage") for r in benefit_rows): warn("IUL_SOURCE_PAGE_MISSING", "利益演示缺少来源页码") + annual_premium = _safe_number(policy.get("annualPremium")) + positive_surrender = [ + _safe_number(row.get("totalSurrenderValue")) + for row in benefit_rows + if _safe_number(row.get("totalSurrenderValue")) > 0 + ] + if annual_premium > 0 and len(positive_surrender) >= 3: + tiny_values = [value for value in positive_surrender if value < annual_premium * 0.01] + if len(tiny_values) / len(positive_surrender) >= 0.6: + err( + "IUL_BENEFIT_VALUE_IMPLAUSIBLE", + "多数退保价值不足年缴保费的 1%,疑似把年龄、页码或百分比识别为金额,请核对利益表", + ) + # 缴费年期一致性检查 payment_period = plan.get("policy", {}).get("paymentPeriod", "") if benefit_rows and payment_period and is_continuous: @@ -215,12 +229,21 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]: err("IUL_PAY_TERM_MISMATCH", f"缴费年期不一致:声明 {stated_years} 年,数据检测 {detected_years} 年") # 年龄合理性检查 + insured_age = _safe_number(plan.get("insured", {}).get("age")) for row in benefit_rows: if isinstance(row, dict): age = _safe_number(row.get("age")) if age > 0 and (age < 0 or age > 150): err("IUL_AGE_OUT_OF_RANGE", f"年龄超出合理范围: {age}") break + policy_year = _safe_number(row.get("policyYear")) + expected_age = insured_age + policy_year - 1 + if age > 0 and insured_age > 0 and policy_year > 0 and abs(age - expected_age) > 1: + err( + "IUL_AGE_YEAR_MISMATCH", + f"第 {int(policy_year)} 保单年度年龄应约为 {int(expected_age)},实际为 {int(age)}", + ) + break return issues diff --git a/api/insurance/requirements.txt b/api/insurance/requirements.txt index 4593275..963b1f5 100644 --- a/api/insurance/requirements.txt +++ b/api/insurance/requirements.txt @@ -1,34 +1,34 @@ # Web framework -Flask==3.0.0 -Flask-CORS==4.0.0 -gunicorn==21.2.0 -gevent==23.9.1 -gevent-websocket==0.10.1 +Flask>=3.0.0 +Flask-CORS>=4.0.0 +gunicorn>=21.2.0 +gevent>=23.9.1 +gevent-websocket>=0.10.1 # Database -SQLAlchemy==2.0.23 -Flask-SQLAlchemy==3.1.1 -psycopg2-binary==2.9.9 -alembic==1.13.0 +SQLAlchemy>=2.0.23 +Flask-SQLAlchemy>=3.1.1 +psycopg2-binary>=2.9.9 +alembic>=1.13.0 # Cache -redis==5.0.1 +redis>=5.0.1 # Auth -PyJWT==2.8.0 -bcrypt==4.1.2 +PyJWT>=2.8.0 +bcrypt>=4.1.2 # HTTP client -requests==2.31.0 -httpx==0.25.2 +requests>=2.31.0 +httpx>=0.25.2 # Utils -python-dotenv==1.0.0 -pydantic==2.5.0 -pydantic-settings==2.1.0 +python-dotenv>=1.0.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 # Logging -loguru==0.7.2 +loguru>=0.7.2 # PDF parsing (required for PPT extraction) PyMuPDF>=1.23.0 diff --git a/frontend/src/pages/PosterPage.vue b/frontend/src/pages/PosterPage.vue index 18f817f..052b018 100644 --- a/frontend/src/pages/PosterPage.vue +++ b/frontend/src/pages/PosterPage.vue @@ -277,6 +277,12 @@ async function onGenerate() { return } + const existingRecordId = d.taskRecordId || ws.recordId.value + if (d.taskStatus === 'failed' && existingRecordId) { + const reused = await retryExistingComposite(existingRecordId) + if (reused) return + } + if (!d.templateId) { ElMessage.warning('请选择海报模板') return @@ -399,9 +405,9 @@ function startPolling(id: number) { if (!ready) throw new Error('海报合成保存失败') ws.draft.value.taskStatus = 'done' ws.draft.value.taskProgress = 100 - } catch { + } catch (e: any) { ws.draft.value.taskStatus = 'failed' - ws.draft.value.taskError = '背景加载或海报合成失败,请重试' + ws.draft.value.taskError = pipelineErrorMessage(e) } } } @@ -501,13 +507,15 @@ async function renderComposite(): Promise { async function saveCompositeToServer(recordId: number) { await nextTick() const blob = await renderComposite() - if (!blob) return false + if (!blob) { + throw new Error(lastRenderError.value || 'DOM_EXPORT_FAILED: 浏览器未生成海报文件') + } try { await posterApi.uploadRendered(recordId, blob, buildPosterDocument()) return true - } catch (e) { + } catch (e: any) { console.warn('最终海报保存失败:', e) - return false + throw new Error(`RENDER_UPLOAD_FAILED: ${e?.message || '最终海报回传失败'}`) } } @@ -516,25 +524,67 @@ async function applyGeneratedBackground(blob: Blob, recordId: number, persist = const nextUrl = URL.createObjectURL(blob) try { await loadImage(nextUrl) - } catch { + } catch (e: any) { URL.revokeObjectURL(nextUrl) - return false + throw new Error(`BACKGROUND_DECODE_FAILED: ${e?.message || '底图无法解码'}`) } if (d.posterUrl) { if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl) d.backgroundCandidateUrl = nextUrl await nextTick() const canvas = stageRef.value?.htmlCanvasRef?.canvasRef - if (canvas) await waitForPosterAssets(canvas) + if (canvas) { + try { + await waitForPosterAssets(canvas) + } catch (e: any) { + throw new Error(`ASSET_WAIT_FAILED: ${e?.message || '海报资源加载失败'}`) + } + } return true } d.posterUrl = nextUrl await nextTick() const canvas = stageRef.value?.htmlCanvasRef?.canvasRef - if (canvas) await waitForPosterAssets(canvas) + if (canvas) { + try { + await waitForPosterAssets(canvas) + } catch (e: any) { + throw new Error(`ASSET_WAIT_FAILED: ${e?.message || '海报资源加载失败'}`) + } + } return persist ? saveCompositeToServer(recordId) : true } +function pipelineErrorMessage(error: any): string { + const message = error?.message || lastRenderError.value || '海报合成失败' + return `底图已生成,但浏览器合成失败:${message}。请点击重试,系统不会重新生成 AI 底图。` +} + +async function retryExistingComposite(recordId: number): Promise { + try { + const res: any = await posterApi.getRecord(recordId) + const record = res?.data ?? res + const status = record?.taskStatus || record?.task_status + if (status !== 'background_ready' && status !== 'done') return false + + ws.draft.value.taskStatus = 'asset_loading' + ws.draft.value.taskError = null + if (ws.draft.value.posterUrl) { + await saveCompositeToServer(recordId) + } else { + const blob = await posterApi.downloadBackground(recordId) + await applyGeneratedBackground(blob, recordId, status === 'background_ready') + } + ws.draft.value.taskStatus = 'done' + ws.draft.value.taskProgress = 100 + return true + } catch (e: any) { + ws.draft.value.taskStatus = 'failed' + ws.draft.value.taskError = pipelineErrorMessage(e) + return true + } +} + async function acceptBackgroundCandidate() { const d = ws.draft.value if (!d.backgroundCandidateUrl) return @@ -702,10 +752,10 @@ async function restoreWorkspace() { record?.taskStatus === 'background_ready', ) d.taskStatus = ready ? 'done' : 'failed' - if (!ready) d.taskError = '海报图片加载失败,请刷新页面重试' - } catch { + if (!ready) d.taskError = pipelineErrorMessage(null) + } catch (e: any) { d.taskStatus = 'failed' - d.taskError = '海报图片加载失败,请刷新页面重试' + d.taskError = pipelineErrorMessage(e) } } } else if (task.status === 'failed' || task.status === 'cancelled') { @@ -743,10 +793,10 @@ async function restoreWorkspace() { const blob = await posterApi.downloadBackground(pollId) const ready = await applyGeneratedBackground(blob, pollId, realStatus === 'background_ready') d.taskStatus = ready ? 'done' : 'failed' - if (!ready) d.taskError = '海报图片加载失败,请刷新页面重试' - } catch { + if (!ready) d.taskError = pipelineErrorMessage(null) + } catch (e: any) { d.taskStatus = 'failed' - d.taskError = '海报图片加载失败,请刷新页面重试' + d.taskError = pipelineErrorMessage(e) } } else if (realStatus !== 'failed') { startPolling(pollId) @@ -791,9 +841,9 @@ function startPollingUnified(taskId: string) { if (!ready) throw new Error('海报合成保存失败') ws.draft.value.taskStatus = 'done' ws.draft.value.taskProgress = 100 - } catch { + } catch (e: any) { ws.draft.value.taskStatus = 'failed' - ws.draft.value.taskError = '背景加载或海报合成失败,请重试' + ws.draft.value.taskError = pipelineErrorMessage(e) } } } else if (status === 'failed' || status === 'cancelled') { diff --git a/frontend/src/utils/poster-exporter.ts b/frontend/src/utils/poster-exporter.ts index 3d7c43a..df0f8ca 100644 --- a/frontend/src/utils/poster-exporter.ts +++ b/frontend/src/utils/poster-exporter.ts @@ -7,14 +7,14 @@ const FORMAT_OUTPUTS: Record { return new Promise((resolve, reject) => { const image = new Image() - const timeout = window.setTimeout(() => reject(new Error(`图片加载超时: ${url}`)), 15000) + const timeout = window.setTimeout(() => reject(new Error(`BACKGROUND_ASSET_TIMEOUT: ${url}`)), 15000) image.onload = () => { window.clearTimeout(timeout) resolve() } image.onerror = () => { window.clearTimeout(timeout) - reject(new Error(`图片加载失败: ${url}`)) + reject(new Error(`BACKGROUND_ASSET_FAILED: ${url}`)) } image.src = url }) @@ -44,7 +44,12 @@ export async function waitForPosterAssets(canvas: HTMLElement): Promise { return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { chart.removeEventListener('poster-chart-ready', onReady) - reject(new Error('收益图表渲染超时')) + const rendered = chart.querySelector('canvas, svg') as HTMLElement | null + if (rendered && rendered.clientWidth > 0 && rendered.clientHeight > 0) { + resolve() + return + } + reject(new Error('CHART_RENDER_TIMEOUT: 收益图表渲染超时')) }, 10000) const onReady = () => { window.clearTimeout(timeout) @@ -81,15 +86,15 @@ async function readBlobSize(blob: Blob): Promise<{ width: number; height: number async function validateOutput(blob: Blob, formatId: string, expectedHeight?: number | null): Promise { const expected = FORMAT_OUTPUTS[formatId] - if (!expected) throw new Error(`不支持的导出格式: ${formatId}`) + if (!expected) throw new Error(`OUTPUT_FORMAT_UNSUPPORTED: ${formatId}`) const { width, height } = await readBlobSize(blob) const requiredHeight = expectedHeight ?? expected.height if (width !== expected.width || (requiredHeight !== null && height !== requiredHeight)) { - throw new Error(`导出尺寸错误:期望 ${expected.width}×${requiredHeight ?? '自动高度'},实际 ${width}×${height}`) + throw new Error(`OUTPUT_SIZE_INVALID: 期望 ${expected.width}×${requiredHeight ?? '自动高度'},实际 ${width}×${height}`) } if (expected.height === null && (height < 1 || height > 32767)) { - throw new Error(`长图高度 ${height}px 超出浏览器安全范围`) + throw new Error(`OUTPUT_HEIGHT_UNSAFE: 长图高度 ${height}px 超出浏览器安全范围`) } } @@ -99,13 +104,17 @@ export async function renderPosterToBlob( expectedHeight?: number | null, ): Promise { const spec = FORMAT_OUTPUTS[formatId] - if (!spec) throw new Error(`不支持的导出格式: ${formatId}`) + if (!spec) throw new Error(`OUTPUT_FORMAT_UNSUPPORTED: ${formatId}`) if (expectedHeight != null && formatId !== 'long_1242_auto') { throw new Error('自定义高度仅长图模式可用') } const { toBlob } = await import('html-to-image') await waitForPosterAssets(canvas) + const predictedHeight = Math.ceil(canvas.scrollHeight * spec.pixelRatio) + if (predictedHeight < 1 || predictedHeight > 32767) { + throw new Error(`OUTPUT_HEIGHT_UNSAFE: 预计导出高度 ${predictedHeight}px 超出浏览器安全范围`) + } if (expectedHeight != null && canvas.scrollHeight > canvas.clientHeight + 1) { const minimumHeight = Math.ceil(canvas.scrollHeight * spec.pixelRatio) throw new Error(`当前内容超出自定义高度,请将高度调整到至少 ${minimumHeight}px`) @@ -113,9 +122,10 @@ export async function renderPosterToBlob( const blob = await toBlob(canvas, { quality: 0.95, pixelRatio: spec.pixelRatio, - cacheBust: true, + // AI 底图使用 blob: URL;html-to-image 的 cacheBust 会给 URL 追加查询串并使其失效。 + cacheBust: false, }) - if (!blob) throw new Error('浏览器未生成海报文件') + if (!blob) throw new Error('DOM_EXPORT_FAILED: 浏览器未生成海报文件') await validateOutput(blob, formatId, expectedHeight) return blob } diff --git a/tests/poster_render_validation_test.py b/tests/poster_render_validation_test.py index c6cad36..cf73480 100644 --- a/tests/poster_render_validation_test.py +++ b/tests/poster_render_validation_test.py @@ -75,3 +75,17 @@ def test_background_generation_does_not_claim_final_poster_is_done(): assert 'record.task_status = "background_ready"' in celery_source assert 'record.task_status = "done"' in service_source + + +def test_failed_client_composition_reuses_existing_background_and_exposes_stage_error(): + page = (ROOT / "frontend/src/pages/PosterPage.vue").read_text(encoding="utf-8") + exporter = (ROOT / "frontend/src/utils/poster-exporter.ts").read_text(encoding="utf-8") + + assert "retryExistingComposite" in page + assert "系统不会重新生成 AI 底图" in page + assert "BACKGROUND_DECODE_FAILED" in page + assert "ASSET_WAIT_FAILED" in page + assert "RENDER_UPLOAD_FAILED" in page + assert "CHART_RENDER_TIMEOUT" in exporter + assert "OUTPUT_HEIGHT_UNSAFE" in exporter + assert "cacheBust: false" in exporter diff --git a/tests/ppt_poster_optimization_test.py b/tests/ppt_poster_optimization_test.py index 45a698b..5e102ad 100644 --- a/tests/ppt_poster_optimization_test.py +++ b/tests/ppt_poster_optimization_test.py @@ -38,6 +38,70 @@ def test_optional_review_fields_are_normalized_without_becoming_required(): assert plan["policy"]["firstYearAmountDue"] == 9800 +def test_iul_key_fields_accept_raw_and_normalized_names_without_data_loss(): + from insurance.ppt.normalizer import normalize_iul_plan + + plan = normalize_iul_plan({ + "productName": "Manulife SIUL 3", + "insured": {"age": 48, "gender": "female", "smoker": "non-smoker"}, + "policy": { + "currency": "USD", + "sumInsured": 3000000, + "annualPremium": 80060, + "payYears": 5, + "coverage_period": "终身", + }, + "benefit_illustration": [ + {"policy_year": 1, "total_surrender_value": 31600}, + {"policy_year": 10, "total_surrender_value": 76800}, + ], + }) + + assert plan["productName"] == "Manulife SIUL 3" + assert plan["insured"]["age"] == 48 + assert plan["insured"]["smoker"] == "no" + assert plan["policy"]["sumInsured"] == 3000000 + assert plan["policy"]["annualPremium"] == 80060 + assert plan["policy"]["payYears"] == 5 + assert plan["benefitRows"][0]["age"] == 48 + assert plan["benefitRows"][1]["age"] == 57 + assert plan["benefitRows"][1]["totalSurrenderValue"] == 76800 + + +def test_iul_implausibly_tiny_benefit_values_block_generation(): + 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 - 1, "totalSurrenderValue": value} + for year, value in [(1, 56), (10, 85), (20, 114), (30, 122)] + ], + "source": {"pdfHash": "hash"}, + }) + + assert any( + issue.code == "IUL_BENEFIT_VALUE_IMPLAUSIBLE" and issue.level == "error" + for issue in issues + ) + + +def test_ppt_generation_preserves_company_selected_for_uploaded_file(): + source = ( + Path(__file__).resolve().parents[1] + / "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 + + def test_benefit_and_withdrawal_issues_never_block_savings_generation(): from insurance.ppt.validator import validate_formal_savings_plan diff --git a/tests/ppt_task_lifecycle_test.py b/tests/ppt_task_lifecycle_test.py index da884dc..2c0f536 100644 --- a/tests/ppt_task_lifecycle_test.py +++ b/tests/ppt_task_lifecycle_test.py @@ -12,7 +12,12 @@ from insurance.generation import task_service from insurance.ppt import extraction as extraction_module from insurance.ppt import regex_extractor from insurance.ppt.extraction import ExtractionOrchestrator, ExtractionResult -from insurance.ppt.llm_client import LLMResponse, _parse_json_content, _parse_timeout_ms +from insurance.ppt.llm_client import ( + LLMResponse, + _parse_json_content, + _parse_timeout_ms, + _validate_required_fields, +) from insurance.ppt.prompts import select_key_pages @@ -134,6 +139,26 @@ def test_llm_json_parser_accepts_explanation_and_trailing_comma(): assert _parse_json_content(content) == {"product_name": "测试产品"} +def test_structured_schema_rejects_empty_or_incomplete_success_payload(): + schema = { + "type": "object", + "required": ["insured", "policy"], + "properties": { + "insured": {"type": "object", "required": ["age"]}, + "policy": {"type": "object", "required": ["annual_premium"]}, + }, + } + + with pytest.raises(ValueError, match="缺少必填字段"): + _validate_required_fields({}, schema) + with pytest.raises(ValueError, match="annual_premium"): + _validate_required_fields({"insured": {"age": 48}, "policy": {}}, schema) + _validate_required_fields( + {"insured": {"age": 48}, "policy": {"annual_premium": 80060}}, + schema, + ) + + def test_pdf_pages_keep_page_numbers_and_select_late_benefit_page(): text = extraction_module._format_pdf_pages([ "Product Name: Example IUL", @@ -154,6 +179,8 @@ def test_corrupted_pdf_text_detection_accepts_normal_text_and_rejects_font_garba assert extraction_module._looks_corrupted(normal) is False assert extraction_module._looks_corrupted(corrupted) is True + cid_garbage = "[PAGE 1]\n" + "(cid:4)(cid:17)(cid:238)(cid:99)" * 30 + assert extraction_module._looks_corrupted(cid_garbage) is True def test_iul_filename_hint_corrects_ocr_product_name(): @@ -164,12 +191,43 @@ def test_iul_filename_hint_corrects_ocr_product_name(): corrected = extraction_module._apply_filename_hints( data, - "/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m.pdf", + "/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x_coi__SC_.pdf", "iul", ) assert corrected["product_name"] == "Manulife SIUL 3" assert corrected["policy"]["product_name"] == "Manulife SIUL 3" + assert corrected["insured"] == {"gender": "female", "age": 48, "smoker": "no"} + assert corrected["policy"]["currency"] == "USD" + assert corrected["policy"]["sum_insured"] == 3_000_000 + assert corrected["policy"]["premium_payment_period"] == 5 + + +def test_iul_filename_hint_does_not_override_extracted_values(): + data = { + "product_name": "unknown", + "insured": {"age": 49, "gender": "female", "smoker": "no"}, + "policy": {"currency": "HKD", "sum_insured": 2_000_000, "premium_payment_period": 8}, + } + + corrected = extraction_module._apply_filename_hints( + data, + "/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x.pdf", + "iul", + ) + + assert corrected["insured"]["age"] == 49 + assert corrected["policy"]["currency"] == "HKD" + assert corrected["policy"]["sum_insured"] == 2_000_000 + assert corrected["policy"]["premium_payment_period"] == 8 + + +def test_iul_ocr_label_extracts_planned_annual_premium(): + from insurance.ppt.regex_extractor import _extract_annual_premium + + text = "性别 Female 首期规划保费 US$80,060.00\n偿还至形成基金所需保费 US$80,060.00 从第1年至第5年" + + assert _extract_annual_premium(text) == 80_060 def test_savings_milestone_rows_are_warnings_not_blocking_errors():