baodan/api/insurance/ppt/regex_extractor.py
wsb1224 b8c4e8b672 主要完成内容:
修复 PPT 异步任务无法生成的问题,包括任务变量引用错误、失败状态回写、心跳缺失任务恢复。
脱敏改为保司/产品后台统一配置,生成端不再让用户选择;任务创建时保存策略快照。
保司支持独立控制 PPT、海报 Logo 显示。
PPT 核验新增吸烟状态、币种及三个条件字段。
利益演示、退保提取调整为警告,不再阻止生成。
PPT 生成完成后可以直接返回数据核验页修改。
建立不同险种、单图/长图共六套海报字段画像。
PPT“生成场景”支持后台新增、启停和删除。
保司、产品、PPT 模板、文案模板均支持安全删除。
内置模板禁止删除,只允许停用;存在关联数据时拒绝危险删除。
补充策略变更及删除审计日志。
更新 API 文档、部署文档及修复计划实施记录。
关键交付文件:
[数据库迁移 migrate_027.py](D:/work/code/python/coding/baodanagent/api/insurance/db/migrate_027.py)
[海报字段画像 field_profiles.py](D:/work/code/python/coding/baodanagent/api/insurance/poster/field_profiles.py)
[动态场景服务 scenarios.py](D:/work/code/python/coding/baodanagent/api/insurance/ppt/scenarios.py)
[新增回归测试](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py)
[优化修复计划书](D:/work/code/python/coding/baodanagent/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md)
验证结果:
核心链路测试:37 passed,1 skipped
扩展回归测试:140 passed
PPT 渲染器测试:6 passed
前端生产构建:通过
Python 编译检查:通过
完整测试集:190 passed,1 failed
唯一失败为 tests/test_chat_save.py::test_chat_logs_query 未建立 Flask application context,与本次 PPT/海报链路无关。
2026-07-31 14:10:24 +08:00

878 lines
35 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(re.escape(label) 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)",
]
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*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)',
r'(?:Annual\s+Premium|每年(?:缴付)?保费|年缴保费)\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', # 简称
'受保人年龄': 'policy_year', # 有些表用年龄代替年度
'受保人年齡': 'policy_year',
'age': 'policy_year',
# 已缴总保费
'缴付保费总额': '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',
# 保证现金价值
'保证现金价值': '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 account value': 'non_guaranteed_account_value',
'guaranteed account value': 'guaranteed_account_value',
'保险成本': '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]]:
"""检测表头行的列位置和字段名。返回 [(start_pos, field_name), ...]。"""
columns = []
# 尝试按多个空格或制表符分割
parts = re.split(r'\s{2,}|\t', header_line)
pos = 0
for part in parts:
field = _normalize_column_name(part)
if field:
columns.append((pos, field))
pos += len(part) + 2 # 近似位置
# 如果没有检测到列,尝试逐个关键词搜索
if len(columns) < 2:
for alias, field in _COLUMN_ALIASES.items():
idx = header_line.lower().find(alias)
if idx >= 0:
# 避免重复
if not any(f == field for _, f in columns):
columns.append((idx, 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:
rows.append(row)
return rows
def _parse_data_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
# 用列位置映射数值
if columns:
# 获取非 policy_year 的列(按原始顺序)
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 # 跳过年度对应的数字
# 如果第一个数字是大的金额如50000说明行首没有年度列
elif first_num is not None and first_num > 100:
start_idx = 0 # 从头开始分配给 value_columns
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 'policy_year' not in row:
age_match = re.search(r'(\d{1,3})\s*岁', line)
if age_match:
age = int(age_match.group(1))
row['_age'] = age # 留给上层推算
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 _ensure_required_fields(row: dict) -> dict:
"""确保每行有所有必需字段。"""
required = {
'policy_year': None,
'total_premium_paid': None,
'guaranteed_cash_value': None,
'reversionary_bonus': None,
'terminal_dividend': None,
'total_surrender_value': None,
'death_benefit': 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
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 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_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', '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)
# 数据特征判断
for row in benefit_rows:
if row.get('account_value') or row.get('non_guaranteed_account_value'):
iul_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 ci_score > 0:
return 'ci'
return 'savings'
# ─── 主提取函数 ──────────────────────────────────────────
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 = _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)
# 提取提领表
withdrawal_rows = _extract_withdrawal_rows(pdf_text)
# 产品类型由上层 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]
# 按年度排序并去重
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 = None
si_match = re.search(
r'(?:保额|投保额|保額|sum\s*insured)\s*[:]\s*[\$UuSsHhKk]*\s*([\d,]+(?:\.\d+)?)',
pdf_text, re.IGNORECASE,
)
if si_match:
sum_insured = _parse_money(si_match.group(1))
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"],
)
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,
'product_type': None, # 由上层 infer_plan_type() 基于实际数据推断
'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,
'basic_sum_insured': basic_sum_insured,
'basic_sum_insured_source_label': basic_sum_label,
'annual_premium': annual_premium,
'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)