feat:新增投顾工作台相关接口

This commit is contained in:
2026-09-12 20:42:33 +08:00
parent b95fb5d4f7
commit 639a10e10a
50 changed files with 3434 additions and 14 deletions
+332
View File
@@ -0,0 +1,332 @@
"""草稿代理 + 本地镜像 + 发送终审(投顾工作台核心闭环)。
- 草稿 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 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(suggestions: Any) -> list[str]:
"""从建议清单提取「低配申购」产品代码(发送终审适当性校验对象)。
假设:结构为 dict,申购侧键见 _BUY_KEYS;项为 {product_code} 或 {code}/{fund_code}。
解析失败返回空列表(视为无结构化产品建议,适当性空过,不误拦)。
"""
if not isinstance(suggestions, dict):
return []
items: list | None = None
for key in _BUY_KEYS:
value = suggestions.get(key)
if isinstance(value, list):
items = value
break
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 {}).get("suggestions"))
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, "suggestions": 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_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,
)
# 写站内信 + 报告置 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
await db.flush() # 生成 message.id,供回填 msg_id
report.msg_id = message.id
await db.commit()
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