baodan/api/insurance/ppt/regex_extractor.py

1592 lines
65 KiB
Python
Raw Normal View History

2026-07-28 16:45:14 +08:00
"""纯正则 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"
2026-07-31 09:00:39 +08:00
注意: 0 是合法值如红利为 0 仅空字符串和无法解析返回 None
2026-07-28 16:45:14 +08:00
"""
if not s:
return None
# 去掉货币符号和空白
s = re.sub(r'[Uu][Ss]\$|[Hh][Kk]\$|[Cc][Nn][Yy]?¥|¥|\$|\s', '', s.strip())
# 去掉逗号
s = s.replace(',', '')
try:
2026-07-31 09:00:39 +08:00
return float(s)
2026-07-28 16:45:14 +08:00
except (ValueError, TypeError):
return None
# ─── 产品信息提取 ──────────────────────────────────────────
def _extract_product_name(text: str) -> str | None:
2026-07-31 09:00:39 +08:00
"""提取产品名称。
支持带标签格式计划书标题繁体英文缩写
"""
2026-07-28 16:45:14 +08:00
patterns = [
2026-07-31 09:00:39 +08:00
# 带标签
2026-07-28 16:45:14 +08:00
r'(?:产品名称|产品名|产品|保单名称)\s*[:]\s*([^\n,]+)',
r'(?:Product(?:\s+Name)?|Plan(?:\s+Name)?)\s*[:]\s*([^\n,]+)',
r'(?:建议书|建議書|計劃書|計劃名稱|保障計劃|保障计划)\s*[:]\s*([^\n,]+)',
r'(?:Proposal)\s*[:]\s*([^\n,]+)',
2026-07-31 09:00:39 +08:00
# 繁体
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|$)',
2026-07-28 16:45:14 +08:00
]
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
2026-07-31 09:00:39 +08:00
def _extract_currency(text: str) -> str | None:
"""提取保单货币,支持多币种(优先匹配保费行中的币种符号)。
未识别时返回 None由用户或后续流程确认不再默认 USD
"""
2026-07-28 16:45:14 +08:00
# 按优先级搜索
patterns = [
主要完成内容: 修复 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
(r'(?:保单货币|保费货币|Currency)\s*[:]\s*(USD|USB|HKD|CNY|RMB|SGD|GBP|EUR)', 'direct'),
2026-07-28 16:45:14 +08:00
(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'
主要完成内容: 修复 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
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
)
主要完成内容: 修复 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
match = re.search(
rf"({label_pattern})\s*[:]?\s*(?:[A-Z]{{2,3}}|[¥¥$£€]|US\$|HK\$)?\s*([\d,]+(?:\.\d+)?)",
主要完成内容: 修复 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
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"(?:非吸烟者|非吸煙者|不吸烟|不吸煙)",
主要完成内容: 修复 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
]
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"
2026-07-31 09:00:39 +08:00
return None
2026-07-28 16:45:14 +08:00
def _extract_annual_premium(text: str) -> float | None:
"""提取年缴保费。"""
patterns = [
主要修复: 海报加载失败根因:html-to-image 给 blob: 底图地址追加缓存参数,导致地址失效。现已关闭该行为。 合成失败后再次点击“重新生成”,会复用已有 AI 底图,只重试浏览器合成和上传,避免重复调用 AI。 增加底图加载、图表超时、导出失败、尺寸越界等分阶段错误提示。 修复计划书 (cid:数字) 字体乱码被误判为正常文本的问题,现在会正确转入 OCR。 增加繁体中文 OCR 运行支持。 补齐 SIUL 文件名中的确定字段,并且不会覆盖正文已识别数据。 增加“首期规划保费/償還至形成基金所需保費”等保费标签识别。 修正 IUL 年龄、保额、退保价值、缴费期、公司信息等字段映射。 LLM 返回空对象或缺字段时不再视为成功。 增加错误利益数值和年龄/保单年度错位校验。 修复依赖版本降级导致 API/Worker 无法启动的风险。 真实计划书复验结果: 产品:Manulife SIUL 3 投保年龄:48 岁 性别:女性 吸烟状态:非吸烟 币种:USD 基本保额:3,000,000 年缴保费:80,060 缴费期:5 年 利益演示:识别到 10 行 验证结果: 后端相关回归测试:91 passed 前端生产构建:通过 API、数据库、Redis、存储、数据表健康检查:全部正常 Celery Worker:已重启并连接 Redis 真实 PDF:确认进入 OCR,不再使用 (cid:...) 乱码 前端构建目录由运行容器挂载,修复已生效
2026-07-31 23:35:45 +08:00
r'(?:首期规划保费|首期規劃保費|偿还至形成基金所需保费|償還至形成基金所需保費)\s*[:]?\s*(?:[A-Z]{2,3})?\$?\s*([\d,]+(?:\.\d+)?)',
2026-07-31 09:00:39 +08:00
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+)?)',
2026-07-28 16:45:14 +08:00
]
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:
2026-07-31 09:00:39 +08:00
"""提取被保人信息(年龄、性别)。
2026-07-28 16:45:14 +08:00
2026-07-31 09:00:39 +08:00
支持简体/繁体英文出生日期推算
使用"受保人上下文窗口"避免误取利益表中的年龄数字
"""
info = {'age': None, 'gender': None, 'age_source': None}
# ── 年龄:优先匹配带标签的明确年龄 ──
2026-07-28 16:45:14 +08:00
age_patterns = [
2026-07-31 09:00:39 +08:00
# 简体
r'(?:受保人|被保人|投保时|投保人)?\s*年龄\s*[:]\s*(\d{1,3})\s*岁?',
2026-07-28 16:45:14 +08:00
r'(?:受保人|被保人)\s*[:]?\s*(?:[^\n,]{0,10})?(\d{1,3})\s*岁',
2026-07-31 09:00:39 +08:00
# 繁体
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})',
2026-07-28 16:45:14 +08:00
]
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
2026-07-31 09:00:39 +08:00
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
2026-07-28 16:45:14 +08:00
break
2026-07-31 09:00:39 +08:00
# ── 性别 ──
2026-07-28 16:45:14 +08:00
gender_patterns = [
2026-07-31 09:00:39 +08:00
r'(?:受保人|被保人|投保人)?\s*性[别別]?\s*[:]\s*(男|女|Male|Female|M|F)',
2026-07-28 16:45:14 +08:00
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'):
2026-07-31 09:00:39 +08:00
info['gender'] = 'male'
2026-07-28 16:45:14 +08:00
elif raw in ('', 'F', 'FEMALE'):
2026-07-31 09:00:39 +08:00
info['gender'] = 'female'
2026-07-28 16:45:14 +08:00
break
return info
2026-07-31 09:00:39 +08:00
def _extract_premium_payment_period(text: str) -> int | str | None:
"""提取保费缴付年期。返回 int年数'整付',不再返回 '5年' 等字符串。"""
2026-07-28 16:45:14 +08:00
patterns = [
2026-07-31 09:00:39 +08:00
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*年',
2026-07-28 16:45:14 +08:00
]
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:
2026-07-31 09:00:39 +08:00
return years
2026-07-28 16:45:14 +08:00
except ValueError:
pass
return None
def _extract_coverage_period(text: str) -> str | None:
"""提取保障年期。"""
patterns = [
2026-07-31 09:00:39 +08:00
r'(?:保障年期|保障期|保障年期|Coverage\s+(?:Period|Term))\s*[:]\s*([^\n,]+)',
r'(?:终身保障|保障至终身|终身|Whole\s+Life|終身)',
2026-07-28 16:45:14 +08:00
r'(?:保障至|保至)\s*(\d+)\s*岁',
2026-07-31 09:00:39 +08:00
r'(?:保障至|保至)\s*(\d+)\s*歲',
2026-07-28 16:45:14 +08:00
]
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', # 简称
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'受保人年龄': 'age', '受保人年齡': 'age',
'年龄': 'age', '年齡': 'age',
'insured age': 'age', 'age': 'age',
2026-07-28 16:45:14 +08:00
# 已缴总保费
'缴付保费总额': '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',
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'累计保费': 'total_premium_paid', '累計保費': 'total_premium_paid',
2026-07-28 16:45:14 +08:00
'已缴保费总额': 'total_premium_paid', '缴付保费': 'total_premium_paid',
2026-07-31 09:00:39 +08:00
'繳付保費總額': 'total_premium_paid', '已繳保費': 'total_premium_paid',
'累積已付保費': 'total_premium_paid', '已付保費總額': 'total_premium_paid',
2026-07-28 16:45:14 +08:00
# 保证现金价值
'保证现金价值': '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',
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'非保证现金价值': 'non_guaranteed_cash_value',
'非保證現金價值': 'non_guaranteed_cash_value',
'非保证身故赔偿': 'non_guaranteed_death_benefit',
'非保證身故賠償': 'non_guaranteed_death_benefit',
2026-07-28 16:45:14 +08:00
'non-guaranteed account value': 'non_guaranteed_account_value',
'guaranteed account value': 'guaranteed_account_value',
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'non-guaranteed cash value': 'non_guaranteed_cash_value',
'non-guaranteed death benefit': 'non_guaranteed_death_benefit',
2026-07-28 16:45:14 +08:00
'保险成本': '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]]:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
"""检测表头槽位和字段名。返回 [(slot_index, field_name), ...]。"""
2026-07-28 16:45:14 +08:00
columns = []
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
# 表头和数据行都按槽位解析,避免数据中的“—”被数字正则丢弃后整行左移。
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):
2026-07-28 16:45:14 +08:00
field = _normalize_column_name(part)
if field:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
columns.append((slot_index, field))
2026-07-28 16:45:14 +08:00
# 如果没有检测到列,尝试逐个关键词搜索
if len(columns) < 2:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
matches = []
2026-07-28 16:45:14 +08:00
for alias, field in _COLUMN_ALIASES.items():
idx = header_line.lower().find(alias)
if idx >= 0:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
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))
2026-07-28 16:45:14 +08:00
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:
2026-07-31 09:00:39 +08:00
header_fields = ['保单年度', '保單年度', 'policy year', '年度', '受保人年龄', '受保人年齡']
2026-07-28 16:45:14 +08:00
has_header = any(f in stripped.lower() for f in header_fields)
# 需要至少有两个数值相关列名
2026-07-31 09:00:39 +08:00
value_cols = ['价值', '價值', '保费', '保費', '红利', '紅利', '分红', '分紅',
'赔偿', '賠償', 'surrender', 'premium', 'cash', 'bonus',
'benefit', 'value', '退保', '保证', '保證']
2026-07-28 16:45:14 +08:00
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)
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
if row and (row.get('policy_year') is not None or row.get('age') is not None):
2026-07-28 16:45:14 +08:00
rows.append(row)
return rows
def _parse_data_row(
line: str, columns: list[tuple[int, str]], numbers: list[str]
) -> dict | None:
"""解析单行数据。"""
row = {}
# 提取第一个数字作为保单年度(通常是行首的小数字)
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
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
2026-07-28 16:45:14 +08:00
if year_match:
year_val = int(year_match.group(1))
if 0 < year_val <= 100:
row['policy_year'] = year_val
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
# 优先按表格槽位映射。制表符保留空槽;空格表格以两个以上空格分栏。
2026-07-28 16:45:14 +08:00
if columns:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
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
2026-07-28 16:45:14 +08:00
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
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
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:
2026-07-28 16:45:14 +08:00
age_match = re.search(r'(\d{1,3})\s*岁', line)
if age_match:
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
row['age'] = int(age_match.group(1))
2026-07-28 16:45:14 +08:00
return row if row else None
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
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
2026-07-28 16:45:14 +08:00
def _ensure_required_fields(row: dict) -> dict:
"""确保每行有所有必需字段。"""
required = {
'policy_year': None,
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'age': None,
2026-07-28 16:45:14 +08:00
'total_premium_paid': None,
'guaranteed_cash_value': None,
'reversionary_bonus': None,
'terminal_dividend': None,
'total_surrender_value': None,
'death_benefit': None,
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
'guaranteed_account_value': None,
'non_guaranteed_account_value': None,
'non_guaranteed_cash_value': None,
'non_guaranteed_death_benefit': None,
'cost_of_insurance': None,
2026-07-28 16:45:14 +08:00
}
result = dict(required)
result.update(row)
return result
# ─── 提领表提取 ──────────────────────────────────────────
_WITHDRAWAL_HEADER_KEYWORDS = [
主要改动: 修正 benefit_illustration JSON 契约,兼容安全的数组返回格式,并把具体缺失字段反馈给 LLM 重试。[llm_client.py (line 184)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:184) LLM 利益表失败时保留有效正则结果,不再把已有数据全部丢掉。[extraction.py (line 1237)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1237) Female、Male、男女等误识别结果会用用户选择的产品名纠正。[extraction.py (line 521)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:521) 解析缓存升级至 v7,旧错误缓存自动失效。 新增真正的“退保价值”页签,数据来自利益演示中的年度退保价值,不再错误依赖提领数据。[PptDataReview.vue (line 318)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:318) “提领方案”继续作为独立可选情景;0 行不再显示待校对,也不会影响生成 PPT。[validator.py (line 98)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/validator.py:98) 提领表识别支持跨行标题及“提领/领取”等名称。[regex_extractor.py (line 619)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:619) 补充了对应回归测试。[ppt_poster_optimization_test.py](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) 验证结果: PPT/解析专项测试:51 passed, 1 skipped 新增问题回归测试:32 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过
2026-08-01 01:56:33 +08:00
'提取', '提款', '提领', '提領', '领取', '領取',
'现金提取', '現金提取', '款项提取', '款項提取',
2026-07-28 16:45:14 +08:00
'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
主要改动: 修正 benefit_illustration JSON 契约,兼容安全的数组返回格式,并把具体缺失字段反馈给 LLM 重试。[llm_client.py (line 184)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:184) LLM 利益表失败时保留有效正则结果,不再把已有数据全部丢掉。[extraction.py (line 1237)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1237) Female、Male、男女等误识别结果会用用户选择的产品名纠正。[extraction.py (line 521)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:521) 解析缓存升级至 v7,旧错误缓存自动失效。 新增真正的“退保价值”页签,数据来自利益演示中的年度退保价值,不再错误依赖提领数据。[PptDataReview.vue (line 318)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:318) “提领方案”继续作为独立可选情景;0 行不再显示待校对,也不会影响生成 PPT。[validator.py (line 98)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/validator.py:98) 提领表识别支持跨行标题及“提领/领取”等名称。[regex_extractor.py (line 619)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:619) 补充了对应回归测试。[ppt_poster_optimization_test.py](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) 验证结果: PPT/解析专项测试:51 passed, 1 skipped 新增问题回归测试:32 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过
2026-08-01 01:56:33 +08:00
pending_header_lines = 0
2026-07-28 16:45:14 +08:00
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)
主要改动: 修正 benefit_illustration JSON 契约,兼容安全的数组返回格式,并把具体缺失字段反馈给 LLM 重试。[llm_client.py (line 184)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:184) LLM 利益表失败时保留有效正则结果,不再把已有数据全部丢掉。[extraction.py (line 1237)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1237) Female、Male、男女等误识别结果会用用户选择的产品名纠正。[extraction.py (line 521)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:521) 解析缓存升级至 v7,旧错误缓存自动失效。 新增真正的“退保价值”页签,数据来自利益演示中的年度退保价值,不再错误依赖提领数据。[PptDataReview.vue (line 318)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:318) “提领方案”继续作为独立可选情景;0 行不再显示待校对,也不会影响生成 PPT。[validator.py (line 98)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/validator.py:98) 提领表识别支持跨行标题及“提领/领取”等名称。[regex_extractor.py (line 619)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:619) 补充了对应回归测试。[ppt_poster_optimization_test.py](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) 验证结果: PPT/解析专项测试:51 passed, 1 skipped 新增问题回归测试:32 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过
2026-08-01 01:56:33 +08:00
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
2026-07-28 16:45:14 +08:00
if len(columns) >= 2:
in_table = True
consecutive_non_data = 0
logger.debug(f"检测到提领表头: {[(c[1], c[0]) for c in columns]}")
主要改动: 修正 benefit_illustration JSON 契约,兼容安全的数组返回格式,并把具体缺失字段反馈给 LLM 重试。[llm_client.py (line 184)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:184) LLM 利益表失败时保留有效正则结果,不再把已有数据全部丢掉。[extraction.py (line 1237)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1237) Female、Male、男女等误识别结果会用用户选择的产品名纠正。[extraction.py (line 521)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:521) 解析缓存升级至 v7,旧错误缓存自动失效。 新增真正的“退保价值”页签,数据来自利益演示中的年度退保价值,不再错误依赖提领数据。[PptDataReview.vue (line 318)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:318) “提领方案”继续作为独立可选情景;0 行不再显示待校对,也不会影响生成 PPT。[validator.py (line 98)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/validator.py:98) 提领表识别支持跨行标题及“提领/领取”等名称。[regex_extractor.py (line 619)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:619) 补充了对应回归测试。[ppt_poster_optimization_test.py](D:/work/code/python/coding/baodanagent/tests/ppt_poster_optimization_test.py) 验证结果: PPT/解析专项测试:51 passed, 1 skipped 新增问题回归测试:32 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过
2026-08-01 01:56:33 +08:00
pending_header_lines = 0
continue
if pending_header_lines > 0 and not has_header:
pending_header_lines -= 1
2026-07-28 16:45:14 +08:00
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']
2026-07-28 16:45:14 +08:00
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
2026-07-28 16:45:14 +08:00
# 数据特征判断
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
2026-07-28 16:45:14 +08:00
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'
2026-07-28 16:45:14 +08:00
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 {}
2026-07-28 16:45:14 +08:00
# ─── 主提取函数 ──────────────────────────────────────────
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)
2026-07-28 16:45:14 +08:00
insured_info = _extract_insured_info(pdf_text)
主要完成内容: 修复 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
smoker = _extract_smoker(pdf_text)
2026-07-28 16:45:14 +08:00
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
主要改动: PPT 表格现在按槽位解析,正确保留 —、空列和单空格表格,不再发生金额左移。[regex_extractor.py (line 470)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/regex_extractor.py:470) 补齐年龄、累计保费、非保证现金价值、非保证身故赔偿等别名。 IUL 使用专属 LLM 提取结构,并加强年度、年龄、账户价值完整性校验。[extraction.py (line 741)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:741) PPT 缓存升级到 v6,旧错误缓存会自动失效。 提领方案不再与普通退保价值混淆,相关提示语已统一。 海报摘要年度金额与收益图表改为同一数据源;缺少部分里程碑时会从原始真实年度补足,不会隐藏图表。[content_builder.py (line 51)](D:/work/code/python/coding/baodanagent/api/insurance/poster/content_builder.py:51) 系统计算、利益表派生、人工修改增加来源标记。 增加“重新解析”功能,旧海报计划书无需重新上传。[routes.py (line 266)](D:/work/code/python/coding/baodanagent/api/insurance/poster/routes.py:266) ECharts 导出增加双帧就绪检测和事件竞态保护,不再依赖不存在的 .once()。 PPT 手机端改为可编辑数据卡片,320px 操作栏自动纵向排列;补齐按钮语义、键盘焦点及 44px 触控区域。[PptDataReview.vue (line 195)](D:/work/code/python/coding/baodanagent/frontend/src/pages/components/ppt/PptDataReview.vue:195) 验证结果: 核心专项测试:145 passed 除既有聊天日志测试外的测试集:236 passed 前端生产构建:通过 Python 语法检查:通过 git diff --check:通过 Impeccable 前端检测:无发现 全量测试仅剩一个与本次无关的既有失败:test_chat_logs_query 缺少 Flask application context
2026-08-01 01:22:07 +08:00
benefit_rows = _derive_policy_years(benefit_rows, insured_info.get('age'))
2026-07-28 16:45:14 +08:00
# 提取提领表
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"],
2026-07-28 16:45:14 +08:00
)
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]
2026-07-28 16:45:14 +08:00
主要完成内容: 修复 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
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",
],
主要完成内容: 修复 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
)
first_year_amount_due, first_year_label = _extract_labeled_money(
pdf_text,
[
"投保时年缴总保费", "投保時年繳總保費",
"首年应缴金额", "首年應繳金額", "First Year Amount Due",
],
主要完成内容: 修复 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
)
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)
主要完成内容: 修复 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
2026-07-28 16:45:14 +08:00
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,
2026-07-28 16:45:14 +08:00
'insured': {
'name': None,
'age': insured_info.get('age'),
'gender': insured_info.get('gender'),
'relation': None,
主要完成内容: 修复 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
'smoker': smoker,
2026-07-28 16:45:14 +08:00
},
'policy': {
'product_name': product_name,
'currency': currency,
'sum_insured': sum_insured,
'sum_insured_source_label': sum_insured_label,
主要完成内容: 修复 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
'basic_sum_insured': basic_sum_insured,
'basic_sum_insured_source_label': basic_sum_label,
2026-07-28 16:45:14 +08:00
'annual_premium': annual_premium,
'annual_premium_source_label': annual_premium_label,
主要完成内容: 修复 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
'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,
2026-07-28 16:45:14 +08:00
'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)