Files
Mutual_Fund/service/advisor/drafts.py
T

368 lines
14 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.
"""草稿代理 + 本地镜像 + 发送终审(投顾工作台核心闭环)。
- 草稿 list/detail/save/discard 代理到投顾Agent(错误码映射见 agent_client);
- save 时本地镜像一份 advisor_report(send_status=draft),实现 A5「以最后保存快照为准」;
- send 为工作台本地实现:签约兜底 + 三检终审(敏感词/适当性/免责)+ 写 sys_message
与 advisor_report(sent) 同事务;Agent 不感知 sent。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from common_const import (
AGENT_INTENT_RECOMMEND,
CUSTOMER_REL_STATUS_SIGNED,
INTENT_TO_MSG_TYPE,
MSG_TYPE_RECOMMEND,
REPORT_SEND_STATUS_DISCARDED,
REPORT_SEND_STATUS_DRAFT,
REPORT_SEND_STATUS_SENT,
)
from model.advisor_report import AdvisorReport
from model.sys_message import SysMessage
from model.sys_user import SysUser
from repositories.advisor_report import AdvisorReportRepo
from repositories.product import ProductRepo
from repositories.risk_assessment import CustomerProfileRepo
from repositories.sensitive_word import SensitiveWordRepo
from repositories.sys_message import SysMessageRepo
from schemas.advisor import DraftSaveReq, RebalanceRunReq, TalkScriptReq
from service.advisor.agent_client import get_agent_client
from service.advisor.permissions import ensure_customer_owned, require_owned_relation
from service.advisor.send_review import review_send
from utils.exceptions import ForbiddenError, ParamError
# 建议清单中「低配申购」侧键(Agent 侧 suggestions 结构,文档未给字段级定义,按合理约定)
# 假设:suggestions 为 dict,申购清单在 buy/purchase/低配申购/申购 任一键下,项含 product_code。
_BUY_KEYS = ("buy", "purchase", "低配申购", "申购")
def gen_report_id() -> str:
"""生成本地报告唯一 ID(advisor_report.report_id)。"""
return "rpt-" + uuid.uuid4().hex
def _build_report_from_detail(detail: dict, advisor_id: int) -> AdvisorReport:
"""由 Agent 草稿详情构建本地报告镜像(未 save 直接 send 的兜底,D6)。"""
return AdvisorReport(
report_id=gen_report_id(),
draft_id=str(detail.get("draft_id") or ""),
customer_id=int(detail.get("customer_id") or 0),
advisor_id=advisor_id,
intent=detail.get("intent") or AGENT_INTENT_RECOMMEND,
title=detail.get("title"),
content=detail.get("content"),
edit_history=[],
send_status=REPORT_SEND_STATUS_DRAFT,
)
def _extract_buy_codes(detail: Any) -> list[str]:
"""从 Agent 草稿详情提取发送终审需要校验的产品代码。
兼容旧的顶层 ``suggestions``,以及 Agent 当前的 ``structured_data``:调仓只取
``buy``,推荐取 ``items``。解析失败返回空列表,交由其它终审规则继续处理。
"""
if not isinstance(detail, dict):
return []
structured_data = detail.get("structured_data")
data = structured_data if isinstance(structured_data, dict) else detail
suggestions = data.get("suggestions")
if isinstance(suggestions, dict):
data = suggestions
items: list | None = None
for key in _BUY_KEYS:
value = data.get(key)
if isinstance(value, list):
items = value
break
if items is None and isinstance(data.get("items"), list):
items = data["items"]
codes: list[str] = []
for it in items or []:
if isinstance(it, dict):
code = it.get("product_code") or it.get("code") or it.get("fund_code")
if code:
codes.append(str(code))
return codes
async def _get_customer_risk(db: AsyncSession, customer_id: int) -> str | None:
"""客户当前风险等级(适当性硬依据)。
假设:取 fin_customer_profile.risk_level(问卷服务在风评后回写,与 fin_risk_assessment
保持一致),作为「当前有效 C 级」。
"""
profile = await CustomerProfileRepo(db).get_by_customer(customer_id)
return profile.risk_level if profile else None
async def _resolve_product_risks(
db: AsyncSession, draft_id: str, auth_header: str, trace_id: str
) -> list[str | None]:
"""适当性输入:取草稿建议清单的申购产品,查 fin_product 得 R 级。
本地镜像不存 suggestions,故发送时向 Agent 实时取一次结构化清单;Agent 不可用则
发送被 fail-closed 阻断(合规优先,符合 PRD「不可完全依赖 Agent」+ 双重防护)。
"""
result = await get_agent_client().draft_detail(
draft_id, auth_header=auth_header, trace_id=trace_id
)
codes = _extract_buy_codes(result["data"] or {})
product_repo = ProductRepo(db)
risks: list[str | None] = []
for code in codes:
product = await product_repo.get_by_code(code)
risks.append(product.risk_level if product else None)
return risks
def _sent_payload(report: AdvisorReport) -> dict:
return {
"report_id": report.report_id,
"send_time": report.send_time.isoformat() if report.send_time else None,
"msg_id": report.msg_id,
}
# ---------------------------------------------------------------------------
# 草稿代理(读取/编辑/废弃)
# ---------------------------------------------------------------------------
async def list_drafts(
db: AsyncSession,
user: SysUser,
*,
auth_header: str,
trace_id: str,
customer_id: int | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict:
result = await get_agent_client().draft_list(
auth_header=auth_header,
trace_id=trace_id,
advisor_id=user.id,
customer_id=customer_id,
status=status,
page=page,
page_size=page_size,
)
data = result["data"] or {}
if result["warning"]:
data["warning"] = result["warning"]
return data
async def get_draft(
db: AsyncSession, user: SysUser, *, auth_header: str, trace_id: str, draft_id: str
) -> dict:
result = await get_agent_client().draft_detail(
draft_id, auth_header=auth_header, trace_id=trace_id
)
data = result["data"] or {}
# 数据权限:草稿归属客户必须属于当前投顾;无法确认归属时拒绝(fail-closed)
customer_id = data.get("customer_id")
if customer_id is None:
raise ForbiddenError("无法校验草稿归属客户")
await ensure_customer_owned(db, user.id, int(customer_id))
if result["warning"]:
data["warning"] = result["warning"]
return data
async def _upsert_report_mirror(
db: AsyncSession, user: SysUser, draft_id: str, detail: dict, req: DraftSaveReq
) -> None:
"""save 后本地镜像快照(send_status=draft),记录编辑留痕。
已发送(sent)的快照不回退为 draft,避免覆盖已交付内容。
"""
repo = AdvisorReportRepo(db)
report = await repo.get_by_draft_id(draft_id)
edit_entry = {
"editor": user.id,
"time": datetime.now().isoformat(),
"title": req.title,
}
if report is None:
report = AdvisorReport(
report_id=gen_report_id(),
draft_id=draft_id,
customer_id=int(detail["customer_id"]),
advisor_id=user.id,
intent=detail.get("intent") or AGENT_INTENT_RECOMMEND,
title=req.title,
content=req.content,
edit_history=[edit_entry],
send_status=REPORT_SEND_STATUS_DRAFT,
)
db.add(report)
elif report.send_status != REPORT_SEND_STATUS_SENT:
report.title = req.title
report.content = req.content
report.edit_history = list(report.edit_history or []) + [edit_entry]
report.send_status = REPORT_SEND_STATUS_DRAFT
await db.commit()
async def save_draft(
db: AsyncSession,
user: SysUser,
*,
auth_header: str,
trace_id: str,
draft_id: str,
req: DraftSaveReq,
) -> dict:
# 1) 先取草稿确认归属(避免对无权限草稿执行写操作),并拿到 intent/customer_id
detail = await get_draft(db, user, auth_header=auth_header, trace_id=trace_id, draft_id=draft_id)
# 2) 调 Agent 保存(Agent 重新适当性校验,违规 40020;缺免责仅告警不阻断)
payload = {
"title": req.title,
"content": req.content,
"structured_data": req.suggestions,
}
result = await get_agent_client().draft_save(
draft_id, payload, auth_header=auth_header, trace_id=trace_id
)
# 3) 本地镜像快照
await _upsert_report_mirror(db, user, draft_id, detail, req)
data = result["data"] or {}
if result["warning"]:
data["warning"] = result["warning"]
return data
async def discard_draft(
db: AsyncSession, user: SysUser, *, auth_header: str, trace_id: str, draft_id: str
) -> dict:
# 1) 确认归属(避免越权废弃)
await get_draft(db, user, auth_header=auth_header, trace_id=trace_id, draft_id=draft_id)
# 2) 回写 Agent 归档(Agent 不可用则废弃失败,本地不翻转,保持一致)
result = await get_agent_client().draft_operate(
draft_id, "discard", auth_header=auth_header, trace_id=trace_id
)
# 3) 本地镜像翻转 discarded(已 sent 的终态不回退)
report = await AdvisorReportRepo(db).get_by_draft_id(draft_id)
if report is not None and report.send_status != REPORT_SEND_STATUS_SENT:
report.send_status = REPORT_SEND_STATUS_DISCARDED
await db.commit()
data = result["data"] or {}
if result["warning"]:
data["warning"] = result["warning"]
return data
# ---------------------------------------------------------------------------
# 发送(工作台本地,Agent 不感知)
# ---------------------------------------------------------------------------
async def send_draft(
db: AsyncSession, user: SysUser, *, auth_header: str, trace_id: str, draft_id: str
) -> dict:
repo = AdvisorReportRepo(db)
report = await repo.get_by_draft_id(draft_id)
# 无快照:未 save 直接发送,先拉 Agent 草稿建快照(D6 fallback)
if report is None:
detail = await get_draft(db, user, auth_header=auth_header, trace_id=trace_id, draft_id=draft_id)
report = _build_report_from_detail(detail, user.id)
elif report.advisor_id != user.id:
raise ForbiddenError("无权操作该客户数据")
if report.send_status == REPORT_SEND_STATUS_DISCARDED:
raise ParamError("已废弃的报告不可发送")
# 幂等:已发送直接返回(重复点击不重复发站内信)
if report.send_status == REPORT_SEND_STATUS_SENT:
return {**_sent_payload(report), "duplicated": True}
# 数据权限 + 签约状态实时兜底(发送前实时查询,PRD §4.5.2)
rel = await require_owned_relation(db, user.id, report.customer_id)
if rel.status != CUSTOMER_REL_STATUS_SIGNED:
raise ForbiddenError("客户尚未签约,禁止发送报告")
# 发送终审(三检全过才放行;适当性需 Agent 侧结构化建议清单)
customer_risk = await _get_customer_risk(db, report.customer_id)
product_risks = await _resolve_product_risks(db, draft_id, auth_header, trace_id)
sensitive_words = [w.word for w in await SensitiveWordRepo(db).list_active()]
review_send(
content=report.content,
title=report.title,
customer_risk=customer_risk,
product_risks=product_risks,
sensitive_words=sensitive_words,
)
# 提交结果不确定后重试时,先复用已经写入的站内信,避免重复触达客户。
existing_message = await SysMessageRepo(db).get_by_biz_id(
report.report_id, user_id=report.customer_id
)
if existing_message is not None:
report.send_status = REPORT_SEND_STATUS_SENT
report.send_time = report.send_time or existing_message.create_time
report.send_by = report.send_by or user.id
report.msg_id = existing_message.id
db.add(report)
await db.commit()
return {**_sent_payload(report), "duplicated": True}
# 写站内信 + 报告置 sent,同事务(失败可重试,避免假送达)
msg_type = INTENT_TO_MSG_TYPE.get(report.intent, MSG_TYPE_RECOMMEND)
message = SysMessage(
user_id=report.customer_id,
msg_type=msg_type,
title=report.title or msg_type,
content=(report.content or "")[:512],
biz_id=report.report_id,
)
report.send_status = REPORT_SEND_STATUS_SENT
report.send_time = datetime.now()
report.send_by = user.id
db.add(message)
db.add(report) # 已跟踪对象时无副作用,新对象时入 session
try:
await db.flush() # 生成 message.id,供回填 msg_id
report.msg_id = message.id
await db.commit()
except Exception:
await db.rollback()
raise
return _sent_payload(report)
# ---------------------------------------------------------------------------
# 调仓触发 / 话术(代理 Agent)
# ---------------------------------------------------------------------------
async def run_rebalance(
db: AsyncSession, user: SysUser, *, auth_header: str, trace_id: str, req: RebalanceRunReq
) -> dict:
# 本地先校验归属 + 签约,快速失败(Agent 侧 40030 为兜底);未签约不生成待办
rel = await require_owned_relation(db, user.id, req.customer_id)
if rel.status != CUSTOMER_REL_STATUS_SIGNED:
raise ParamError("该客户尚未签约,不支持生成调仓建议")
result = await get_agent_client().rebalance_run(
req.customer_id, auth_header=auth_header, trace_id=trace_id
)
return result["data"] or {"accepted": True}
async def generate_talk_script(
db: AsyncSession, user: SysUser, *, auth_header: str, trace_id: str, req: TalkScriptReq
) -> dict:
await ensure_customer_owned(db, user.id, req.customer_id)
result = await get_agent_client().generate_talk_script(
req.customer_id, req.scene_type, auth_header=auth_header, trace_id=trace_id
)
data = result["data"] or {}
if result["warning"]:
data["warning"] = result["warning"]
return data