192 lines
8.3 KiB
Python
192 lines
8.3 KiB
Python
"""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": "男/女", "relation": null, "smoker": null},
|
||
"policy": {"product_name": "产品名称", "currency": "USD/HKD/CNY", "sum_insured": null, "basic_sum_insured": null, "annual_premium": 数字, "premium_payment_period": "5年", "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": "男/女", "relation": null},
|
||
"policy": {"currency": "USD/HKD", "sum_insured": 数字, "annual_premium": 数字, "premium_payment_period": "20年", "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": "男/女"},
|
||
"policy": {"currency": "USD", "sum_insured": 数字, "annual_premium": 数字, "premium_payment_period": "10年", "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]
|
||
|
||
# 评分排序
|
||
scored = []
|
||
cover_kw = ['产品名称', '保单', '受保人', '计划书', '建议书', 'Product', 'Proposal']
|
||
table_kw = [
|
||
'保单年度', '保证现金', '退保价值', '红利', '保费',
|
||
'benefit', 'illustration', 'policy year', 'account value',
|
||
'cash value', 'cash surrender', 'death benefit',
|
||
]
|
||
premium_kw = [
|
||
'年缴保费', 'Annual Premium', '缴费年期', 'Premium Payment',
|
||
'planned premium', 'initial premium',
|
||
]
|
||
|
||
for page_num, content in pages:
|
||
score = 0
|
||
content_lower = content.lower()
|
||
if any(kw.lower() in content_lower for kw in cover_kw):
|
||
score += 30
|
||
if any(kw.lower() in content_lower for kw in table_kw):
|
||
score += 50
|
||
if any(kw.lower() in content_lower for kw in premium_kw):
|
||
score += 20
|
||
# 数字密度高的页面(表格页)加分
|
||
num_count = len(re.findall(r'\b\d{3,}\b', content))
|
||
score += min(num_count, 20)
|
||
scored.append((score, page_num, content))
|
||
|
||
# 按分数降序,取前 max_pages 页
|
||
scored.sort(key=lambda x: x[0], reverse=True)
|
||
selected = sorted(scored[:max_pages], key=lambda x: x[1]) # 恢复页码顺序
|
||
|
||
# 拼接,遵守字符预算
|
||
result_parts = []
|
||
total_chars = 0
|
||
for score, page_num, content in selected:
|
||
page_text = f"[PAGE {page_num}]\n{content}"
|
||
if total_chars + len(page_text) > max_chars:
|
||
remaining = max_chars - total_chars
|
||
if remaining > len(f"[PAGE {page_num}]\n"):
|
||
result_parts.append(page_text[:remaining])
|
||
break
|
||
result_parts.append(page_text)
|
||
total_chars += len(page_text)
|
||
|
||
return '\n\n'.join(result_parts)
|