"""草稿代理 + 本地镜像 + 发送终审(投顾工作台核心闭环)。 - 草稿 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 = 10, ) -> 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