baodan/api/insurance/ppt/extraction.py
wsb1224 9dc494d968 主要修改:
利益表按最多两页、9000字符分块调用 LLM,成功分块按保单年度合并、去重、排序。[extraction.py (line 125)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:125)
身份字段只发送关键页面;没有提领关键词时不再发送整份 PDF 做空提领请求。
单个利益分块失败不会丢失其他成功分块,并记录具体分块编号和错误。[extraction.py (line 1161)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:1161)
RemoteProtocolError、连接超时、408/429/部分5xx现在会按配置执行真实退避重试;DeepSeek默认最多调用3次。[llm_client.py (line 600)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/llm_client.py:600)
只有一个供应商时,错误信息会明确显示“已配置1个供应商”,不再误导为存在多个备用供应商。
利益表分块失败或储蓄险年度数据不足时,结果标记为 partial,不再伪装完整成功。[extraction.py (line 750)](D:/work/code/python/coding/baodanagent/api/insurance/ppt/extraction.py:750)
会话显示“解析完成,部分文件需校对”,同时保留可人工修正的数据。
日志增加文件名、文件序号、最终状态、利益行数,以及身份/利益各分块/提领的独立耗时、token和错误信息。[celery_tasks.py (line 181)](D:/work/code/python/coding/baodanagent/api/insurance/generation/celery_tasks.py:181)
缓存版本升级至 v8,旧解析缓存自动失效。
验证结果:
PPT专项测试:54 passed, 1 skipped
大范围测试:231 passed, 1 skipped
仅剩既有聊天日志测试缺少 Flask application context
完整测试收集另受本机缺少 python-pptx 影响
Python语法检查:通过
前端生产构建:通过
git diff --check:通过
2026-08-01 02:25:35 +08:00

