baodan/api/insurance/recommend/service.py
2026-07-12 14:17:18 +08:00

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""推荐服务:调用 BaoDan Workflow 生成推荐方案。"""
import os
import json
import re
import uuid
import requests
from flask import current_app
from insurance.db.compat import db
from insurance.models.recommendation import RecommendationRecord
class RecommendService:
"""产品推荐业务逻辑。"""
def generate(self, user_id: str, data: dict) -> dict:
"""提交推荐方案生成任务。"""
customer = data.get("customer", {})
# 保存推荐记录
record = RecommendationRecord(
user_id=user_id,
customer_name=customer.get("name", ""),
customer_age=customer.get("age"),
customer_gender=customer.get("gender", ""),
health_status=customer.get("health_status", ""),
occupation=customer.get("occupation", ""),
annual_income=customer.get("annual_income", 0),
monthly_budget=customer.get("monthly_budget", 0),
insurance_types=json.dumps(data.get("insurance_types", []), ensure_ascii=False),
coverage_amount=data.get("coverage_amount", 0),
coverage_period=data.get("coverage_period", ""),
existing_policies=json.dumps(data.get("existing_policies", []), ensure_ascii=False),
status="processing",
)
db.session.add(record)
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
# 调用 BaoDan Workflow API
api_key = os.environ.get("BAODAN_WORKFLOW_API_KEY", "")
# 统一使用 DIFY_BASE_URL 环境变量(与 app.py 保持一致)
base_url = os.environ.get("DIFY_BASE_URL", os.environ.get("BAODAN_API_URL", "http://localhost:5001"))
# Workflow 期望全部 string 类型输入insurance_types 为逗号分隔字符串
insurance_types = data.get("insurance_types", [])
if isinstance(insurance_types, list):
insurance_types_str = ",".join(insurance_types)
else:
insurance_types_str = str(insurance_types)
try:
resp = requests.post(
f"{base_url}/v1/workflows/run",
json={
"inputs": {
"age": str(customer.get("age", "")),
"gender": customer.get("gender", ""),
"occupation": customer.get("occupation", ""),
"annual_income": str(customer.get("annual_income", 0)),
"monthly_budget": str(customer.get("monthly_budget", 0)),
"insurance_types": insurance_types_str,
"coverage_amount": str(data.get("coverage_amount", 0)),
"coverage_period": data.get("coverage_period", ""),
},
"response_mode": "blocking",
"user": f"user_{user_id}",
},
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
timeout=120,
)
result = resp.json()
if result.get("data", {}).get("status") == "succeeded":
output = result["data"].get("outputs", {})
# Workflow 输出 resultMarkdown 字符串)
recommendation_md = output.get("result", "") or output.get("recommendation", "")
if recommendation_md:
record.generated_plan = recommendation_md
record.plan_variants = json.dumps(
self._parse_markdown_to_plans(recommendation_md),
ensure_ascii=False,
)
record.status = "done"
else:
record.status = "failed"
record.error_message = "方案生成失败:无输出"
else:
record.status = "failed"
error_msg = result.get("message", "Workflow 执行失败")
record.error_message = error_msg
except Exception as e:
record.status = "failed"
record.error_message = str(e)
try:
db.session.commit()
except Exception:
db.session.rollback()
raise
from insurance.utils.audit import log_operation
log_operation(user_id, "generate", "proposal", str(record.id), {
"customer_name": customer.get("name", ""),
"insurance_types": data.get("insurance_types", []),
})
return {
"code": 0,
"data": {
"task_id": str(record.id),
"status": record.status,
},
}
def get_status(self, task_id: str) -> dict:
"""查询推荐任务状态。"""
record = db.session.query(RecommendationRecord).filter_by(id=int(task_id)).first()
if not record:
return {"code": 1005, "message": "任务不存在", "data": None}
data = {
"task_id": str(record.id),
"status": record.status,
# 客户信息
"customer_name": record.customer_name or "",
"customer_age": record.customer_age,
"customer_gender": record.customer_gender or "",
"health_status": record.health_status or "",
"occupation": record.occupation or "",
"annual_income": record.annual_income,
"monthly_budget": record.monthly_budget,
"insurance_types": record.insurance_types or "[]",
"coverage_amount": record.coverage_amount,
"coverage_period": record.coverage_period or "",
"created_at": str(record.created_at) if record.created_at else None,
}
if record.status == "done":
# 返回 Markdown 原文 + 解析后的结构化方案
plans = json.loads(record.plan_variants) if record.plan_variants else []
data["proposal"] = {
"id": str(record.id),
"recommendation": record.generated_plan or "",
"plans": plans,
}
elif record.status == "failed":
data["error_message"] = record.error_message
return {"code": 0, "data": data}
@staticmethod
def _parse_markdown_to_plans(md_text: str) -> list:
"""解析 LLM 输出的 Markdown 为结构化方案列表。
解析格式:
### 基础方案(年保费约 XXXX 元)
|产品名称|所属保险公司|险种|保额|年保费|推荐理由|
...
方案总结:...
### 均衡方案(年保费约 XXXX 元)
...
"""
plans = []
# 按 ### 分割方案
sections = re.split(r'(?=###\s)', md_text)
for section in sections:
section = section.strip()
if not section.startswith('###'):
continue
# 提取方案名称和年保费
title_match = re.match(r'###\s*(.+?)(?:.*?)?$', section, re.MULTILINE)
plan_name = title_match.group(1).strip() if title_match else "未知方案"
# 提取年保费
premium_match = re.search(r'年保费约\s*([\d,]+)\s*元', section)
total_premium = premium_match.group(1).replace(',', '') if premium_match else ""
# 提取表格行
items = []
table_lines = re.findall(r'\|(.+)\|', section)
for line in table_lines:
cells = [c.strip() for c in line.split('|')]
# 跳过表头和分隔行
if len(cells) < 6 or cells[0] in ('产品名称', '---------', '---', ''):
continue
items.append({
"product_name": cells[0],
"company": cells[1],
"insurance_type": cells[2],
"coverage": cells[3],
"premium": cells[4],
"reason": cells[5],
})
# 提取方案总结
summary_match = re.search(r'方案总结[:]\s*(.+?)(?=###|\Z)', section, re.DOTALL)
summary = summary_match.group(1).strip() if summary_match else ""
plans.append({
"name": plan_name,
"total_premium": total_premium,
"items": items,
"summary": summary,
})
return plans
def delete(self, proposal_id: str) -> dict:
"""删除推荐方案。"""
record = db.session.query(RecommendationRecord).filter_by(id=int(proposal_id)).first()
if not record:
return {"code": 1005, "message": "方案不存在", "data": None}
db.session.delete(record)
db.session.commit()
return {"code": 0, "message": "删除成功", "data": None}
def share(self, proposal_id: str, expire_hours: int) -> dict:
"""生成分享链接。"""
if expire_hours < 1 or expire_hours > 720:
return {"code": 1001, "message": "有效期应在 1-720 小时之间", "data": None}
record = db.session.query(RecommendationRecord).filter_by(id=int(proposal_id)).first()
if not record:
return {"code": 1005, "message": "方案不存在", "data": None}
# 生成分享 Token
share_token = str(uuid.uuid4())
from insurance.db.compat import redis_client
redis_client.setex(f"share:{share_token}", expire_hours * 3600, proposal_id)
domain = current_app.config.get("DOMAIN", "localhost")
return {
"code": 0,
"data": {
"share_url": f"https://{domain}/shared/{proposal_id}?token={share_token}",
"expire_at": "",
},
}
def list_proposals(self, user_id: str, params: dict) -> dict:
"""推荐历史列表(带数据权限)。"""
from insurance.middleware.auth_middleware import get_data_scope
scope = get_data_scope()
query = db.session.query(RecommendationRecord)
# 根据数据权限过滤
if scope["scope"] == "self":
# 销售:只看自己的
query = query.filter(RecommendationRecord.user_id == user_id)
elif scope["scope"] == "team":
# 主管:看本部门的
from insurance.models.wecom_user import WeComUserMapping
query = query.filter(
RecommendationRecord.user_id.in_(
db.session.query(WeComUserMapping.id).filter(
WeComUserMapping.department == scope["department"]
)
)
)
# admin/super_admin看全部
if params.get("customer_name"):
query = query.filter(RecommendationRecord.customer_name.ilike(f"%{params['customer_name']}%"))
if params.get("insurance_type"):
query = query.filter(RecommendationRecord.insurance_types.ilike(f"%{params['insurance_type']}%"))
total = query.count()
items = (
query.order_by(RecommendationRecord.created_at.desc())
.offset((params["page"] - 1) * params["page_size"])
.limit(params["page_size"])
.all()
)
return {
"code": 0,
"data": {
"total": total,
"items": [
{
"id": str(r.id),
"customer_name": r.customer_name or "",
"customer_age": r.customer_age,
"customer_gender": r.customer_gender or "",
"occupation": r.occupation or "",
"insurance_types": r.insurance_types or "[]",
"coverage_amount": r.coverage_amount,
"coverage_period": r.coverage_period or "",
"monthly_budget": r.monthly_budget,
"status": r.status,
"created_at": str(r.created_at) if r.created_at else None,
}
for r in items
],
},
}