2026-07-02 23:13:23 +08:00
|
|
|
|
"""推荐服务:调用 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)
|
2026-07-12 14:17:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 调用 BaoDan Workflow API
|
|
|
|
|
|
api_key = os.environ.get("BAODAN_WORKFLOW_API_KEY", "")
|
2026-07-12 14:17:18 +08:00
|
|
|
|
# 统一使用 DIFY_BASE_URL 环境变量(与 app.py 保持一致)
|
|
|
|
|
|
base_url = os.environ.get("DIFY_BASE_URL", os.environ.get("BAODAN_API_URL", "http://localhost:5001"))
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-07-12 14:17:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise
|
2026-07-02 23:13:23 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|