主要修复:

海报加载失败根因: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:
wsb1224 2026-07-31 23:35:45 +08:00
parent e1770be4cc
commit caee27b0d3
12 changed files with 402 additions and 70 deletions

View File

@ -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()

View File

@ -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:

View File

@ -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}")

View File

@ -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),

View File

@ -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+)?)',
]

View File

@ -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

View File

@ -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

View File

@ -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') {

View File

@ -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: URLhtml-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
}

View File

@ -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

View File

@ -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

View File

@ -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():