1616 lines
64 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""PDF 提取服务 — 从 PDF 计划书中提取结构化数据。"""
import os
import json
import time
import hashlib
import logging
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Callable, Optional
logger = logging.getLogger(__name__)
CACHE_VERSION = 8
def _format_exception(exc: Exception) -> str:
message = str(exc) or repr(exc)
return f"{exc.__class__.__name__}: {message}"
@dataclass
class ExtractionResult:
pdf_path: str
product_name: str
plan_type: str # savings/ci/iul
status: str # success/partial/error
data: Optional[dict] = None
usage: Optional[dict] = None
error: Optional[str] = None
duration_ms: float = 0
provenance: Optional[dict] = None # 字段级来源追踪
page_qualities: Optional[list] = None # 每页质量评分
def infer_plan_type(raw: dict) -> str:
"""从 LLM 输出推断产品类型。"""
t = str(raw.get("product_type", "")).lower()
if "ci" in t or "critical" in t:
return "ci"
if "iul" in t or "universal" in t:
return "iul"
rows = raw.get("benefit_illustration", [])
if not isinstance(rows, list):
rows = []
has_savings = any(
r.get("total_surrender_value") is not None
or r.get("guaranteed_cash_value") is not None
or r.get("reversionary_bonus") is not None
for r in rows if isinstance(r, dict)
)
has_ci = any(
r.get("surrender_value_total") is not None
or r.get("death_benefit_total") is not None
for r in rows if isinstance(r, dict)
)
has_iul = any(
r.get("cash_value") is not None
or r.get("account_value") is not None
or r.get("guaranteed_account_value") is not None
or r.get("non_guaranteed_account_value") is not None
or r.get("non_guaranteed_cash_value") is not None
for r in rows if isinstance(r, dict)
)
if has_iul:
return "iul"
if has_savings:
return "savings"
if has_ci:
return "ci"
policy = raw.get("policy", {})
if isinstance(policy, dict):
if policy.get("index_account_rate") is not None or policy.get("capital_partition") is not None:
return "iul"
if policy.get("sum_insured") is not None:
return "ci"
return "savings"
def _hash_file(file_path: str) -> str:
"""计算文件 SHA-256 哈希。"""
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _get_cache_path(pdf_path: str, cache_dir: str) -> str:
"""获取缓存文件路径。"""
file_hash = _hash_file(pdf_path)
return os.path.join(cache_dir, f"{file_hash}.json")
def _format_pdf_pages(text_parts: list[str]) -> str:
"""保留真实页码,供后续按封面、投保信息和利益表筛选关键页面。"""
return "\n\n".join(
f"[PAGE {page_num}]\n{content.strip()}"
for page_num, content in enumerate(text_parts, start=1)
if content and content.strip()
)
def _split_marked_pages(pdf_text: str) -> list[tuple[int, str]]:
"""拆分带 [PAGE N] 标记的文本;无标记时视为单页。"""
parts = re.split(r"\[PAGE\s+(\d+)\]", pdf_text or "", flags=re.IGNORECASE)
if len(parts) <= 1:
return [(1, (pdf_text or "").strip())] if (pdf_text or "").strip() else []
pages = []
for index in range(1, len(parts), 2):
page_num = int(parts[index])
content = parts[index + 1].strip() if index + 1 < len(parts) else ""
if content:
pages.append((page_num, content))
return pages
def _select_page_chunks(
pdf_text: str,
keywords: tuple[str, ...] = (),
min_keyword_hits: int = 1,
pages_per_chunk: int = 2,
max_chars: int = 9000,
) -> list[str]:
"""筛选指定页面并切成小块,避免单次 LLM 请求承载整份计划书。"""
pages = _split_marked_pages(pdf_text)
if keywords:
lowered_keywords = tuple(keyword.lower() for keyword in keywords)
pages = [
(page_num, content)
for page_num, content in pages
if sum(keyword in content.lower() for keyword in lowered_keywords) >= min_keyword_hits
]
chunks: list[str] = []
current: list[str] = []
current_chars = 0
for page_num, content in pages:
page_text = f"[PAGE {page_num}]\n{content}"
if len(page_text) > max_chars:
page_text = page_text[:max_chars]
if current and (
len(current) >= pages_per_chunk
or current_chars + len(page_text) > max_chars
):
chunks.append("\n\n".join(current))
current = []
current_chars = 0
current.append(page_text)
current_chars += len(page_text)
if current:
chunks.append("\n\n".join(current))
return chunks
def _merge_rows_by_policy_year(rows: list[dict]) -> list[dict]:
"""按保单年度合并分块结果,重复年度保留字段更完整的一行。"""
by_year: dict[int, dict] = {}
without_year = []
for row in rows:
if not isinstance(row, dict):
continue
try:
year = int(row.get("policy_year"))
except (TypeError, ValueError):
without_year.append(row)
continue
if year <= 0:
without_year.append(row)
continue
normalized = dict(row)
normalized["policy_year"] = year
existing = by_year.get(year)
if existing is None or sum(value is not None for value in normalized.values()) > sum(
value is not None for value in existing.values()
):
by_year[year] = normalized
return [by_year[year] for year in sorted(by_year)] + without_year
def _extract_pdf_text(pdf_path: str, max_chars: int = 120000) -> tuple[str, list[dict]]:
"""提取 PDF 文本,支持多种 PDF 解析库。
返回 (text, page_qualities):
- text: 合并后的全文
- page_qualities: 每页质量评分列表 [{"page": 1, "quality": "high", ...}, ...]
优先级PyMuPDF > PyPDF2 > pdfplumber > pypdf
"""
text = ""
# 尝试 PyMuPDF (fitz 或 pymupdf)
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
doc = fitz.open(pdf_path)
text_parts = []
for page in doc:
text_parts.append(page.get_text())
doc.close()
text = _format_pdf_pages(text_parts)
if text.strip() and not _looks_corrupted(text):
logger.info(f"使用 PyMuPDF 提取成功: {len(text)} 字符")
truncated = text[:max_chars] if len(text) > max_chars else text
page_qualities = _assess_page_qualities(text_parts)
return truncated, page_qualities
if text.strip():
logger.warning("PyMuPDF 提取结果疑似乱码,将尝试其他文本解析器,必要时使用 OCR")
except ImportError:
logger.debug("PyMuPDF 未安装,尝试下一个库")
except Exception as e:
logger.warning(f"PyMuPDF 提取失败: {e}")
# 尝试 PyPDF2
try:
from PyPDF2 import PdfReader
reader = PdfReader(pdf_path)
text_parts = []
for page in reader.pages:
page_text = page.extract_text()
text_parts.append(page_text or "")
text = _format_pdf_pages(text_parts)
if text.strip() and not _looks_corrupted(text):
logger.info(f"使用 PyPDF2 提取成功: {len(text)} 字符")
truncated = text[:max_chars] if len(text) > max_chars else text
page_qualities = _assess_page_qualities(text_parts)
return truncated, page_qualities
except ImportError:
logger.debug("PyPDF2 未安装,尝试下一个库")
except Exception as e:
logger.warning(f"PyPDF2 提取失败: {e}")
# 尝试 pypdf
try:
from pypdf import PdfReader
reader = PdfReader(pdf_path)
text_parts = []
for page in reader.pages:
page_text = page.extract_text()
text_parts.append(page_text or "")
text = _format_pdf_pages(text_parts)
if text.strip() and not _looks_corrupted(text):
logger.info(f"使用 pypdf 提取成功: {len(text)} 字符")
truncated = text[:max_chars] if len(text) > max_chars else text
page_qualities = _assess_page_qualities(text_parts)
return truncated, page_qualities
except ImportError:
logger.debug("pypdf 未安装,尝试下一个库")
except Exception as e:
logger.warning(f"pypdf 提取失败: {e}")
# 尝试 pdfplumber
try:
import pdfplumber
with pdfplumber.open(pdf_path) as pdf:
text_parts = []
for page in pdf.pages:
page_text = page.extract_text()
text_parts.append(page_text or "")
text = _format_pdf_pages(text_parts)
if text.strip() and not _looks_corrupted(text):
logger.info(f"使用 pdfplumber 提取成功: {len(text)} 字符")
truncated = text[:max_chars] if len(text) > max_chars else text
page_qualities = _assess_page_qualities(text_parts)
return truncated, page_qualities
except ImportError:
logger.debug("pdfplumber 未安装")
except Exception as e:
logger.warning(f"pdfplumber 提取失败: {e}")
ocr_text = _extract_pdf_text_ocr(pdf_path, max_chars=max_chars)
if ocr_text:
return ocr_text, []
logger.error(
"无法提取 PDF 文本,请安装以下任一库:\n"
" pip install PyMuPDF\n"
" pip install PyPDF2\n"
" pip install pypdf\n"
" pip install pdfplumber"
)
return "", []
def _assess_page_qualities(text_parts: list[str]) -> list[dict]:
"""评估每页文本质量。"""
qualities = []
for i, page_text in enumerate(text_parts):
score = _score_page_quality(page_text)
score["page"] = i + 1
qualities.append(score)
# 汇总日志
low_pages = [q for q in qualities if q["quality"] in ("corrupted", "low")]
if low_pages:
logger.info(
f"[PageQuality] {len(qualities)} 页中 {len(low_pages)} 页质量低: "
f"{[p['page'] for p in low_pages]}"
)
return qualities
def _looks_corrupted(text: str) -> bool:
"""检测 PDF 文本是否乱码。"""
if not text or len(text) < 50:
return True
# 缺少 ToUnicode 映射时,部分解析器会返回大量 ``(cid:123)``。
# 这些占位符是 ASCII不能只靠下方的可读字符率判断。
cid_placeholders = re.findall(r"\(cid:\d+\)", text, re.IGNORECASE)
if len(cid_placeholders) >= 10:
cid_chars = sum(len(value) for value in cid_placeholders)
if cid_chars / max(len(text), 1) >= 0.02:
return True
visible = [c for c in text if not c.isspace()]
if not visible:
return True
bad_chars = sum(
1
for c in visible
if c in ("\ufffd", "\uffff")
or ord(c) < 32
or 0x7F <= ord(c) <= 0x9F
or 0xE000 <= ord(c) <= 0xF8FF
)
readable_chars = sum(
1
for c in visible
if (
(c.isascii() and (c.isalnum() or c in ".,:;!?%+-_/()[]{}$¥¥'"))
or "\u3400" <= c <= "\u9fff"
)
)
return bad_chars / len(visible) > 0.03 or readable_chars / len(visible) < 0.35
def _score_page_quality(page_text: str) -> dict:
"""评估单页文本质量。返回质量指标字典。
用于识别需要 OCR 重处理的低质量页。
"""
if not page_text or not page_text.strip():
return {"chars": 0, "readable_ratio": 0, "label_hits": 0, "number_density": 0, "quality": "empty"}
visible = [c for c in page_text if not c.isspace()]
total = len(visible)
if total == 0:
return {"chars": 0, "readable_ratio": 0, "label_hits": 0, "number_density": 0, "quality": "empty"}
readable = sum(
1 for c in visible
if (c.isascii() and (c.isalnum() or c in ".,:;!?%+-_/()[]{}$"))
or "" <= c <= "鿿"
or " " <= c <= ""
)
readable_ratio = readable / total
# 保险相关标签命中
label_keywords = [
"保单", "保單", "保费", "保費", "受保人", "被保人", "投保",
"现金价值", "現金價值", "红利", "紅利", "退保",
"premium", "policy", "insured", "benefit", "cash value",
"保证", "保證", "非保证", "非保證", "年度", "年齡", "年龄",
]
text_lower = page_text.lower()
label_hits = sum(1 for kw in label_keywords if kw in text_lower)
# 数字密度
digits = sum(1 for c in visible if c.isdigit())
number_density = digits / total
# 综合评分
if readable_ratio < 0.3:
quality = "corrupted"
elif readable_ratio < 0.5 and label_hits < 2:
quality = "low"
elif label_hits >= 3 and number_density >= 0.1:
quality = "high"
elif label_hits >= 1:
quality = "medium"
else:
quality = "low"
return {
"chars": total,
"readable_ratio": round(readable_ratio, 3),
"label_hits": label_hits,
"number_density": round(number_density, 3),
"quality": quality,
}
def _extract_pdf_text_ocr(
pdf_path: str,
max_chars: int = 120000,
max_pages: int = 40,
) -> str:
"""对扫描件或字体映射损坏的 PDF 使用 Tesseract OCR。
支持繁体中文chi_tra+ 简体中文chi_sim+ 英文。
如果 chi_tra 未安装,自动降级到 chi_sim+eng。
"""
tesseract = shutil.which("tesseract")
if not tesseract:
logger.warning("PDF 文本疑似乱码,但未安装 Tesseract OCR")
return ""
# 检测可用的语言包:优先繁体+简体+英文,降级到简体+英文
ocr_lang = "chi_tra+chi_sim+eng"
try:
test_result = subprocess.run(
[tesseract, "--list-langs"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10, check=False,
)
available_langs = test_result.stdout.lower()
if "chi_tra" not in available_langs:
ocr_lang = "chi_sim+eng"
logger.info("[OCR] 繁体语言包(chi_tra)未安装,降级到 chi_sim+eng")
else:
logger.info("[OCR] 使用繁体+简体+英文识别")
except Exception:
ocr_lang = "chi_sim+eng"
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
doc = fitz.open(pdf_path)
text_parts = []
page_count = min(len(doc), max_pages)
with tempfile.TemporaryDirectory(prefix="insurance-pdf-ocr-") as temp_dir:
for index in range(page_count):
page = doc[index]
# 300 DPI 基线,灰度模式
pixmap = page.get_pixmap(
matrix=fitz.Matrix(3.0, 3.0),
colorspace=fitz.csGRAY,
)
image_path = os.path.join(temp_dir, f"page-{index + 1}.png")
pixmap.save(image_path)
# 使用 psm 6统一文本块适合表格和表单
completed = subprocess.run(
[
tesseract,
image_path,
"stdout",
"-l",
ocr_lang,
"--psm",
"6",
"--oem",
"3",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=90,
check=False,
)
if completed.returncode == 0 and completed.stdout.strip():
text_parts.append(completed.stdout)
else:
text_parts.append("")
logger.warning(
"Tesseract OCR 第 %s 页失败: %s",
index + 1,
completed.stderr.strip()[:300],
)
doc.close()
text = _format_pdf_pages(text_parts)
if text.strip() and not _looks_corrupted(text):
logger.info("使用 Tesseract OCR 提取成功: %s 字符", len(text))
text = text[:max_chars] if len(text) > max_chars else text
return f"[OCR]\n{text}"
logger.warning("Tesseract OCR 未能产生可用文本")
except Exception as exc:
logger.warning("Tesseract OCR 提取失败: %s", _format_exception(exc))
return ""
def _ocr_specific_pages(
pdf_path: str,
page_indices: list[int],
ocr_lang: str = "chi_tra+chi_sim+eng",
) -> dict[int, str]:
"""对指定页码执行 OCR返回 {page_index: ocr_text}。"""
tesseract = shutil.which("tesseract")
if not tesseract or not page_indices:
return {}
# 检测可用语言包
try:
test_result = subprocess.run(
[tesseract, "--list-langs"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10, check=False,
)
if "chi_tra" not in test_result.stdout.lower():
ocr_lang = "chi_sim+eng"
except Exception:
ocr_lang = "chi_sim+eng"
results = {}
try:
try:
import fitz
except ImportError:
import pymupdf as fitz
doc = fitz.open(pdf_path)
with tempfile.TemporaryDirectory(prefix="insurance-page-ocr-") as temp_dir:
for page_idx in page_indices:
if page_idx >= len(doc):
continue
page = doc[page_idx]
pixmap = page.get_pixmap(
matrix=fitz.Matrix(3.0, 3.0),
colorspace=fitz.csGRAY,
)
image_path = os.path.join(temp_dir, f"page-{page_idx + 1}.png")
pixmap.save(image_path)
completed = subprocess.run(
[tesseract, image_path, "stdout", "-l", ocr_lang, "--psm", "6", "--oem", "3"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=90, check=False,
)
if completed.returncode == 0 and completed.stdout.strip():
results[page_idx] = completed.stdout.strip()
doc.close()
except Exception as exc:
logger.warning(f"[OCR] 逐页 OCR 失败: {_format_exception(exc)}")
return results
def _merge_page_ocr(pdf_text: str, page_qualities: list[dict], pdf_path: str) -> str:
"""对低质量页执行 OCR 并合并回全文。
识别 quality 为 corrupted 或 low 的页面OCR 替换其内容。
"""
low_pages = [q for q in page_qualities if q.get("quality") in ("corrupted", "low")]
if not low_pages:
return pdf_text
tesseract = shutil.which("tesseract")
if not tesseract:
logger.info("[OCR] 低质量页检测到但 Tesseract 未安装,跳过逐页 OCR")
return pdf_text
page_indices = [q["page"] - 1 for q in low_pages] # 转为 0-based
logger.info(f"[OCR] 对 {len(page_indices)} 个低质量页执行 OCR: {[p+1 for p in page_indices]}")
progress_callback = None # 这里的进度由上层管理
ocr_results = _ocr_specific_pages(pdf_path, page_indices)
if not ocr_results:
return pdf_text
# 按 [PAGE N] 标记分割全文,替换低质量页
import re
parts = re.split(r'(\[PAGE \d+\])', pdf_text)
current_page = 0
merged_parts = []
for part in parts:
page_match = re.match(r'\[PAGE (\d+)\]', part)
if page_match:
current_page = int(page_match.group(1)) - 1 # 转为 0-based
merged_parts.append(part)
elif current_page in ocr_results:
# 用 OCR 结果替换该页内容
merged_parts.append(f"\n{ocr_results[current_page]}\n")
logger.info(f"[OCR] 第 {current_page + 1} 页已用 OCR 替换")
else:
merged_parts.append(part)
return "".join(merged_parts)
def _normalized_product_name(data: dict) -> str:
product_name = str(data.get("product_name") or "").strip()
if not product_name:
return "unknown"
if product_name.lower() == "unknown":
return "unknown"
return product_name
def _is_obviously_invalid_product_name(product_name: str) -> bool:
"""识别被模型误填到产品名字段的常见身份值。"""
normalized = re.sub(r"[\s._/-]+", "", str(product_name or "").strip().lower())
return normalized in {
"female", "male", "f", "m", "", "", "女性", "男性",
"女士", "先生", "吸烟", "非吸烟", "smoker", "nonsmoker",
}
def _build_provenance(
final_data: dict,
regex_data: Optional[dict],
method: str,
used_ocr: bool,
) -> dict:
"""构建字段级来源追踪。
返回结构:
{
"product_name": {"source": "regex|llm|ocr", "confidence": 0.0-1.0},
"insured.age": {"source": "regex|llm", "confidence": 0.0-1.0},
"benefit_illustration": {"source": "regex|llm", "row_count": N, "confidence": 0.0-1.0},
...
}
"""
prov = {}
base_source = "ocr" if used_ocr else ("regex" if method == "regex+analysis" else "llm")
def _field_confidence(value, source) -> float:
"""根据值和来源计算置信度。"""
if value is None or value == "" or value == "unknown":
return 0.0
if source == "regex":
return 0.9 # 正则匹配高置信
if source == "ocr":
return 0.6 # OCR 中等置信
return 0.7 # LLM 中高置信
# 标量字段
product_name = _normalized_product_name(final_data)
if regex_data and _normalized_product_name(regex_data) != "unknown":
prov["product_name"] = {"source": "regex", "confidence": _field_confidence(product_name, "regex")}
else:
prov["product_name"] = {"source": base_source, "confidence": _field_confidence(product_name, base_source)}
# insured 子字段
insured = final_data.get("insured") or {}
regex_insured = (regex_data or {}).get("insured") or {}
for field in ("age", "gender"):
final_val = insured.get(field)
regex_val = regex_insured.get(field)
if regex_val is not None and regex_val != "":
src = "regex"
else:
src = base_source
prov[f"insured.{field}"] = {"source": src, "confidence": _field_confidence(final_val, src)}
# policy 子字段
policy = final_data.get("policy") or {}
regex_policy = (regex_data or {}).get("policy") or {}
for field in ("currency", "sum_insured", "annual_premium", "premium_payment_period", "coverage_period"):
final_val = policy.get(field)
regex_val = regex_policy.get(field)
if regex_val is not None and regex_val != "":
src = "regex"
else:
src = base_source
prov[f"policy.{field}"] = {"source": src, "confidence": _field_confidence(final_val, src)}
# benefit_illustration
benefit = final_data.get("benefit_illustration") or []
regex_benefit = (regex_data or {}).get("benefit_illustration") or []
if len(regex_benefit) >= len(benefit) and regex_benefit:
ben_src = "regex"
elif benefit:
ben_src = base_source
else:
ben_src = "missing"
prov["benefit_illustration"] = {
"source": ben_src,
"row_count": len(benefit),
"confidence": min(1.0, len(benefit) / 20) if benefit else 0.0,
}
# withdrawal_illustration
withdrawal = final_data.get("withdrawal_illustration") or []
if withdrawal:
prov["withdrawal_illustration"] = {
"source": base_source,
"row_count": len(withdrawal),
"confidence": min(1.0, len(withdrawal) / 10),
}
# 统计
sources = [v["source"] for v in prov.values() if isinstance(v, dict) and "source" in v]
avg_conf = sum(v.get("confidence", 0) for v in prov.values() if isinstance(v, dict)) / max(len(prov), 1)
prov["_summary"] = {
"method": method,
"used_ocr": used_ocr,
"avg_confidence": round(avg_conf, 3),
"source_counts": {s: sources.count(s) for s in set(sources)},
}
return prov
def _apply_filename_hints(data: dict, pdf_path: str, plan_type: str) -> dict:
"""用标准计划书文件名中的明确字段补齐 OCR 容易误读的数据。"""
if not isinstance(data, dict):
return data
filename = os.path.basename(pdf_path)
if plan_type == "iul" and re.search(r"(?:^|[_-])SIUL3(?:[_-]|$)", filename, re.IGNORECASE):
def set_missing(target: dict, key: str, value) -> None:
if target.get(key) in (None, "", "unknown"):
target[key] = value
data["product_name"] = "Manulife SIUL 3"
insured = data.setdefault("insured", {})
policy = data.setdefault("policy", {})
if isinstance(policy, dict):
policy["product_name"] = "Manulife SIUL 3"
# 标准文件名示例SIUL3_F-48-N-CN-USD-S3m-5x。
# 只补齐文件名明确编码且正文未识别的字段,绝不覆盖已识别值。
identity_match = re.search(
r"(?:^|[_-])(?P<gender>[FM])-(?P<age>\d{1,3})-(?P<smoker>[NS])(?:[_-]|$)",
filename,
re.IGNORECASE,
)
if identity_match and isinstance(insured, dict):
set_missing(insured, "gender", "female" if identity_match.group("gender").upper() == "F" else "male")
set_missing(insured, "age", int(identity_match.group("age")))
set_missing(insured, "smoker", "no" if identity_match.group("smoker").upper() == "N" else "yes")
if isinstance(policy, dict):
currency_match = re.search(r"(?:^|[_-])(USD|HKD|CNY|RMB)(?:[_-]|$)", filename, re.IGNORECASE)
if currency_match:
set_missing(policy, "currency", currency_match.group(1).upper())
sum_match = re.search(r"(?:^|[_-])S(?P<amount>\d+(?:\.\d+)?)(?P<unit>[mMkK])(?:[_-]|$)", filename)
if sum_match:
multiplier = 1_000_000 if sum_match.group("unit").lower() == "m" else 1_000
set_missing(policy, "sum_insured", float(sum_match.group("amount")) * multiplier)
payment_match = re.search(r"(?:^|[_-])(?P<years>\d{1,2})x(?:[_-]|$)", filename, re.IGNORECASE)
if payment_match:
set_missing(policy, "premium_payment_period", int(payment_match.group("years")))
return data
def _has_sufficient_benefit_rows(rows: list[dict]) -> bool:
years = set()
for row in rows:
try:
year = int((row or {}).get("policy_year"))
except (TypeError, ValueError):
continue
if year > 0:
years.add(year)
return len(rows) >= 20 or (len(years) >= 5 and {10, 20, 30}.issubset(years) and max(years) >= 30)
def assess_extraction_payload(data: Optional[dict], plan_type: str) -> tuple[str, str]:
"""Return extraction status and a user-facing error when data is incomplete."""
if not isinstance(data, dict) or not data:
return "partial", "结构化结果为空,请补充识别数据后再生成 PPT"
insured = data.get("insured") or {}
policy = data.get("policy") or {}
benefit_rows = data.get("benefit_illustration")
benefit_rows = benefit_rows if isinstance(benefit_rows, list) else []
product_name = _normalized_product_name(data)
insured_age = insured.get("age")
def has_positive_number(value) -> bool:
try:
return float(value) > 0
except (TypeError, ValueError):
return False
problems = []
if product_name == "unknown":
problems.append("产品名称未识别")
if not has_positive_number(insured_age):
problems.append("被保人年龄缺失")
if not benefit_rows:
problems.append("利益演示为空")
normalized_type = (plan_type or "").lower()
if normalized_type == "iul":
if not has_positive_number(policy.get("sum_insured")):
problems.append("保额缺失")
index_accounts = data.get("index_accounts")
if not isinstance(index_accounts, list) or not index_accounts:
problems.append("指数账户缺失")
rows_with_iul_values = sum(
1 for row in benefit_rows if isinstance(row, dict) and any(
row.get(field) is not None
for field in (
"guaranteed_account_value",
"non_guaranteed_account_value",
"non_guaranteed_cash_value",
"account_value",
"cash_value",
)
)
)
if benefit_rows and rows_with_iul_values / len(benefit_rows) < 0.5:
problems.append("IUL利益字段缺失")
elif normalized_type == "ci":
if not has_positive_number(policy.get("sum_insured")):
problems.append("保额缺失")
coverage_items = data.get("coverage_items")
if not isinstance(coverage_items, list) or not coverage_items:
problems.append("保障项目缺失")
else:
if not has_positive_number(policy.get("annual_premium")):
problems.append("年缴保费缺失")
if benefit_rows and not _has_sufficient_benefit_rows(benefit_rows):
problems.append(f"利益演示数据不足(当前 {len(benefit_rows)} 行)")
split_benefit = ((data.get("_meta") or {}).get("split_extraction") or {}).get("benefit") or {}
if split_benefit.get("status") in ("failed", "partial") and not any(
"利益演示" in problem for problem in problems
):
problems.append(f"利益演示分块提取未完成(当前 {len(benefit_rows)} 行)")
if problems:
return "partial", "".join(problems[:3])
return "success", ""
def _payload_score(data: Optional[dict]) -> int:
"""用于比较两次提取结果,优先保留关键字段更完整的一次。"""
if not isinstance(data, dict):
return 0
insured = data.get("insured") if isinstance(data.get("insured"), dict) else {}
policy = data.get("policy") if isinstance(data.get("policy"), dict) else {}
rows = data.get("benefit_illustration")
row_count = len(rows) if isinstance(rows, list) else 0
return (
row_count * 10
+ (5 if _normalized_product_name(data) != "unknown" else 0)
+ (5 if insured.get("age") else 0)
+ sum(1 for value in policy.values() if value not in (None, "", [], {}))
)
def _regex_quality_gate(regex_data: dict, plan_type: str) -> tuple[bool, list[str]]:
"""检查正则提取结果的关键字段完整性。
返回 (passed, missing_fields):
- passed: True 表示关键字段完整,可以仅用 LLM 做分析
- missing_fields: 缺失的关键字段列表
"""
missing = []
product_name = _normalized_product_name(regex_data)
if product_name == "unknown":
missing.append("product_name")
insured = regex_data.get("insured") or {}
if not insured.get("age"):
missing.append("insured.age")
policy = regex_data.get("policy") or {}
normalized_type = (plan_type or "savings").lower()
if normalized_type == "iul":
try:
if not (float(policy.get("sum_insured") or 0) > 0):
missing.append("policy.sum_insured")
except (TypeError, ValueError):
missing.append("policy.sum_insured")
index_accounts = regex_data.get("index_accounts")
if not isinstance(index_accounts, list) or not index_accounts:
missing.append("index_accounts")
elif normalized_type == "ci":
try:
if not (float(policy.get("sum_insured") or 0) > 0):
missing.append("policy.sum_insured")
except (TypeError, ValueError):
missing.append("policy.sum_insured")
rows = [row for row in (regex_data.get("benefit_illustration") or []) if isinstance(row, dict)]
years = []
for row in rows:
try:
year = int(row.get("policy_year"))
except (TypeError, ValueError):
continue
if year > 0:
years.append(year)
if len(rows) >= 3 and (
len(years) != len(rows) or len(years) != len(set(years)) or years != sorted(years)
):
missing.append("benefit_illustration.policy_year_sequence")
age_offsets = []
for row in rows:
try:
if row.get("age") is not None and row.get("policy_year") is not None:
age_offsets.append(int(row["age"]) - int(row["policy_year"]))
except (TypeError, ValueError):
continue
if len(age_offsets) >= 2 and max(age_offsets) - min(age_offsets) > 1:
missing.append("benefit_illustration.age_sequence")
if normalized_type == "iul" and rows:
iul_value_fields = (
"guaranteed_account_value",
"non_guaranteed_account_value",
"non_guaranteed_cash_value",
"cost_of_insurance",
"account_value",
"cash_value",
)
rows_with_iul_values = sum(
1 for row in rows
if any(row.get(field) is not None for field in iul_value_fields)
)
if rows_with_iul_values / len(rows) < 0.5:
missing.append("benefit_illustration.iul_value_columns")
try:
annual_premium = float(policy.get("annual_premium") or 0)
except (TypeError, ValueError):
annual_premium = 0
positive_surrender = []
for row in rows:
try:
value = float((row or {}).get("total_surrender_value") or 0)
except (TypeError, ValueError):
value = 0
if value > 0:
positive_surrender.append(value)
if len(positive_surrender) >= 3:
implausible_floor = max(150, annual_premium * 0.01)
tiny = [value for value in positive_surrender if value < implausible_floor]
if len(tiny) / len(positive_surrender) >= 0.6:
missing.append("benefit_illustration.total_surrender_value_implausible")
return (len(missing) == 0, missing)
async def _llm_extract_split(
pdf_text: str,
plan_type: str,
llm_client,
progress_callback=None,
) -> tuple[dict, any]:
"""分块 LLM 提取:身份字段 + 利益表 + 提领表分开调用。
优势:
- 每次调用输出更小,减少截断风险
- 利益表可以使用更多 token
- 某块失败不影响其他块
返回 (merged_data, last_response)。
"""
from insurance.ppt.prompts import select_key_pages
identity_text = select_key_pages(pdf_text, max_pages=3, max_chars=8000)
benefit_keywords = (
"保单年度", "保單年度", "退保价值", "退保價值", "保证现金", "保證現金",
"policy year", "cash value", "surrender value", "account value",
)
benefit_chunks = _select_page_chunks(
pdf_text,
benefit_keywords,
min_keyword_hits=2,
pages_per_chunk=2,
max_chars=9000,
)
if not benefit_chunks:
benefit_fallback = select_key_pages(pdf_text, max_pages=6, max_chars=18000)
benefit_chunks = _select_page_chunks(
benefit_fallback,
pages_per_chunk=2,
max_chars=9000,
)
merged_data = {}
last_response = None
split_diagnostics = {
"tokens": {"input": 0, "output": 0},
"identity": {"status": "pending"},
"benefit": {
"status": "pending",
"chunkCount": len(benefit_chunks),
"completedChunks": 0,
"errors": [],
},
"withdrawal": {"status": "pending"},
}
def record_response(block: str, response) -> None:
tokens = getattr(response, "tokens", None) or {}
split_diagnostics["tokens"]["input"] += int(tokens.get("input", 0) or 0)
split_diagnostics["tokens"]["output"] += int(tokens.get("output", 0) or 0)
latency_ms = getattr(response, "latency_ms", 0) or 0
if latency_ms:
split_diagnostics[block]["latencyMs"] = round(
split_diagnostics[block].get("latencyMs", 0) + latency_ms,
1,
)
normalized_type = (plan_type or "savings").lower()
if normalized_type == "iul":
identity_shape = (
'"policy": {"product_name": "产品名称", "currency": "USD/HKD/CNY", '
'"sum_insured": 数字, "annual_premium": 数字, "premium_payment_period": 数字, '
'"coverage_period": "终身", "target_premium": 数字或null, "minimum_premium": 数字或null}, '
'"coverage_items": null, '
'"index_accounts": [{"name": "账户名称", "allocation": 数字或null, '
'"current_rate": 数字或null, "guaranteed_floor": 数字或null}]'
)
elif normalized_type == "ci":
identity_shape = (
'"policy": {"product_name": "产品名称", "currency": "USD/HKD/CNY", '
'"sum_insured": 数字, "annual_premium": 数字, "premium_payment_period": 数字, '
'"coverage_period": "终身"}, '
'"coverage_items": [{"label": "保障项目", "amount": 数字, "description": null}], '
'"index_accounts": null'
)
else:
identity_shape = (
'"policy": {"product_name": "产品名称", "currency": "USD/HKD/CNY", '
'"sum_insured": 数字或null, "annual_premium": 数字, "premium_payment_period": 数字, '
'"coverage_period": "终身"}, "coverage_items": null, "index_accounts": null'
)
# ── 第一次调用:身份 + 保单字段(小输出,高精度)──
identity_prompt = (
"从以下 PDF 文本中提取保险计划书的身份和保单字段。\n"
"只输出以下 JSON不要输出利益演示表和提领表\n"
'{"product_name": "产品全称", "product_type": "savings/ci/iul", '
'"insured": {"name": null, "age": 数字, "gender": "male/female", "relation": null, "smoker": null}, '
f'{identity_shape}' + '}\n\n'
"规则:\n"
"1. gender 用 male/female\n"
"2. premium_payment_period 只输出数字(年数)\n"
"3. 无法确定的字段填 null\n"
"4. 只输出 JSON无 markdown\n\n"
f"PDF 文本:\n{identity_text}"
)
if progress_callback:
progress_callback(55, "正在识别产品和保单信息")
try:
identity_data, last_response = await llm_client.structured_output(
prompt=identity_prompt,
system_prompt="你是保险计划书数据提取专家。只输出 JSON。",
schema={
"type": "object",
"required": ["product_name", "insured", "policy"],
"properties": {
"product_name": {"type": "string"},
"insured": {
"type": "object",
"required": ["age", "gender"],
},
"policy": {
"type": "object",
"required": [
"currency", "sum_insured", "annual_premium",
"premium_payment_period",
],
},
},
},
temperature=0,
)
if isinstance(identity_data, dict):
merged_data.update(identity_data)
split_diagnostics["identity"]["status"] = "success"
record_response("identity", last_response)
except Exception as e:
split_diagnostics["identity"] = {
"status": "failed",
"error": _format_exception(e),
}
logger.warning(f"[SplitExtract] 身份字段提取失败: {e}")
# ── 第二次调用:利益演示表(大输出,用更多 token──
if normalized_type == "iul":
benefit_shape = (
'{"benefit_illustration": [{"policy_year": 数字, "age": 数字或null, '
'"total_premium_paid": 数字或null, "guaranteed_account_value": 数字或null, '
'"guaranteed_cash_value": 数字或null, "non_guaranteed_account_value": 数字或null, '
'"non_guaranteed_cash_value": 数字或null, "total_surrender_value": 数字或null, '
'"non_guaranteed_death_benefit": 数字或null, "cost_of_insurance": 数字或null, '
'"source_page": 数字或null}]}'
)
elif normalized_type == "ci":
benefit_shape = (
'{"benefit_illustration": [{"policy_year": 数字, "age": 数字或null, '
'"total_premium_paid": 数字或null, "death_benefit": 数字或null, '
'"source_page": 数字或null}]}'
)
else:
benefit_shape = (
'{"benefit_illustration": [{"policy_year": 数字, "age": 数字或null, '
'"total_premium_paid": 数字或null, "guaranteed_cash_value": 数字或null, '
'"reversionary_bonus": 数字或null, "terminal_dividend": 数字或null, '
'"total_surrender_value": 数字或null, "death_benefit": 数字或null, '
'"source_page": 数字或null}]}'
)
benefit_prompt_prefix = (
"从以下 PDF 文本中提取保险计划书的利益演示表。\n"
"必须输出 JSON 对象,根对象只能包含 benefit_illustration 字段;禁止直接输出数组:\n"
f"{benefit_shape}\n\n"
"规则:\n"
"1. 提取当前分块中的全部保单年度,不要遗漏任何数据行\n"
"2. 数值去逗号转数字,无法确定填 null不要填 0\n"
"3. 严禁编造数据\n"
"4. 年龄和保单年度是不同列,年龄绝不能写入任何金额字段\n"
"5. 只输出 JSON无 markdown\n\n"
)
if progress_callback:
progress_callback(70, "正在提取利益演示表")
benefit_rows = []
for chunk_index, benefit_text in enumerate(benefit_chunks, start=1):
benefit_prompt = (
f"{benefit_prompt_prefix}"
f"当前分块:{chunk_index}/{len(benefit_chunks)}\n"
f"PDF 文本:\n{benefit_text}"
)
try:
benefit_data, benefit_resp = await llm_client.structured_output(
prompt=benefit_prompt,
system_prompt="你是保险计划书数据提取专家。只输出 JSON。",
schema={
"type": "object",
"required": ["benefit_illustration"],
"properties": {
"benefit_illustration": {
"type": "array",
"items": {"type": "object"},
},
},
"additionalProperties": False,
},
temperature=0,
)
chunk_rows = benefit_data.get("benefit_illustration", []) if isinstance(benefit_data, dict) else []
if isinstance(chunk_rows, list):
benefit_rows.extend(row for row in chunk_rows if isinstance(row, dict))
split_diagnostics["benefit"]["completedChunks"] += 1
record_response("benefit", benefit_resp)
last_response = benefit_resp
except Exception as e:
error_text = _format_exception(e)
split_diagnostics["benefit"]["errors"].append({
"chunk": chunk_index,
"error": error_text,
})
logger.warning(
f"[SplitExtract] 利益表分块 {chunk_index}/{len(benefit_chunks)} 提取失败: {error_text}"
)
merged_data["benefit_illustration"] = _merge_rows_by_policy_year(benefit_rows)
completed_benefit_chunks = split_diagnostics["benefit"]["completedChunks"]
if completed_benefit_chunks == len(benefit_chunks) and benefit_chunks:
split_diagnostics["benefit"]["status"] = "success"
elif completed_benefit_chunks > 0:
split_diagnostics["benefit"]["status"] = "partial"
else:
split_diagnostics["benefit"]["status"] = "failed"
split_diagnostics["benefit"]["rowCount"] = len(merged_data["benefit_illustration"])
# ── 第三次调用:提领表(可选,仅储蓄险/IUL──
if normalized_type in ("savings", "iul"):
withdrawal_chunks = _select_page_chunks(
pdf_text,
("提取", "提款", "提领", "提領", "领取", "領取", "withdrawal"),
min_keyword_hits=1,
pages_per_chunk=2,
max_chars=8000,
)
split_diagnostics["withdrawal"].update({
"chunkCount": len(withdrawal_chunks),
"completedChunks": 0,
"errors": [],
})
withdrawal_rows = []
if progress_callback and withdrawal_chunks:
progress_callback(85, "正在提取提领表")
for chunk_index, withdrawal_text in enumerate(withdrawal_chunks, start=1):
withdrawal_prompt = (
"从以下 PDF 文本中提取保险计划书的提领/提款演示表。\n"
"如果文本中没有提领表,输出 {\"withdrawal_illustration\": []}。\n"
"必须输出 JSON 对象,根对象只能包含 withdrawal_illustration 字段;禁止直接输出数组:\n"
'{"withdrawal_illustration": [{"policy_year": 数字, "annual_withdrawal": 数字或null, '
'"total_withdrawn": 数字或null, "surrender_value_before": 数字或null, '
'"surrender_value_after": 数字或null, "source_page": 数字或null}]}\n\n'
"规则:\n"
"1. 只取\"总额/Total\"列,不取子列\n"
"2. 没有提领表时 withdrawal_illustration 必须为空数组 []\n"
"3. 只输出 JSON无 markdown\n\n"
f"当前分块:{chunk_index}/{len(withdrawal_chunks)}\n"
f"PDF 文本:\n{withdrawal_text}"
)
try:
withdrawal_data, withdrawal_resp = await llm_client.structured_output(
prompt=withdrawal_prompt,
system_prompt="你是保险计划书数据提取专家。只输出 JSON。",
schema={
"type": "object",
"required": ["withdrawal_illustration"],
"properties": {
"withdrawal_illustration": {
"type": "array",
"items": {"type": "object"},
},
},
"additionalProperties": False,
},
temperature=0,
)
chunk_rows = withdrawal_data.get("withdrawal_illustration", []) if isinstance(withdrawal_data, dict) else []
if isinstance(chunk_rows, list):
withdrawal_rows.extend(row for row in chunk_rows if isinstance(row, dict))
split_diagnostics["withdrawal"]["completedChunks"] += 1
record_response("withdrawal", withdrawal_resp)
last_response = withdrawal_resp
except Exception as e:
error_text = _format_exception(e)
split_diagnostics["withdrawal"]["errors"].append({
"chunk": chunk_index,
"error": error_text,
})
logger.warning(
f"[SplitExtract] 提领表分块 {chunk_index}/{len(withdrawal_chunks)} 提取失败: {error_text}"
)
merged_data["withdrawal_illustration"] = _merge_rows_by_policy_year(withdrawal_rows)
completed_withdrawal_chunks = split_diagnostics["withdrawal"]["completedChunks"]
if not withdrawal_chunks:
split_diagnostics["withdrawal"]["status"] = "not_present"
elif completed_withdrawal_chunks == len(withdrawal_chunks):
split_diagnostics["withdrawal"]["status"] = "success"
elif completed_withdrawal_chunks > 0:
split_diagnostics["withdrawal"]["status"] = "partial"
else:
split_diagnostics["withdrawal"]["status"] = "failed"
split_diagnostics["withdrawal"]["rowCount"] = len(merged_data["withdrawal_illustration"])
else:
split_diagnostics["withdrawal"]["status"] = "not_applicable"
# 确保必要字段存在
merged_data.setdefault("benefit_illustration", [])
merged_data.setdefault("withdrawal_illustration", [])
merged_data.setdefault("_meta", {})["split_extraction"] = split_diagnostics
return merged_data, last_response
class ExtractionOrchestrator:
"""PDF 提取编排器。
提取策略(按优先级):
1. 缓存命中 → 直接返回
2. 正则提取(零延迟)→ 关键字段完整则仅用 LLM 做轻量分析
3. 关键字段缺失或正则不足 → 回退到完整 LLM 提取
"""
# 正则提取行数阈值:低于此值回退到 LLM
REGEX_ROW_THRESHOLD = 3
def __init__(self, use_cache: bool = True, cache_dir: str = ".cache/insurance-ppt"):
self.use_cache = use_cache
self.cache_dir = cache_dir
async def extract_plan(
self,
pdf_path: str,
plan_type: str = "savings",
force_reparse: bool = False,
progress_callback: Optional[Callable[[int, str], None]] = None,
company_id: str = "",
product_id: str = "",
product_name_hint: str = "",
product_aliases: Optional[list[str]] = None,
) -> ExtractionResult:
"""从 PDF 提取结构化数据。
优先使用正则提取(~50ms仅在行数不足或关键字段缺失时回退到 LLM。
Args:
product_name_hint: 用户选择的产品标准名称(用于匹配和纠错)
product_aliases: 产品别名列表(用于模糊匹配)
"""
from insurance.ppt.llm_client import llm_client
from insurance.ppt.prompts import (
SAVINGS_PLAN_SYSTEM_PROMPT, CI_PLAN_SYSTEM_PROMPT, IUL_SYSTEM_PROMPT,
ANALYSIS_SYSTEM_PROMPT, build_analysis_prompt, select_key_pages,
)
from insurance.ppt.regex_extractor import extract_insurance_regex, count_benefit_rows
start = time.time()
abs_path = os.path.abspath(pdf_path)
if not os.path.exists(abs_path):
return ExtractionResult(
pdf_path=abs_path, product_name="unknown", plan_type=plan_type,
status="error", error="文件不存在",
duration_ms=(time.time() - start) * 1000,
)
# 检查缓存
if self.use_cache and not force_reparse:
cached = self._load_from_cache(abs_path)
if cached:
if progress_callback:
progress_callback(100, "已使用历史解析结果")
cached.duration_ms = (time.time() - start) * 1000
return cached
# 提取 PDF 文本
if progress_callback:
progress_callback(10, "正在读取 PDF 文本")
pdf_text, page_qualities = _extract_pdf_text(abs_path)
if not pdf_text:
return ExtractionResult(
pdf_path=abs_path, product_name="unknown", plan_type=plan_type,
status="error", error="无法提取 PDF 文本",
duration_ms=(time.time() - start) * 1000,
)
# 逐页 OCR对低质量页单独 OCR 替换(仅在非全文 OCR 模式下)
if not pdf_text.startswith("[OCR]") and page_qualities:
low_count = sum(1 for q in page_qualities if q.get("quality") in ("corrupted", "low"))
if low_count > 0:
if progress_callback:
progress_callback(20, f"检测到 {low_count} 个低质量页,正在 OCR 补充")
pdf_text = _merge_page_ocr(pdf_text, page_qualities, abs_path)
if progress_callback:
progress_callback(30, "PDF 文本读取完成,正在识别数据表")
# ─── 正则提取(第一阶段,零 LLM 调用)────────────────
regex_start = time.time()
used_ocr = pdf_text.startswith("[OCR]")
regex_data = extract_insurance_regex(pdf_text)
regex_rows = count_benefit_rows(regex_data)
regex_ms = (time.time() - regex_start) * 1000
if progress_callback:
progress_callback(
50,
f"规则识别完成,共识别 {regex_rows} 行利益数据",
)
extraction_stats = {
"regex_rows": regex_rows,
"regex_ms": round(regex_ms, 1),
"page_count": len(page_qualities),
"low_quality_pages": [q["page"] for q in page_qualities if q["quality"] in ("corrupted", "low")],
}
response = None # LLM 响应(可能未调用)
# 质量门:检查正则结果的关键字段完整性
quality_passed, quality_missing = _regex_quality_gate(regex_data, plan_type)
extraction_stats["quality_passed"] = quality_passed
extraction_stats["quality_missing"] = quality_missing
if regex_rows >= self.REGEX_ROW_THRESHOLD and not used_ocr and quality_passed:
# ─── 正则成功且关键字段完整:仅调用 LLM 做轻量分析 ────────────
extraction_stats["method"] = "regex+analysis"
data = regex_data
llm_start = time.time()
try:
# 选取关键页面4-5页~8000字符远小于原来的 20000+
key_pages = select_key_pages(pdf_text, max_pages=5, max_chars=8000)
analysis_prompt = build_analysis_prompt(key_pages, data)
if progress_callback:
progress_callback(60, "正在补充产品分析")
analysis_result, response = await llm_client.structured_output(
prompt=analysis_prompt,
system_prompt=ANALYSIS_SYSTEM_PROMPT,
)
# 将分析结果合并到 data
if isinstance(analysis_result, dict):
data["sales_insights"] = {
"key_points": analysis_result.get("keyPoints", []),
"gaps": analysis_result.get("gaps", []),
"suggested_questions": analysis_result.get("suggestedQuestions", []),
}
extraction_stats["llm_tokens"] = {
"input": response.tokens.get("input", 0) if response.tokens else 0,
"output": response.tokens.get("output", 0) if response.tokens else 0,
}
extraction_stats["llm_ms"] = round((time.time() - llm_start) * 1000, 1)
if progress_callback:
progress_callback(90, "产品分析完成,正在校验数据")
except Exception as e:
# 分析失败不影响已提取的数据,只记日志
logger.warning(f"[ExtractionOrchestrator] LLM 分析失败(不影响数据提取): {_format_exception(e)}")
extraction_stats["llm_error"] = _format_exception(e)
extraction_stats["llm_ms"] = round((time.time() - llm_start) * 1000, 1)
else:
# ─── 正则不足或关键字段缺失:回退到分块 LLM 提取 ────
if not quality_passed:
logger.info(
f"[ExtractionOrchestrator] 正则关键字段缺失({quality_missing}), "
f"回退到分块 LLM 提取"
)
else:
logger.info(
f"[ExtractionOrchestrator] 正则提取行数不足({regex_rows}), "
f"回退到分块 LLM 提取"
)
extraction_stats["method"] = "llm_split"
llm_start = time.time()
try:
if progress_callback:
progress_callback(55, "规则识别不足,正在等待 AI 分块提取")
data, response = await _llm_extract_split(
pdf_text, plan_type, llm_client, progress_callback,
)
split_meta = ((data.get("_meta") or {}).get("split_extraction") or {})
extraction_stats["llm_blocks"] = {
key: value for key, value in split_meta.items() if key != "tokens"
}
# 检查完整性,对缺失字段做针对性重试
initial_status, initial_error = assess_extraction_payload(data, plan_type)
if initial_status == "partial" and initial_error:
if progress_callback:
progress_callback(88, "首次识别不完整,正在补充关键字段")
# 用正则结果填补 LLM 缺失的字段
if regex_data:
for key in ("product_name", "insured", "policy", "currency"):
if key in regex_data and (key not in data or not data[key]):
data[key] = regex_data[key]
# 利益表:取行数更多的那个
regex_benefit = regex_data.get("benefit_illustration", [])
llm_benefit = data.get("benefit_illustration", [])
if len(regex_benefit) > len(llm_benefit) and (quality_passed or not llm_benefit):
data["benefit_illustration"] = regex_benefit
extraction_stats["llm_tokens"] = split_meta.get(
"tokens", {"input": 0, "output": 0}
)
if progress_callback:
progress_callback(90, "AI 分块提取完成,正在校验数据")
except Exception as e:
return ExtractionResult(
pdf_path=abs_path, product_name="unknown", plan_type=plan_type,
status="error", error=f"LLM 调用失败: {_format_exception(e)}",
duration_ms=(time.time() - start) * 1000,
)
extraction_stats["llm_ms"] = round((time.time() - llm_start) * 1000, 1)
data = _apply_filename_hints(data, abs_path, plan_type)
# 产品先验匹配:用用户选择的产品信息补正提取结果
if product_name_hint:
extracted_name = _normalized_product_name(data)
if extracted_name == "unknown" or _is_obviously_invalid_product_name(extracted_name):
# 用户选择的产品名优先纠正空值和明显误识别(例如 Female
data.setdefault("policy", {})["product_name"] = product_name_hint
data["product_name"] = product_name_hint
logger.info(
f"[ExtractionOrchestrator] 提取产品名 '{extracted_name}' 无效,"
f"采用用户选择: {product_name_hint}"
)
elif product_aliases:
# 检查提取的名称是否与别名匹配
extracted_lower = extracted_name.lower()
all_names = [product_name_hint.lower()] + [a.lower() for a in product_aliases]
if not any(n in extracted_lower or extracted_lower in n for n in all_names):
logger.info(
f"[ExtractionOrchestrator] 提取产品名 '{extracted_name}' "
f"与用户选择 '{product_name_hint}' 不匹配,请用户确认"
)
# 推断产品类型
detected_type = plan_type if plan_type in ("savings", "ci", "iul") else infer_plan_type(data)
product_name = _normalized_product_name(data)
status, extraction_error = assess_extraction_payload(data, detected_type)
# 只缓存完整结果,避免后续复用错误或缺字段的解析。
if self.use_cache and status == "success":
self._save_to_cache(abs_path, data)
total_ms = (time.time() - start) * 1000
extraction_stats["total_ms"] = round(total_ms, 1)
logger.info(
f"[ExtractionOrchestrator] 提取完成 file={os.path.basename(abs_path)} "
f"status={status}: {extraction_stats}"
)
if progress_callback:
progress_callback(100, "数据校验完成")
# 构建字段级来源追踪
method = extraction_stats.get("method", "unknown")
provenance = _build_provenance(data, regex_data, method, used_ocr)
return ExtractionResult(
pdf_path=abs_path, product_name=product_name,
plan_type=detected_type, status=status, data=data,
usage=extraction_stats.get("llm_tokens"),
error=extraction_error or None,
duration_ms=total_ms,
provenance=provenance,
page_qualities=page_qualities,
)
async def extract_multiple(self, pdf_paths: list[str], plan_type: str = "savings") -> list[ExtractionResult]:
"""顺序提取多个 PDF。"""
results = []
for pdf_path in pdf_paths:
logger.info(f" 📄 {os.path.basename(pdf_path)}...")
results.append(await self.extract_plan(pdf_path, plan_type))
return results
def _load_from_cache(self, pdf_path: str) -> Optional[ExtractionResult]:
"""从缓存加载。"""
try:
cache_path = _get_cache_path(pdf_path, self.cache_dir)
if not os.path.exists(cache_path):
return None
with open(cache_path, "r", encoding="utf-8") as f:
raw = json.load(f)
meta = raw.get("_meta", {})
if meta.get("cacheVersion") != CACHE_VERSION:
return None
data = raw.get("_data", raw)
product_name = _normalized_product_name(data)
plan_type = infer_plan_type(data)
status, extraction_error = assess_extraction_payload(data, plan_type)
return ExtractionResult(
pdf_path=pdf_path, product_name=product_name,
plan_type=plan_type, status=status, data=data, error=extraction_error or None,
)
except Exception:
return None
def _save_to_cache(self, pdf_path: str, data: dict):
"""写入缓存。"""
try:
os.makedirs(self.cache_dir, exist_ok=True)
file_hash = _hash_file(pdf_path)
cache_data = {
"_data": data,
"_meta": {
"cacheVersion": CACHE_VERSION,
"originalFile": os.path.basename(pdf_path),
"extractedAt": __import__("datetime").datetime.now().isoformat(),
"fileHash": file_hash,
},
}
cache_path = os.path.join(self.cache_dir, f"{file_hash}.json")
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"缓存写入失败: {e}")
async def extract_for_poster(self, filepath: str) -> dict:
"""提取海报所需的关键字段(精简 prompt降低 LLM 成本)。
返回:
{age, gender, currency, sum_assured, premium_term,
annual_premium, coverage_period, key_benefits}
"""
from insurance.ppt.llm_client import llm_client
abs_path = os.path.abspath(filepath)
text, _page_qualities = _extract_pdf_text(abs_path)
if not text:
raise ValueError("无法提取 PDF 文本")
text = text[:6000]
system_prompt = (
"你是一位保险计划书解析专家。请从以下计划书内容中提取海报所需的关键字段。\n"
"输出 JSON 格式(不要包含 markdown 代码块标记):\n"
'{"age": 35, "gender": "male", "currency": "USD", "sum_assured": 500000, '
'"premium_term": 5, "annual_premium": 100000, "coverage_period": "终身", '
'"key_benefits": ["身故赔偿", "全残保障"]}\n'
"注意数值用数字不要带货币符号。gender 用 male/female。"
)
result, _response = await llm_client.structured_output(
text, system_prompt,
schema={
"type": "object",
"properties": {
"age": {"type": "number"},
"gender": {"type": "string"},
"currency": {"type": "string"},
"sum_assured": {"type": "number"},
"premium_term": {"type": "number"},
"annual_premium": {"type": "number"},
"coverage_period": {"type": "string"},
"key_benefits": {"type": "array", "items": {"type": "string"}},
},
"required": ["age", "gender", "currency", "sum_assured", "annual_premium"],
},
)
return result