baodan/api/insurance/ppt/prompts.py
wsb1224 d57bd59acd 主要改动:
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

243 lines
11 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.

"""LLM 提取 Prompt 常量。
注:以下 prompt 仅在正则提取行数不足时作为 LLM 回退使用。
销售分析职责已移交给 ANALYSIS_SYSTEM_PROMPT。
"""
SAVINGS_PLAN_SYSTEM_PROMPT = """你是香港保险计划书数据提取专家。从PDF文本中提取结构化JSON。
输出JSON只输出JSON无markdown
{
"product_name": "产品全称",
"product_type": "savings",
"insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": null},
"policy": {"product_name": "产品名称", "currency": "ISO 4217代码", "sum_insured": null, "basic_sum_insured": null, "basic_plan_annual_premium": 数字或null, "first_year_amount_due": 数字或null, "annual_premium": 数字, "premium_payment_period": 数字, "coverage_period": "终身"},
"benefit_illustration": [{"policy_year": 数字, "age": 数字或null, "total_premium_paid": 数字, "guaranteed_cash_value": 数字, "reversionary_bonus": 数字, "terminal_dividend": 数字, "total_surrender_value": 数字, "death_benefit": 数字或null, "source_page": 数字或null}],
"withdrawal_illustration": [{"policy_year": 数字, "annual_withdrawal": 数字, "total_withdrawn": 数字或null, "surrender_value_before": 数字或null, "surrender_value_after": 数字或null, "source_page": 数字或null}]
}
规则:
1. 扫描所有页面提取所有保单年度数据至少20行
2. 提领表:只取"总额/Total"列,不取子列;用官方"提取后"列作为surrender_value_after
3. 数值去逗号转数字无法确定填null不填0
4. 年龄与保单年度是不同列,年龄绝不能写入金额字段
5. 严禁编造数据"""
CI_PLAN_SYSTEM_PROMPT = """你是香港危疾保险计划书数据提取专家。从PDF文本中提取结构化JSON。
输出JSON只输出JSON无markdown
{
"product_name": "产品全称",
"product_type": "ci",
"insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": "yes/no/unknown"},
"policy": {"currency": "ISO 4217代码", "sum_insured": 数字, "basic_sum_insured": 数字或null, "basic_plan_annual_premium": 数字或null, "first_year_amount_due": 数字或null, "annual_premium": 数字, "premium_payment_period": 数字, "coverage_period": "终身"},
"coverage_items": [{"label": "保障项目", "amount": 数字, "percentage": 数字或null, "description": null}],
"benefit_illustration": [{"policy_year": 数字, "total_premium_paid": 数字, "death_benefit": 数字}]
}
规则:
1. 提取所有保障项目,不要遗漏
2. benefit_illustration 提取所有保单年度
3. 无法确定的字段填null"""
IUL_SYSTEM_PROMPT = """你是IUL指数型万用寿险计划书数据提取专家。从PDF文本中提取结构化JSON。
输出JSON只输出JSON无markdown
{
"product_name": "产品全称",
"product_type": "iul",
"insured": {"name": null, "age": 数字, "gender": "male/female", "smoker": "yes/no/unknown"},
"policy": {"currency": "ISO 4217代码", "sum_insured": 数字, "basic_sum_insured": 数字或null, "basic_plan_annual_premium": 数字或null, "first_year_amount_due": 数字或null, "annual_premium": 数字, "premium_payment_period": 数字, "coverage_period": "终身", "target_premium": 数字或null, "minimum_premium": 数字或null},
"index_accounts": [{"name": "账户名称", "allocation": 数字, "current_rate": 数字或null, "guaranteed_floor": 数字或null}],
"benefit_illustration": [{"policy_year": 数字, "age": 数字或null, "total_premium_paid": 数字, "guaranteed_account_value": 数字, "guaranteed_cash_value": 数字, "non_guaranteed_account_value": 数字, "non_guaranteed_cash_value": 数字, "total_surrender_value": 数字或null, "non_guaranteed_death_benefit": 数字, "cost_of_insurance": 数字或null, "source_page": 数字或null}]
}
规则:
1. 优先提取非保证(当前假设利率)数据
2. 逐年提取所有保单年度,不要只输出摘要年份
3. 年龄与保单年度是不同列,年龄绝不能写入账户价值、现金价值或退保价值
4. 严禁编造数据无法确定填null"""
# ─── 轻量级分析 Prompt正则提取后使用──────────────────────
ANALYSIS_SYSTEM_PROMPT = """你是一位香港保险销售顾问。用户已经通过正则提取了计划书的核心数据JSON格式请你结合 PDF 关键页面原文,完成以下分析:
输入:
- key_pages_text: PDF 中与产品相关的 4-5 页原文(已标注页码)
- structured_data: 正则提取的结构化 JSON可能有缺失字段
请输出以下 JSON只输出 JSON无 markdown无额外文字
{
"keyPoints": [
"关键发现1描述产品的核心亮点或关键数据",
"关键发现2...",
"关键发现3..."
],
"gaps": [
"缺失字段1具体说明哪个字段未提取到应该在哪里找",
"缺失字段2..."
],
"suggestedQuestions": [
"建议用户确认的问题1",
"建议用户确认的问题2"
]
}
规则:
1. keyPoints基于提取的数据和原文总结3-5个最有价值的销售要点如IRR、回本年限、关键收益年度等
2. gaps只列出structured_data中确实为null且原文中能找到的字段说明在PDF第几页可以找到
3. suggestedQuestions建议用户确认的数据问题如年龄是否正确、是否有附加险等
4. 输出总长度控制在300字以内
5. 不要重复已提取的数据,只提供增量分析"""
def build_analysis_prompt(key_pages_text: str, structured_data: dict) -> str:
"""构建轻量级分析 prompt正则提取后调用"""
import json
data_str = json.dumps(structured_data, ensure_ascii=False, indent=2)
return (
f"## PDF 关键页面原文\n{key_pages_text}\n\n"
f"## 正则提取的结构化数据\n{data_str}"
)
# ─── 页面筛选工具 ──────────────────────────────────────────
def select_key_pages(pdf_text: str, max_pages: int = 5, max_chars: int = 8000) -> str:
"""从完整 PDF 文本中筛选与产品最相关的 N 页。
筛选优先级:
1. 包含产品名称的封面页
2. 包含演示表(保单年度 + 价值类关键词)的页面
3. 包含保费信息的页面
4. 其余页面按顺序填充
Args:
pdf_text: 完整 PDF 文本(可能含 [PAGE N] 标记)
max_pages: 最多选取页数
max_chars: 总字符预算
Returns:
筛选后的页面文本(含页码标记)
"""
import re
# 拆分为页面(按 [PAGE N] 标记或换页符)
page_pattern = re.compile(r'\[PAGE\s+(\d+)\]')
parts = page_pattern.split(pdf_text)
pages = []
if len(parts) > 1:
# 有 [PAGE N] 标记
for i in range(1, len(parts), 2):
page_num = int(parts[i])
content = parts[i + 1] if i + 1 < len(parts) else ''
pages.append((page_num, content.strip()))
else:
# 没有页码标记,按换页符或大量换行分割
raw_pages = re.split(r'\f|\n{3,}', pdf_text)
for i, content in enumerate(raw_pages):
if content.strip():
pages.append((i + 1, content.strip()))
if not pages:
return pdf_text[:max_chars]
# ── 第一轮:按页面角色强制选入 ──
# 角色识别关键词
cover_kw = ['产品名称', '产品名', '保单', '受保人', '计划书', '建议书',
'Product', 'Proposal', '計劃書', '建議書', '產品名稱', '保障計劃']
customer_kw = ['受保人', '被保人', '投保人', '姓名', '年龄', '性别',
'Insured', 'Policyholder', '受保人年齡', '被保人年齡',
'Date of Birth', '出生日期']
summary_kw = ['保单摘要', '保單摘要', 'Policy Summary', '基本资料', '基本資料',
'保额', '保額', '缴费年期', '繳費年期', '保障年期']
# 利益表关键词(匹配这些的页面必须全部选入)
table_kw = [
'保单年度', '保單年度', '保证现金', '保證現金', '退保价值', '退保價值',
'红利', '紅利', '保费', '保費',
'benefit', 'illustration', 'policy year', 'account value',
'cash value', 'cash surrender', 'death benefit',
'guaranteed', 'non-guaranteed', 'non guaranteed',
]
premium_kw = [
'年缴保费', 'Annual Premium', '缴费年期', 'Premium Payment',
'planned premium', 'initial premium', '年繳保費',
]
# 分类每页
mandatory_pages = [] # 必选页(封面、客户信息、利益表)
optional_scored = [] # 可选页(按分数排序)
for page_num, content in pages:
content_lower = content.lower()
is_cover = any(kw.lower() in content_lower for kw in cover_kw)
is_customer = any(kw.lower() in content_lower for kw in customer_kw)
is_summary = any(kw.lower() in content_lower for kw in summary_kw)
is_table = sum(1 for kw in table_kw if kw.lower() in content_lower) >= 2
has_premium = any(kw.lower() in content_lower for kw in premium_kw)
if is_table:
# 利益表页必须全部选入
mandatory_pages.append((page_num, content, "table"))
elif is_cover and page_num <= 3:
# 封面只取前 3 页
mandatory_pages.append((page_num, content, "cover"))
elif is_customer:
mandatory_pages.append((page_num, content, "customer"))
elif is_summary:
mandatory_pages.append((page_num, content, "summary"))
else:
# 可选页:按分数排序
score = 0
if is_cover:
score += 30
if has_premium:
score += 20
num_count = len(re.findall(r'\b\d{3,}\b', content))
score += min(num_count, 20)
optional_scored.append((score, page_num, content))
# ── 第二轮:拼接必选页 + 填充可选页 ──
mandatory_pages.sort(key=lambda x: x[0]) # 按页码排序
optional_scored.sort(key=lambda x: x[0], reverse=True) # 按分数排序
selected_pages = []
selected_nums = set()
total_chars = 0
# 先加入所有必选页
for page_num, content, role in mandatory_pages:
if page_num in selected_nums:
continue
page_text = f"[PAGE {page_num}]\n{content}"
if total_chars + len(page_text) > max_chars:
# 必选页也截断,但保留页码标记
remaining = max_chars - total_chars
if remaining > 50:
selected_pages.append((page_num, page_text[:remaining]))
break
selected_pages.append((page_num, page_text))
selected_nums.add(page_num)
total_chars += len(page_text)
# 再填充高分可选页
for score, page_num, content in optional_scored:
if len(selected_pages) >= max_pages:
break
if page_num in selected_nums:
continue
page_text = f"[PAGE {page_num}]\n{content}"
if total_chars + len(page_text) > max_chars:
break
selected_pages.append((page_num, page_text))
selected_nums.add(page_num)
total_chars += len(page_text)
# 按页码排序输出
selected_pages.sort(key=lambda x: x[0])
return '\n\n'.join(text for _, text in selected_pages)