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

241 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": 数字, "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. 严禁编造数据"""
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": 数字, "total_premium_paid": 数字, "guaranteed_account_value": 数字, "guaranteed_cash_value": 数字, "non_guaranteed_account_value": 数字, "non_guaranteed_cash_value": 数字, "non_guaranteed_death_benefit": 数字, "cost_of_insurance": 数字或null, "source_page": 数字或null}]
}
规则:
1. 优先提取非保证(当前假设利率)数据
2. 逐年提取所有保单年度,不要只输出摘要年份
3. 严禁编造数据无法确定填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)