baodan/api/insurance/ppt/regex_extractor.py
wsb1224 ed9f0327d2 已完成:
OCR 表格模式改为更适合计划书的 PSM 4。
分别支持斜杠年度、独立年度/年龄列、纵向压缩表格、保证/非保证双栏。
防止错误的坐标解析结果覆盖正确 OCR 数据。
身故利益表与退保价值表按保单年度合并。
修复吸烟状态、保额、年缴/单缴金额和缴费年期。
补齐 SIUL3、SBIUL2、GIUL3、FWD IF、AIA PIL2 产品配置。
多份计划书现在保留全部保司,不再只取第一家公司。
用户端保司选项、生成快照和渲染过程统一使用后台脱敏名
2026-08-01 19:12:17 +08:00

1592 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""纯正则 PDF 数据提取模块 — 不调用 LLM零延迟。
从保险计划书 PDF 的原始文本中,用正则 + 启发式规则提取结构化 JSON。
提取率目标 > 80%(按保险演示表行数计算)。
用法:
from insurance.ppt.regex_extractor import extract_insurance_regex
data = extract_insurance_regex(pdf_text)
"""
import re
import logging
logger = logging.getLogger(__name__)
# ─── 金额解析 ──────────────────────────────────────────────
def _parse_money(s: str) -> float | None:
"""解析各种格式的金额字符串为 float。
支持: "1,234,567.89" "1234567" "1,234,567" "US$1,234" "HK$1234"
注意: 0 是合法值(如红利为 0 时),仅空字符串和无法解析返回 None。
"""
if not s:
return None
# 去掉货币符号和空白
s = re.sub(r'[Uu][Ss]\$|[Hh][Kk]\$|[Cc][Nn][Yy]?¥|¥|\$|\s', '', s.strip())
# 去掉逗号
s = s.replace(',', '')
try:
return float(s)
except (ValueError, TypeError):
return None
# ─── 产品信息提取 ──────────────────────────────────────────
def _extract_product_name(text: str) -> str | None:
"""提取产品名称。
支持:带标签格式、计划书标题、繁体、英文缩写。
"""
patterns = [
# 带标签
r'(?:产品名称|产品名|产品|保单名称)\s*[:]\s*([^\n,]+)',
r'(?:Product(?:\s+Name)?|Plan(?:\s+Name)?)\s*[:]\s*([^\n,]+)',
r'(?:建议书|建議書|計劃書|計劃名稱|保障計劃|保障计划)\s*[:]\s*([^\n,]+)',
r'(?:Proposal)\s*[:]\s*([^\n,]+)',
# 繁体
r'(?:產品名稱|產品名)\s*[:]\s*([^\n,]+)',
# "XXX 计划" 格式(无冒号标签)
r'(?:^|\n)\s*([A-Z][\w\s]*(?:Plan|Scheme|Life|Assurance|Insurance|Protection))\s*(?:\n|$)',
# 中文"XXX计划/保障"格式
r'(?:^|\n)\s*([一-鿿]{2,10}(?:计划|計劃|保障|寿险|壽險|危疾|储蓄))\s*(?:\n|$)',
]
for p in patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
name = m.group(1).strip()
# 过滤掉太短或纯数字的结果
if len(name) >= 2 and not name.isdigit():
return name
return None
def _extract_currency(text: str) -> str | None:
"""提取保单货币,支持多币种(优先匹配保费行中的币种符号)。
未识别时返回 None由用户或后续流程确认不再默认 USD。
"""
# 按优先级搜索
patterns = [
(r'(?:保单货币|保费货币|Currency)\s*[:]\s*(USD|USB|HKD|CNY|RMB|SGD|GBP|EUR)', 'direct'),
(r'\b(USD)\b', 'usd'),
(r'\b(HKD)\b', 'hkd'),
(r'\b(CNY|RMB)\b', 'cny'),
(r'\b(SGD)\b', 'sgd'),
(r'U\s*S\s*\$', 'us_symbol'),
(r'H\s*K\s*\$', 'hk_symbol'),
]
for pattern, tag in patterns:
m = re.search(pattern, text, re.IGNORECASE)
if m:
if tag == 'us_symbol':
return 'USD'
if tag == 'hk_symbol':
return 'HKD'
value = m.group(1).upper()
if value == "USB":
return "USD"
if value == "RMB":
return "CNY"
return value
return None
def _extract_labeled_money(text: str, labels: list[str]) -> tuple[float | None, str | None]:
"""提取明确标签后的金额,同时保留原始命中标签。"""
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+)?)",
text,
re.IGNORECASE,
)
if not match:
return None, None
return _parse_money(match.group(2)), match.group(1)
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)
if not match:
continue
value = match.group(1) if match.lastindex else match.group(0)
if str(value).strip().lower() in ("", "吸烟", "吸煙", "yes", "y"):
return "yes"
return "no"
return None
def _extract_annual_premium(text: str) -> float | None:
"""提取年缴保费。"""
patterns = [
r'(?:首期规划保费|首期規劃保費|偿还至形成基金所需保费|償還至形成基金所需保費)\s*[:]?\s*(?:[A-Z]{2,3})?\$?\s*([\d,]+(?:\.\d+)?)',
r'(?:每年(?:缴付)?保费|年缴保费|年保费|每年(?:繳付)?保費|年繳保費)\s*[:]\s*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)',
r'(?:Annual\s+Premium|每年(?:缴付)?保费|年缴保费)\s*[:]?\s*(?:[A-Z]{2,3}\$?\s*)?([\d,]+(?:\.\d+)?)',
r'(?:投保时|投保時)\s*(?:年缴保费|年繳保費)\s*[:]?\s*(?:[A-Z]{2,3}\$?\s*)?([\d,]+(?:\.\d+)?)',
]
for p in patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
val = _parse_money(m.group(1))
if val and val >= 100: # 排除太小的数字(可能是年期等)
return val
return None
def _extract_insured_info(text: str) -> dict:
"""提取被保人信息(年龄、性别)。
支持:简体/繁体、英文、出生日期推算。
使用"受保人上下文窗口"避免误取利益表中的年龄数字。
"""
info = {'age': None, 'gender': None, 'age_source': None}
# ── 年龄:优先匹配带标签的明确年龄 ──
age_patterns = [
# 简体
r'(?:受保人|被保人|投保时|投保人)?\s*年龄\s*[:]\s*(\d{1,3})\s*岁?',
r'(?:受保人|被保人)\s*[:]?\s*(?:[^\n,]{0,10})?(\d{1,3})\s*岁',
# 繁体
r'(?:受保人|被保人|投保時|投保人)?\s*年齡\s*[:]\s*(\d{1,3})\s*歲?',
r'(?:受保人|被保人)\s*[:]?\s*(?:[^\n,]{0,10})?(\d{1,3})\s*歲',
# 英文
r'(?:Issue\s+Age|Age\s+at\s+Entry|Age\s+at\s+Issue|Insured\s+Age)\s*[:]\s*(\d{1,3})',
r'(?:Age)\s*[:]\s*(\d{1,3})',
]
for p in age_patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
age = int(m.group(1))
if 0 <= age <= 120:
info['age'] = age
info['age_source'] = 'explicit'
break
# ── 年龄:出生日期推算 ──
if info['age'] is None:
dob_patterns = [
r'(?:出生[日 destinationViewController]?[期日]|Date\s+of\s+Birth|DOB|Birth\s+Date)\s*[:]\s*(\d{4})[/-](\d{1,2})[/-](\d{1,2})',
r'(?:出生[日 destinationViewController]?[期日]|Date\s+of\s+Birth|DOB)\s*[:]\s*(\d{1,2})[/-](\d{1,2})[/-](\d{4})',
]
issue_date_patterns = [
r'(?:保单[日 destinationViewController]?[期日]|Issue\s+Date|Policy\s+Date|投保[日 destinationViewController]?[期日])\s*[:]\s*(\d{4})[/-](\d{1,2})[/-](\d{1,2})',
r'(?:保单[日 destinationViewController]?[期日]|Issue\s+Date|Policy\s+Date)\s*[:]\s*(\d{1,2})[/-](\d{1,2})[/-](\d{4})',
]
import datetime
today = datetime.date.today()
for dp in dob_patterns:
dm = re.search(dp, text, re.IGNORECASE)
if dm:
try:
groups = dm.groups()
if len(groups[0]) == 4:
dob = datetime.date(int(groups[0]), int(groups[1]), int(groups[2]))
else:
dob = datetime.date(int(groups[2]), int(groups[1]), int(groups[0]))
# 用保单日期或今天推算
issue_age = None
for ip in issue_date_patterns:
im = re.search(ip, text, re.IGNORECASE)
if im:
ig = im.groups()
if len(ig[0]) == 4:
issue_date = datetime.date(int(ig[0]), int(ig[1]), int(ig[2]))
else:
issue_date = datetime.date(int(ig[2]), int(ig[1]), int(ig[0]))
issue_age = issue_date.year - dob.year - (
(issue_date.month, issue_date.day) < (dob.month, dob.day)
)
break
if issue_age is None:
issue_age = today.year - dob.year - (
(today.month, today.day) < (dob.month, dob.day)
)
if 0 <= issue_age <= 120:
info['age'] = issue_age
info['age_source'] = 'inferred_from_dob'
except (ValueError, TypeError):
pass
break
# ── 性别 ──
gender_patterns = [
r'(?:受保人|被保人|投保人)?\s*性[别別]?\s*[:]\s*(男|女|Male|Female|M|F)',
r'(?:Gender|Sex)\s*[:]\s*(Male|Female|M|F)',
r'(男|女)\s*(?:性|士|仕)',
]
for p in gender_patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
raw = m.group(1).strip().upper()
if raw in ('', 'M', 'MALE'):
info['gender'] = 'male'
elif raw in ('', 'F', 'FEMALE'):
info['gender'] = 'female'
break
return info
def _extract_premium_payment_period(text: str) -> int | str | None:
"""提取保费缴付年期。返回 int年数'整付',不再返回 '5年' 等字符串。"""
patterns = [
r'(?:保费缴付年期|缴费年期|缴付年期|保費繳付年期|繳費年期|繳付年期)\s*[:]\s*(\d+)\s*(?:年|years?|yrs?)?',
r'(?:Premium\s+Payment\s+(?:Period|Term|Years?))\s*[:]\s*(\d+)\s*(?:years?|yrs?)?',
r'(?:整付|趸缴|趸繳|Single\s+Premium)',
r'(?:缴费|缴付|繳費|繳付)\s*(?:期限|年期)\s*[:]\s*(\d+)\s*年',
]
for p in patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
if '整付' in m.group(0) or '趸缴' in m.group(0) or 'Single' in m.group(0).title():
return '整付'
year_str = m.group(1)
try:
years = int(year_str)
if 1 <= years <= 100:
return years
except ValueError:
pass
return None
def _extract_coverage_period(text: str) -> str | None:
"""提取保障年期。"""
patterns = [
r'(?:保障年期|保障期|保障年期|Coverage\s+(?:Period|Term))\s*[:]\s*([^\n,]+)',
r'(?:终身保障|保障至终身|终身|Whole\s+Life|終身)',
r'(?:保障至|保至)\s*(\d+)\s*岁',
r'(?:保障至|保至)\s*(\d+)\s*歲',
]
for p in patterns:
m = re.search(p, text, re.IGNORECASE)
if m:
if '终身' in m.group(0) or 'Whole' in m.group(0).title():
return '终身'
return m.group(1).strip() if m.lastindex else None
return None
# ─── 演示表提取 ──────────────────────────────────────────
# 列名映射:各种 PDF 表头 → 标准字段名
_COLUMN_ALIASES = {
# 保单年度
'保单年度': 'policy_year', '保單年度': 'policy_year',
'policy year': 'policy_year', 'policyyear': 'policy_year',
'year': 'policy_year', '年度': 'policy_year',
'保单': 'policy_year', # 简称
'受保人年龄': 'age', '受保人年齡': 'age',
'年龄': 'age', '年齡': 'age',
'insured age': 'age', 'age': 'age',
# 已缴总保费
'缴付保费总额': 'total_premium_paid', '已缴保费': 'total_premium_paid',
'已付保费总额': 'total_premium_paid', '已缴付保费': 'total_premium_paid',
'total premium paid': 'total_premium_paid', 'premium paid': 'total_premium_paid',
'累积已付保费': 'total_premium_paid', '累计已缴': 'total_premium_paid',
'累计保费': 'total_premium_paid', '累計保費': 'total_premium_paid',
'已缴保费总额': 'total_premium_paid', '缴付保费': 'total_premium_paid',
'繳付保費總額': 'total_premium_paid', '已繳保費': 'total_premium_paid',
'累積已付保費': 'total_premium_paid', '已付保費總額': 'total_premium_paid',
# 保证现金价值
'保证现金价值': 'guaranteed_cash_value', '保證現金價值': 'guaranteed_cash_value',
'guaranteed cash value': 'guaranteed_cash_value', 'gcv': 'guaranteed_cash_value',
'保证退保价值': 'guaranteed_cash_value', '保证价值': 'guaranteed_cash_value',
'保证现金': 'guaranteed_cash_value',
# 归原红利
'归原红利': 'reversionary_bonus', '歸原紅利': 'reversionary_bonus',
'reversionary bonus': 'reversionary_bonus', '累积归原红利': 'reversionary_bonus',
'累计归原红利': 'reversionary_bonus', '周年红利': 'reversionary_bonus',
'复归红利': 'reversionary_bonus', '歸原紅利/復歸紅利': 'reversionary_bonus',
# 终期分红
'终期分红': 'terminal_dividend', '終期分紅': 'terminal_dividend',
'terminal dividend': 'terminal_dividend', '终期红利': 'terminal_dividend',
'特别红利': 'terminal_dividend',
# 退保发还总额
'退保发还总额': 'total_surrender_value', '退保發還總額': 'total_surrender_value',
'total surrender value': 'total_surrender_value', 'cash surrender value': 'total_surrender_value',
'退保价值': 'total_surrender_value', '退保总值': 'total_surrender_value',
'退保金额': 'total_surrender_value', '退保总额': 'total_surrender_value',
'保证利益总额': 'total_surrender_value',
# 身故赔偿
'身故赔偿': 'death_benefit', '身故賠償': 'death_benefit',
'death benefit': 'death_benefit', 'death': 'death_benefit',
'身故保障': 'death_benefit', '身故保险金': 'death_benefit',
# IUL 特有
'账户价值': 'account_value', '非保证账户价值': 'non_guaranteed_account_value',
'保证账户价值': 'guaranteed_account_value',
'非保证现金价值': 'non_guaranteed_cash_value',
'非保證現金價值': 'non_guaranteed_cash_value',
'非保证身故赔偿': 'non_guaranteed_death_benefit',
'非保證身故賠償': 'non_guaranteed_death_benefit',
'non-guaranteed account value': 'non_guaranteed_account_value',
'guaranteed account value': 'guaranteed_account_value',
'non-guaranteed cash value': 'non_guaranteed_cash_value',
'non-guaranteed death benefit': 'non_guaranteed_death_benefit',
'保险成本': 'cost_of_insurance', 'cost of insurance': 'cost_of_insurance', 'coi': 'cost_of_insurance',
# 繁体中文别名
'保單': 'policy_year', '保證現金': 'guaranteed_cash_value',
'歸原紅利': 'reversionary_bonus', '復歸紅利': 'reversionary_bonus',
'週年紅利': 'reversionary_bonus', '終期分紅': 'terminal_dividend',
'特別紅利': 'terminal_dividend', '退保發還總額': 'total_surrender_value',
'退保總值': 'total_surrender_value', '退保價值': 'total_surrender_value',
'身故賠償': 'death_benefit',
'已付保費總額': 'total_premium_paid', '已繳保費總額': 'total_premium_paid',
'累積已付保費': 'total_premium_paid',
'保證價值': 'guaranteed_cash_value', '保證現金價值': 'guaranteed_cash_value',
# ─── 提领表列名 ────────────────────────────────────────
# 年度提取金额
'提取金额': 'annual_withdrawal', '提取金額': 'annual_withdrawal',
'提款金额': 'annual_withdrawal', '提款金額': 'annual_withdrawal',
'现金提取': 'annual_withdrawal', '現金提取': 'annual_withdrawal',
'款项提取': 'annual_withdrawal', '款項提取': 'annual_withdrawal',
'每年提取': 'annual_withdrawal', '每年提款': 'annual_withdrawal',
'annual withdrawal': 'annual_withdrawal', 'withdrawal': 'annual_withdrawal',
'cash withdrawal': 'annual_withdrawal',
# 累计提取
'累计提取': 'total_withdrawn', '累計提取': 'total_withdrawn',
'累计提款': 'total_withdrawn', '累計提款': 'total_withdrawn',
'累积提取': 'total_withdrawn', '累積提取': 'total_withdrawn',
'total withdrawn': 'total_withdrawn', 'cumulative withdrawal': 'total_withdrawn',
# 提取前退保价值
'提取前退保金额': 'surrender_value_before', '提取前退保價值': 'surrender_value_before',
'提款前退保金额': 'surrender_value_before', '提款前退保價值': 'surrender_value_before',
'surrender value before': 'surrender_value_before',
# 提取后退保价值
'提取后退保金额': 'surrender_value_after', '提取後退保金額': 'surrender_value_after',
'提款后退保金额': 'surrender_value_after', '提款後退保金額': 'surrender_value_after',
'退保金额(提取后)': 'surrender_value_after',
'surrender value after': 'surrender_value_after',
}
def _normalize_column_name(name: str) -> str | None:
"""将 PDF 表头规范化为标准字段名。"""
normalized = name.strip().lower()
# 去掉括号内容和多余空格
normalized = re.sub(r'\(.*?\)|.*?', '', normalized).strip()
normalized = re.sub(r'\s+', ' ', normalized)
if normalized in _COLUMN_ALIASES:
return _COLUMN_ALIASES[normalized]
# 模糊匹配:检查是否包含关键词
for alias, field in _COLUMN_ALIASES.items():
if alias in normalized or normalized in alias:
return field
return None
def _detect_columns(header_line: str) -> list[tuple[int, str]]:
"""检测表头槽位和字段名。返回 [(slot_index, field_name), ...]。"""
columns = []
# 表头和数据行都按槽位解析,避免数据中的“—”被数字正则丢弃后整行左移。
parts = re.split(r'\t', header_line.strip()) if '\t' in header_line else re.split(r'\s{2,}', header_line.strip())
for slot_index, part in enumerate(parts):
field = _normalize_column_name(part)
if field:
columns.append((slot_index, field))
# 如果没有检测到列,尝试逐个关键词搜索
if len(columns) < 2:
matches = []
for alias, field in _COLUMN_ALIASES.items():
idx = header_line.lower().find(alias)
if idx >= 0:
matches.append((idx, field))
columns = []
for _, field in sorted(matches, key=lambda item: item[0]):
if not any(existing == field for _, existing in columns):
columns.append((len(columns), field))
columns.sort(key=lambda x: x[0])
return columns
def _extract_benefit_rows(text: str) -> list[dict]:
"""从 PDF 文本中提取利益演示表数据行。
策略:
1. 找到包含"保单年度"等关键词的表头行
2. 解析表头列位置
3. 提取后续数字行
"""
rows = []
lines = text.split('\n')
in_table = False
columns: list[tuple[int, str]] = []
consecutive_non_data = 0
MAX_NON_DATA_LINES = 5 # 允许的最大连续非数据行数
for line in lines:
stripped = line.strip()
if not stripped:
continue
# 检测表头行
if not in_table:
header_fields = ['保单年度', '保單年度', 'policy year', '年度', '受保人年龄', '受保人年齡']
has_header = any(f in stripped.lower() for f in header_fields)
# 需要至少有两个数值相关列名
value_cols = ['价值', '價值', '保费', '保費', '红利', '紅利', '分红', '分紅',
'赔偿', '賠償', 'surrender', 'premium', 'cash', 'bonus',
'benefit', 'value', '退保', '保证', '保證']
has_value = sum(1 for v in value_cols if v in stripped.lower()) >= 1
if has_header and has_value:
columns = _detect_columns(stripped)
if len(columns) >= 2:
in_table = True
consecutive_non_data = 0
logger.debug(f"检测到演示表头: {[(c[1], c[0]) for c in columns]}")
continue
# 跳过子表头行(如"保证 非保证 总额"等)
subheader_patterns = ['保证', '非保证', '总额', 'guaranteed', 'non-guaranteed', 'total']
if sum(1 for p in subheader_patterns if p in stripped.lower()) >= 2:
continue
# 尝试解析数据行
numbers = re.findall(r'[\d,]+(?:\.\d+)?', stripped)
if not numbers:
consecutive_non_data += 1
if consecutive_non_data >= MAX_NON_DATA_LINES:
break # 表格结束
continue
consecutive_non_data = 0
row = _parse_data_row(stripped, columns, numbers)
if row and (row.get('policy_year') is not None or row.get('age') is not None):
rows.append(row)
return rows
def _parse_data_row(
line: str, columns: list[tuple[int, str]], numbers: list[str]
) -> dict | None:
"""解析单行数据。"""
row = {}
# 提取第一个数字作为保单年度(通常是行首的小数字)
has_policy_year_column = any(field == 'policy_year' for _, field in columns)
year_match = re.match(r'^\s*(\d{1,3})\b', line) if has_policy_year_column else None
if year_match:
year_val = int(year_match.group(1))
if 0 < year_val <= 100:
row['policy_year'] = year_val
# 优先按表格槽位映射。制表符保留空槽;空格表格以两个以上空格分栏。
if columns:
cells = line.strip().split('\t') if '\t' in line else re.split(r'\s{2,}', line.strip())
max_slot = max(slot for slot, _ in columns)
if len(cells) == 1:
compact_cells = re.findall(
r'[\d,]+(?:\.\d+)?|[—–-]+|N/?A',
line,
re.IGNORECASE,
)
if len(compact_cells) > max_slot:
cells = compact_cells
if len(cells) > 1 and len(cells) > max_slot:
for slot, field in columns:
val = _parse_money(cells[slot])
if val is not None:
row[field] = val
if row.get('policy_year') is not None:
try:
row['policy_year'] = int(row['policy_year'])
except (TypeError, ValueError):
pass
else:
# 无可靠分栏信息时保留旧的顺序回退,供紧凑纯数字表使用。
value_columns = [(slot, f) for slot, f in columns if f != 'policy_year']
# 确定从哪个数字开始分配(跳过已识别的年度)
start_idx = 0
if 'policy_year' in row:
first_num = _parse_money(numbers[0]) if numbers else None
if first_num is not None and first_num == row['policy_year']:
start_idx = 1
elif first_num is not None and first_num > 100:
start_idx = 0
for i, num_str in enumerate(numbers[start_idx:], start=start_idx):
val = _parse_money(num_str)
if val is None:
continue
col_idx = i - start_idx
if col_idx < len(value_columns):
_, field = value_columns[col_idx]
row[field] = val
else:
# 没有列位置信息,按顺序猜测
all_fields = ['policy_year', 'total_premium_paid', 'guaranteed_cash_value',
'reversionary_bonus', 'terminal_dividend', 'total_surrender_value',
'death_benefit']
for i, num_str in enumerate(numbers):
if i >= len(all_fields):
break
val = _parse_money(num_str)
if val is not None:
row[all_fields[i]] = val
if row.get('age') is not None and not 0 <= row['age'] <= 150:
row['age'] = None
# 无明确年龄列时,保留带“岁”的年龄供上层结合投保年龄推算。
if 'policy_year' not in row and row.get('age') is None:
age_match = re.search(r'(\d{1,3})\s*岁', line)
if age_match:
row['age'] = int(age_match.group(1))
return row if row else None
def _derive_policy_years(rows: list[dict], insured_age: int | float | None) -> list[dict]:
"""仅在表格只有年龄列时,使用明确投保年龄推算保单年度。"""
if insured_age is None:
return rows
try:
issue_age = int(insured_age)
except (TypeError, ValueError):
return rows
ages = []
for row in rows:
try:
if row.get('policy_year') is None and row.get('age') is not None:
ages.append(int(row['age']))
except (TypeError, ValueError):
continue
if not ages:
return rows
first_table_age = min(ages)
if first_table_age not in (issue_age, issue_age + 1):
return rows
for row in rows:
if row.get('policy_year') is not None or row.get('age') is None:
continue
try:
derived = int(row['age']) - first_table_age + 1
except (TypeError, ValueError):
continue
if 1 <= derived <= 100:
row['policy_year'] = derived
row['policy_year_source'] = 'derived_from_age'
return rows
def _ensure_required_fields(row: dict) -> dict:
"""确保每行有所有必需字段。"""
required = {
'policy_year': None,
'age': None,
'total_premium_paid': None,
'guaranteed_cash_value': None,
'reversionary_bonus': None,
'terminal_dividend': None,
'total_surrender_value': None,
'death_benefit': None,
'guaranteed_account_value': None,
'non_guaranteed_account_value': None,
'non_guaranteed_cash_value': None,
'non_guaranteed_death_benefit': None,
'cost_of_insurance': None,
}
result = dict(required)
result.update(row)
return result
# ─── 提领表提取 ──────────────────────────────────────────
_WITHDRAWAL_HEADER_KEYWORDS = [
'提取', '提款', '提领', '提領', '领取', '領取',
'现金提取', '現金提取', '款项提取', '款項提取',
'withdrawal', 'cash withdrawal',
]
_WITHDRAWAL_REQUIRED_COLS = [
'价值', '價值', 'surrender', 'cash', 'value', '退保',
]
def _extract_withdrawal_rows(text: str) -> list[dict]:
"""从 PDF 文本中提取提领/提款演示表。
注意:只提取"总额"列,不取"保证"子列。
"""
rows = []
lines = text.split('\n')
in_table = False
columns: list[tuple[int, str]] = []
consecutive_non_data = 0
pending_header_lines = 0
MAX_NON_DATA_LINES = 5
for line in lines:
stripped = line.strip()
if not stripped:
continue
if not in_table:
has_header = any(kw in stripped for kw in _WITHDRAWAL_HEADER_KEYWORDS)
has_value = any(kw in stripped.lower() for kw in _WITHDRAWAL_REQUIRED_COLS)
if has_header:
pending_header_lines = 2
if (has_header or pending_header_lines > 0) and has_value:
candidate_columns = _detect_columns(stripped)
columns = candidate_columns
if len(columns) >= 2:
in_table = True
consecutive_non_data = 0
logger.debug(f"检测到提领表头: {[(c[1], c[0]) for c in columns]}")
pending_header_lines = 0
continue
if pending_header_lines > 0 and not has_header:
pending_header_lines -= 1
continue
# 跳过子表头("保证 非保证 总额"等)
subheader_pats = ['保证', '保證', '非保证', '非保證', '总额', '總額',
'guaranteed', 'non-guaranteed', 'total']
if sum(1 for p in subheader_pats if p in stripped.lower()) >= 2:
continue
numbers = re.findall(r'[\d,]+(?:\.\d+)?', stripped)
if not numbers:
consecutive_non_data += 1
if consecutive_non_data >= MAX_NON_DATA_LINES:
break
continue
consecutive_non_data = 0
row = _parse_withdrawal_row(stripped, columns, numbers)
if row and row.get('policy_year') is not None:
rows.append(row)
return rows
def _parse_withdrawal_row(
line: str, columns: list[tuple[int, str]], numbers: list[str]
) -> dict | None:
"""解析提领表单行。"""
row = {}
year_match = re.match(r'^\s*(\d{1,3})\b', line)
if year_match:
year_val = int(year_match.group(1))
if 0 < year_val <= 100:
row['policy_year'] = year_val
# 提领表标准字段(按优先级)
withdrawal_fields = [
'annual_withdrawal', 'total_withdrawn',
'surrender_value_before', 'surrender_value_after',
]
if columns:
value_columns = [(pos, f) for pos, f in columns if f != 'policy_year']
start_idx = 0
if 'policy_year' in row:
first_num = _parse_money(numbers[0]) if numbers else None
if first_num is not None and first_num == row['policy_year']:
start_idx = 1
for i, num_str in enumerate(numbers[start_idx:], start=start_idx):
val = _parse_money(num_str)
if val is None:
continue
col_idx = i - start_idx
if col_idx < len(value_columns):
_, field = value_columns[col_idx]
row[field] = val
else:
for i, num_str in enumerate(numbers):
if i == 0 and 'policy_year' in row:
continue
field_idx = i - 1 if 'policy_year' in row else i
if field_idx >= len(withdrawal_fields):
break
val = _parse_money(num_str)
if val is not None:
row[withdrawal_fields[field_idx]] = val
return row if row else None
def _ensure_withdrawal_fields(row: dict) -> dict:
"""确保提领行有所有必需字段。"""
required = {
'policy_year': None,
'annual_withdrawal': None,
'total_withdrawn': None,
'surrender_value_before': None,
'surrender_value_after': None,
}
result = dict(required)
result.update(row)
return result
# ─── 产品类型识别 ─────────────────────────────────────────
def _detect_product_type(text: str, benefit_rows: list[dict]) -> str:
"""从文本和提取数据推断产品类型savings / ci / iul。"""
text_lower = text.lower()
# 文本关键词判断
ci_keywords = ['危疾', '重疾', '重大疾病', 'critical illness', 'ci plan',
'严重疾病', '危疾保障', 'dread disease']
iul_keywords = ['iul', 'index universal', 'universal life', '指数型万用',
'指數型萬用', '万用寿险', '萬用壽險', '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
# policy 字段判断
if re.search(r'(?:保额|投保额|sum\s*insured|保額)\s*[:]\s*[\d,]+', text_lower):
ci_score += 1
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:
"""从 PDF 原始文本中用纯正则提取结构化保险数据。
Args:
pdf_text: PDF 提取的原始文本
Returns:
符合 extraction schema 的 dict字段缺失填 null
"""
product_name = _extract_product_name(pdf_text) or 'unknown'
currency = _extract_currency(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)
coverage_period = _extract_coverage_period(pdf_text)
# 提取利益演示表
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'))
# 提取提领表
withdrawal_rows = _extract_withdrawal_rows(pdf_text)
# 产品类型由上层 infer_plan_type() 推断(基于实际提取数据),此处不重复判断
# 确保字段完整
benefit_rows = [_ensure_required_fields(r) for r in benefit_rows]
withdrawal_rows = [_ensure_withdrawal_fields(r) for r in withdrawal_rows]
# 按年度排序并去重
seen_years = set()
deduped = []
for row in sorted(benefit_rows, key=lambda r: r.get('policy_year') or 0):
year = row.get('policy_year')
if year is not None and year not in seen_years:
seen_years.add(year)
deduped.append(row)
benefit_rows = deduped
# 提领表去重
seen_wd_years = set()
deduped_wd = []
for row in sorted(withdrawal_rows, key=lambda r: r.get('policy_year') or 0):
year = row.get('policy_year')
if year is not None and year not in seen_wd_years:
seen_wd_years.add(year)
deduped_wd.append(row)
withdrawal_rows = deduped_wd
# 提取保额(如果有)
sum_insured, sum_insured_label = _extract_labeled_money(
pdf_text,
["投保时保额", "投保時保額", "Sum Insured", "Sum Assured", "Face Amount"],
)
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,
["基本计划年保费", "基本計劃年保費", "Basic Plan Annual Premium"],
)
basic_sum_insured, basic_sum_label = _extract_labeled_money(
pdf_text,
[
"投保时基本金额", "投保時基本金額",
"基本计划名义金额", "基本計劃名義金額",
"Basic Sum Assured", "Basic Notional Amount",
],
)
first_year_amount_due, first_year_label = _extract_labeled_money(
pdf_text,
[
"投保时年缴总保费", "投保時年繳總保費",
"首年应缴金额", "首年應繳金額", "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}, "
f"currency={currency}, premium={annual_premium}, "
f"age={insured_info.get('age')}, benefit_rows={len(benefit_rows)}, "
f"withdrawal_rows={len(withdrawal_rows)}"
)
data = {
'product_name': product_name,
# 保持底层提取器中立;险种由 infer_plan_type 或文件名先验在上层确定。
'product_type': None,
'insured': {
'name': None,
'age': insured_info.get('age'),
'gender': insured_info.get('gender'),
'relation': None,
'smoker': smoker,
},
'policy': {
'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,
'first_year_amount_due_source_label': first_year_label,
'premium_payment_period': premium_period,
'coverage_period': coverage_period,
},
'benefit_illustration': benefit_rows,
'withdrawal_illustration': withdrawal_rows,
'sales_insights': None,
}
# CI 产品:提取保障项目(如果文本包含 CI 关键词)
coverage_items = _extract_coverage_items(pdf_text)
if coverage_items:
data['coverage_items'] = coverage_items
# IUL 产品:提取指数账户(如果文本包含 IUL 关键词)
index_accounts = _extract_index_accounts(pdf_text)
if index_accounts:
data['index_accounts'] = index_accounts
return data
def _extract_coverage_items(text: str) -> list[dict]:
"""提取危疾保险的保障项目。"""
items = []
# 匹配 "保障项目 赔付金额" 格式的表
lines = text.split('\n')
in_section = False
for line in lines:
stripped = line.strip()
if not stripped:
continue
if '保障项目' in stripped or '保障範圍' in stripped or 'coverage' in stripped.lower():
in_section = True
continue
if in_section:
# 尝试匹配 "项目名 金额" 格式
m = re.match(r'^(.{2,20})\s+[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)', stripped)
if m:
label = m.group(1).strip()
amount = _parse_money(m.group(2))
if amount and label:
items.append({'label': label, 'amount': amount, 'percentage': None, 'description': None})
elif not re.search(r'\d', stripped):
break # 非数字行,可能到了下一节
return items
def _extract_index_accounts(text: str) -> list[dict]:
"""提取 IUL 指数账户信息。"""
accounts = []
# 匹配 "账户名 配置比例 利率" 格式
patterns = [
r'(S&P\s*500|Hang\s*Seng|恒生指数|Global\s*index|指数\d?)\s+([\d.]+)%?\s+([\d.]+)%?\s+([\d.]+)%?',
r'(S&P\s*500|Hang\s*Seng|恒生指数|Global\s*index)\s+([\d.]+)%',
]
for p in patterns:
for m in re.finditer(p, text, re.IGNORECASE):
account = {
'name': m.group(1).strip(),
'allocation': float(m.group(2)),
'current_rate': float(m.group(3)) if m.lastindex >= 3 else None,
'guaranteed_floor': float(m.group(4)) if m.lastindex >= 4 else None,
}
accounts.append(account)
return accounts
def count_benefit_rows(data: dict) -> int:
"""统计提取到的利益演示行数。"""
rows = data.get('benefit_illustration', [])
if not isinstance(rows, list):
return 0
return len(rows)