baodan/api/insurance/poster/product_source_resolver.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

198 lines
7.1 KiB
Python

"""统一解析公共产品和用户私有小册子来源。"""
import json
class ProductSourceError(ValueError):
def __init__(self, code: int, message: str):
super().__init__(message)
self.code = code
self.message = message
def normalize_product_source(data: dict) -> tuple[str, str]:
"""兼容新 productSource 和旧 productId。"""
source = data.get("productSource") if isinstance(data.get("productSource"), dict) else {}
source_type = str(
source.get("type")
or data.get("productSourceType")
or ("library_product" if data.get("productId") else "")
)
source_id = str(
source.get("id")
or data.get("productSourceId")
or data.get("productId")
or ""
)
if source_type not in ("library_product", "user_material") or not source_id:
raise ProductSourceError(1001, "请选择产品或上传产品小册子")
return source_type, source_id
def resolve_product_source(user_id: str, data: dict) -> dict:
"""返回生成链路使用的统一产品上下文。"""
source_type, source_id = normalize_product_source(data)
if source_type == "user_material":
from insurance.config import get_config
enabled = get_config("POSTER_USER_MANUAL_UPLOAD_ENABLED", "true")
if enabled.strip().lower() not in ("1", "true", "yes", "on"):
raise ProductSourceError(4108, "用户上传产品小册子功能暂未开放")
if source_type == "library_product":
return _resolve_library_product(source_id)
return _resolve_user_material(user_id, source_id)
def _resolve_library_product(product_id: str) -> dict:
from insurance.models.ppt_config import PptCompany, PptProduct
product = PptProduct.query.filter_by(
id=product_id,
status=1,
manual_parse_status="reviewed",
deleted_at=None,
).first()
if not product:
raise ProductSourceError(1002, "产品不存在、未启用或尚未审核")
company = PptCompany.query.filter_by(
id=product.company_id, status=1, deleted_at=None
).first()
if not company:
raise ProductSourceError(1002, "产品所属保司不存在或已停用")
try:
rules = json.loads(product.manual_parsed_rules) if product.manual_parsed_rules else {}
except (json.JSONDecodeError, TypeError):
rules = {}
product_data = product.to_dict()
rules.setdefault("product_name", product.display_name)
rules.setdefault("company_name", company.display_name)
rules.setdefault("coverage_period", product.coverage_period or "")
rules.setdefault("payment_period", product.payment_period or "")
rules.setdefault("insured_age_range", product.insured_age_range or "")
if not rules.get("features") and product_data.get("highlights"):
rules["features"] = [
{"title": item, "summary": ""} if isinstance(item, str) else item
for item in product_data["highlights"]
]
return {
"sourceType": "library_product",
"sourceId": str(product.id),
"productId": str(product.id),
"productName": product.display_name,
"companyId": company.id,
"companyName": company.display_name,
"planType": product.plan_type,
"rules": rules,
"confirmedAt": product.manual_reviewed_at.isoformat() if product.manual_reviewed_at else None,
"productData": product_data,
"companyData": company.to_dict(),
}
def _resolve_user_material(user_id: str, material_id: str) -> dict:
from insurance.models.user_product_material import UserProductMaterial
try:
numeric_id = int(material_id)
except (TypeError, ValueError):
raise ProductSourceError(4106, "产品资料不存在或无权访问")
material = UserProductMaterial.query.filter(
UserProductMaterial.id == numeric_id,
UserProductMaterial.owner_user_id == user_id,
UserProductMaterial.status == 1,
UserProductMaterial.deleted_at.is_(None),
).first()
if not material:
raise ProductSourceError(4106, "产品资料不存在或无权访问")
if material.parse_status in ("pending", "queued", "parsing"):
raise ProductSourceError(4103, "产品小册子仍在解析中")
if material.parse_status == "failed":
raise ProductSourceError(4104, "产品小册子解析失败,请重新解析")
if not material.confirmed_rules or not material.confirmed_at:
raise ProductSourceError(4105, "请先确认产品小册子解析结果")
try:
rules = json.loads(material.confirmed_rules)
except (json.JSONDecodeError, TypeError):
raise ProductSourceError(4105, "产品小册子确认数据无效,请重新确认")
return {
"sourceType": "user_material",
"sourceId": str(material.id),
"productId": None,
"productName": material.product_name,
"companyId": material.company_id,
"companyName": material.company_name,
"planType": material.plan_type or "other",
"rules": rules,
"confirmedAt": material.confirmed_at.isoformat(),
"productData": {
"id": str(material.id),
"displayName": material.product_name,
"maskedDisplayName": "",
"planType": material.plan_type or "other",
},
"companyData": {
"id": material.company_id,
"displayName": material.company_name,
"maskedDisplayName": "",
},
}
_PRODUCT_SNAPSHOT_FIELDS = (
"id", "companyId", "planType", "displayName", "maskedDisplayName",
"maskingEnabled",
"productCode", "productType", "coveragePeriod", "paymentPeriod",
"insuredAgeRange", "waitingPeriod", "highlights",
)
_COMPANY_SNAPSHOT_FIELDS = (
"id", "displayName", "maskedDisplayName", "companyIntro",
"maskingEnabled", "logoEnabled",
"companyHighlights", "rating", "foundedYear", "nameZh", "nameEn",
"shortEn", "logoUrl",
)
def product_snapshot(context: dict) -> dict:
"""移除内部展示字段,生成可审计、可序列化的产品快照。"""
product_data = context.get("productData") or {}
company_data = context.get("companyData") or {}
context = {
**context,
"productData": {
key: product_data.get(key)
for key in _PRODUCT_SNAPSHOT_FIELDS
if key in product_data
},
"companyData": {
key: company_data.get(key)
for key in _COMPANY_SNAPSHOT_FIELDS
if key in company_data
},
}
return {
key: context.get(key)
for key in (
"sourceType",
"sourceId",
"productId",
"productName",
"companyId",
"companyName",
"planType",
"rules",
"confirmedAt",
"productData",
"companyData",
)
}
def case_matches_product(case, context: dict) -> bool:
case_type = case.product_source_type or "library_product"
case_id = case.product_source_id or case.product_id
return case_type == context["sourceType"] and str(case_id or "") == context["sourceId"]