"""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": "yes/no/unknown"}, "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)