baodan/api/insurance/ppt/renderer.py

392 lines
16 KiB
Python
Raw Normal View History

2026-07-23 13:10:50 +08:00
"""PPT 渲染服务 — 调用 python-pptx / insurance-deck 生成 PPT。"""
import os
import json
import uuid
import subprocess
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# 默认 Python 路径
DEFAULT_PYTHON = "python"
def _resolve_python() -> str:
"""查找可用的 Python 解释器。
优先使用虚拟环境中的 Python然后是系统 Python
"""
2026-07-23 13:10:50 +08:00
import shutil
import sys
# 优先使用当前运行的 Python 解释器
current_python = sys.executable
if current_python and os.path.exists(current_python):
return current_python
# 查找虚拟环境中的 Python
venv_python = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".venv", "bin", "python")
if os.path.exists(venv_python):
return venv_python
# 查找系统 Python
for candidate in ["python3.12", "python3.11", "python3", "python"]:
2026-07-23 13:10:50 +08:00
path = shutil.which(candidate)
if path:
return path
2026-07-23 13:10:50 +08:00
return DEFAULT_PYTHON
def _build_deck_contract(
normalized_data: dict,
company_info: dict = None,
theme: str = "broker",
) -> dict:
"""将归一化数据转换为 DeckContract 格式。"""
# 映射主题到 stylePreset
theme_map = {
"broker": "broker",
"modern": "modern",
"fresh": "fresh",
"warm": "warm",
"deepblue": "broker",
"caramel": "caramel",
"chinese": "chinese",
}
# 提取产品信息
product = {
"kind": normalized_data.get("kind", "savings"),
"productName": normalized_data.get("productName", ""),
"rawProductName": normalized_data.get("rawProductName", ""),
"insured": normalized_data.get("insured", {}),
"policy": normalized_data.get("policy", {}),
"benefitRows": normalized_data.get("benefitRows", []),
"withdrawalRows": normalized_data.get("withdrawalRows", []),
"withdrawalProvenance": normalized_data.get("withdrawalProvenance", "missing"),
}
# 公司信息
company = company_info or {
"id": "",
"displayName": "",
"shortEn": "",
"evidence": [],
}
# 构建 DeckContract
deck = {
"id": f"deck_{uuid.uuid4().hex[:12]}",
"generatedAt": __import__("datetime").datetime.now().isoformat(),
"customer": {
"name": normalized_data.get("insured", {}).get("name", "客户"),
"age": normalized_data.get("insured", {}).get("age"),
"gender": normalized_data.get("insured", {}).get("gender", ""),
},
"tenantId": "default",
"stylePreset": theme_map.get(theme, "broker"),
"quality": "standard",
"outputFormat": "pptx",
"outputStem": "insurance_plan",
"products": [product],
"company": company,
"meta": normalized_data.get("source", {
"pdfHash": "",
"pdfPath": "",
"parser": "llm-json",
"extractedAt": __import__("datetime").datetime.now().isoformat(),
}),
"fidelity": {
"passed": True,
"issueCount": 0,
"errors": 0,
"warnings": 0,
},
}
return deck
class PptRenderer:
"""PPT 渲染器。"""
def __init__(self, work_dir: str = None):
self.work_dir = work_dir or os.path.join(os.getcwd(), "ppt_work")
os.makedirs(self.work_dir, exist_ok=True)
def render_enhanced(
self,
normalized_data: dict,
output_path: str,
theme: str = "broker",
company_id: str = None,
company_info: dict = None,
) -> dict:
"""增强渲染(使用 fast_pptx_renderer.py
Args:
normalized_data: 归一化后的计划数据
output_path: 输出 PPTX 路径
theme: 视觉主题
company_id: 公司 ID
company_info: 公司信息 dict
Returns:
{"ok": bool, "path": str, "slideCount": int}
"""
try:
# 准备工作目录
session_dir = os.path.join(self.work_dir, uuid.uuid4().hex[:8])
os.makedirs(session_dir, exist_ok=True)
# 转换为 DeckContract 格式
deck = _build_deck_contract(normalized_data, company_info, theme)
# 写入 DeckContract JSON
deck_path = os.path.join(session_dir, "deck.json")
with open(deck_path, "w", encoding="utf-8") as f:
json.dump(deck, f, ensure_ascii=False, indent=2)
# 查找渲染脚本
python = _resolve_python()
script_dir = os.path.join(os.path.dirname(__file__), "scripts")
render_script = os.path.join(script_dir, "fast_pptx_renderer.py")
if os.path.exists(render_script):
# 映射主题到渲染脚本支持的主题
theme_map = {
"broker": "deepblue",
"modern": "deepblue",
"fresh": "deepblue",
"warm": "caramel",
"deepblue": "deepblue",
"caramel": "caramel",
"chinese": "chinese",
}
script_theme = theme_map.get(theme, "deepblue")
2026-07-23 13:10:50 +08:00
# 调用 fast_pptx_renderer.py
cmd = [
python, render_script,
"--deck-json", deck_path,
"--output", output_path,
"--theme", script_theme,
2026-07-23 13:10:50 +08:00
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
# 解析脚本输出
try:
script_result = json.loads(result.stdout.strip())
if script_result.get("ok"):
return {
"ok": True,
"path": output_path,
"slideCount": script_result.get("slides", 0),
}
else:
return {
"ok": False,
"path": "",
"slideCount": 0,
"error": script_result.get("error", "渲染脚本返回失败"),
}
except json.JSONDecodeError:
# 脚本输出不是 JSON尝试统计幻灯片数
slide_count = self._count_slides(output_path)
return {"ok": True, "path": output_path, "slideCount": slide_count}
else:
logger.error(f"渲染脚本失败: {result.stderr}")
return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr}
else:
# 回退到基础渲染
logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染")
return self._render_basic(normalized_data, output_path, theme)
except Exception as e:
logger.error(f"渲染失败: {e}")
return {"ok": False, "path": "", "slideCount": 0, "error": str(e)}
def _render_basic(self, data: dict, output_path: str, theme: str) -> dict:
"""基础 python-pptx 渲染(无 insurance-deck 时的回退)。"""
try:
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
# 主题配色
themes = {
"broker": {"primary": "0A3C5F", "accent": "18898D", "gold": "C9A027"},
"modern": {"primary": "1A1A2E", "accent": "1A73E8", "gold": "FFD700"},
"fresh": {"primary": "2E7D32", "accent": "4CAF50", "gold": "8BC34A"},
"warm": {"primary": "5D4037", "accent": "FF9800", "gold": "FFC107"},
}
colors = themes.get(theme, themes["broker"])
def hex_to_rgb(hex_color: str) -> RGBColor:
h = hex_color.lstrip("#")
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def add_text_box(slide, left, top, width, height, text, font_size=18, bold=False, color="FFFFFF", align=PP_ALIGN.LEFT):
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.color.rgb = hex_to_rgb(color)
p.alignment = align
return txBox
# ─── 封面页 ─────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 空白布局
# 背景色
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
product_name = data.get("productName", "保险计划书")
insured = data.get("insured", {})
customer_name = insured.get("name", "客户")
add_text_box(slide, 0.8, 1.5, 11.7, 1.0, customer_name, font_size=42, bold=True)
add_text_box(slide, 0.8, 2.5, 11.7, 0.6, "财富增值与传承方案", font_size=24, color="90CAF9")
add_text_box(slide, 0.8, 3.5, 11.7, 0.5, product_name, font_size=16, color="8899AA")
# 关键指标卡片
policy = data.get("policy", {})
annual_premium = policy.get("annualPremium", 0)
pay_years = policy.get("payYears", 0)
benefit_rows = data.get("benefitRows", [])
total_premium = annual_premium * pay_years
multiple = "-"
final_value = 0
if benefit_rows:
last_row = benefit_rows[-1]
final_value = last_row.get("totalSurrenderValue", 0)
if total_premium > 0:
multiple = f"{final_value / total_premium:.1f}x"
metrics = [
("年缴保费", f"${annual_premium:,.0f}"),
("缴费年期", f"{pay_years}"),
("总投入", f"${total_premium:,.0f}"),
("期末倍数", multiple),
]
for i, (label, value) in enumerate(metrics):
x = 0.8 + i * 3.0
add_text_box(slide, x, 4.5, 2.5, 0.4, label, font_size=12, color="8899AA")
add_text_box(slide, x, 4.9, 2.5, 0.5, value, font_size=24, bold=True, color=colors["accent"])
# ─── 数据总览页 ─────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "保单数据总览", font_size=28, bold=True, color=colors["primary"])
# 基本信息表
info_items = [
("产品名称", product_name),
("受保人", f"{customer_name} ({insured.get('age', '')}岁)"),
("保单货币", policy.get("currency", "USD")),
("年缴保费", f"${annual_premium:,.0f}"),
("缴费年期", f"{pay_years}"),
("保障期间", policy.get("coveragePeriod", "")),
]
for i, (label, value) in enumerate(info_items):
y = 1.2 + i * 0.5
add_text_box(slide, 1.0, y, 3.0, 0.4, label, font_size=14, color="666666")
add_text_box(slide, 4.0, y, 8.0, 0.4, str(value), font_size=14, bold=True, color="333333")
# ─── 利益演示页 ─────────────────────────────────
if benefit_rows:
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb("FFFFFF")
add_text_box(slide, 0.5, 0.3, 12.3, 0.6, "逐年利益演示", font_size=28, bold=True, color=colors["primary"])
# 表头
headers = ["保单年度", "年龄", "已缴保费", "保证现金价值", "归原红利", "终期分红", "退保发还总额"]
col_widths = [1.5, 1.2, 1.8, 2.0, 1.8, 1.8, 2.2]
x = 0.5
for header, width in zip(headers, col_widths):
add_text_box(slide, x, 1.1, width, 0.4, header, font_size=11, bold=True, color=colors["primary"])
x += width
# 数据行最多显示20行
display_rows = benefit_rows[:20]
for row_idx, row in enumerate(display_rows):
y = 1.5 + row_idx * 0.35
values = [
str(row.get("policyYear", "")),
str(row.get("age", "")),
f"${row.get('totalPremiumPaid', 0):,.0f}",
f"${row.get('guaranteedCashValue', 0):,.0f}",
f"${row.get('reversionaryBonus', 0):,.0f}",
f"${row.get('terminalDividend', 0):,.0f}",
f"${row.get('totalSurrenderValue', 0):,.0f}",
]
x = 0.5
for value, width in zip(values, col_widths):
add_text_box(slide, x, y, width, 0.3, value, font_size=10, color="333333")
x += width
# ─── 总结页 ─────────────────────────────────────
slide = prs.slides.add_slide(prs.slide_layouts[6])
background = slide.background
fill = background.fill
fill.solid()
fill.fore_color.rgb = hex_to_rgb(colors["primary"])
add_text_box(slide, 0.8, 2.0, 11.7, 1.0, "方案总结", font_size=36, bold=True, align=PP_ALIGN.CENTER)
add_text_box(slide, 0.8, 3.2, 11.7, 0.6, f"总投入 ${total_premium:,.0f} → 期末价值 ${final_value:,.0f}", font_size=24, color="90CAF9", align=PP_ALIGN.CENTER)
add_text_box(slide, 0.8, 4.0, 11.7, 0.5, f"倍数增长 {multiple}", font_size=28, bold=True, color=colors["gold"], align=PP_ALIGN.CENTER)
prs.save(output_path)
slide_count = len(prs.slides)
return {"ok": True, "path": output_path, "slideCount": slide_count}
except ImportError:
return {"ok": False, "path": "", "slideCount": 0, "error": "python-pptx 未安装"}
def _count_slides(self, pptx_path: str) -> int:
"""统计 PPTX 幻灯片数量。"""
try:
from pptx import Presentation
prs = Presentation(pptx_path)
return len(prs.slides)
except Exception:
return 0
def generate_previews(self, pptx_path: str, output_dir: str) -> dict:
"""生成幻灯片预览图(需要 LibreOffice"""
os.makedirs(output_dir, exist_ok=True)
try:
soffice = os.environ.get("SOFFICE_BIN", "/usr/bin/soffice")
cmd = [
soffice, "--headless", "--convert-to", "pdf",
"--outdir", output_dir, pptx_path,
]
subprocess.run(cmd, capture_output=True, timeout=60)
# 预览图生成需要额外的 PDF→PNG 转换
return {"ok": True, "previewPaths": []}
except Exception as e:
logger.warning(f"预览生成失败: {e}")
return {"ok": False, "previewPaths": [], "error": str(e)}