主要修复:
海报加载失败根因:html-to-image 给 blob: 底图地址追加缓存参数,导致地址失效。现已关闭该行为。 合成失败后再次点击“重新生成”,会复用已有 AI 底图,只重试浏览器合成和上传,避免重复调用 AI。 增加底图加载、图表超时、导出失败、尺寸越界等分阶段错误提示。 修复计划书 (cid:数字) 字体乱码被误判为正常文本的问题,现在会正确转入 OCR。 增加繁体中文 OCR 运行支持。 补齐 SIUL 文件名中的确定字段,并且不会覆盖正文已识别数据。 增加“首期规划保费/償還至形成基金所需保費”等保费标签识别。 修正 IUL 年龄、保额、退保价值、缴费期、公司信息等字段映射。 LLM 返回空对象或缺字段时不再视为成功。 增加错误利益数值和年龄/保单年度错位校验。 修复依赖版本降级导致 API/Worker 无法启动的风险。 真实计划书复验结果: 产品:Manulife SIUL 3 投保年龄:48 岁 性别:女性 吸烟状态:非吸烟 币种:USD 基本保额:3,000,000 年缴保费:80,060 缴费期:5 年 利益演示:识别到 10 行 验证结果: 后端相关回归测试:91 passed 前端生产构建:通过 API、数据库、Redis、存储、数据表健康检查:全部正常 Celery Worker:已重启并连接 Redis 真实 PDF:确认进入 OCR,不再使用 (cid:...) 乱码 前端构建目录由运行容器挂载,修复已生效
This commit is contained in:
parent
e1770be4cc
commit
caee27b0d3
@ -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()
|
||||
|
||||
@ -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<gender>[FM])-(?P<age>\d{1,3})-(?P<smoker>[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<amount>\d+(?:\.\d+)?)(?P<unit>[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<years>\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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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+)?)',
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Blob | null> {
|
||||
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<boolean> {
|
||||
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') {
|
||||
|
||||
@ -7,14 +7,14 @@ const FORMAT_OUTPUTS: Record<string, { width: number; height: number | null; pix
|
||||
function loadExportImage(url: string): Promise<void> {
|
||||
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<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
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<Blob> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user