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