307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""推荐服务:调用 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 输出 result(Markdown 字符串)
|
||
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
|
||
],
|
||
},
|
||
}
|