"""投后服务与陪伴:回访留痕 + 合规话术库 + 触达日志 + AI 话术(代理 Agent)。 PRD §4.4:回访对话不经过投顾Agent,人工录入仅留痕,不更新记忆单元; AI 话术仅作参考,正式发送走工作台消息模块。 """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from model.advisor_visit_record import AdvisorVisitRecord from model.sys_user import SysUser from repositories.advisor_visit_record import AdvisorVisitRecordRepo from repositories.sys_message import SysMessageRepo from schemas.advisor import VisitCreateReq, VisitUpdateReq from service.advisor.permissions import ensure_customer_owned from service.advisor.audit_writer import write_audit from utils.exceptions import NotFoundError, ParamError from utils.pagination import normalize_pagination, pagination_result # 合规话术库(内置,标准化投教/市场解读/调仓沟通)。 # 假设:V1.0 内置常量,后续可迁移 sys_config 运营化;内容不含承诺收益等敏感词。 TALK_TEMPLATES = [ {"scene": "市场波动安抚", "content": "市场短期波动属正常现象,请结合自身风险承受能力理性看待,勿因短期涨跌追涨杀跌。"}, {"scene": "调仓沟通", "content": "本次调整旨在使组合回归目标配置,不构成任何收益承诺,请结合自身情况审慎判断。"}, {"scene": "风险测评到期提醒", "content": "您的风险测评即将到期,为保障适当性匹配,请及时完成最新测评。"}, ] def _visit_item(v: AdvisorVisitRecord) -> dict: return { "id": v.id, "customer_id": v.customer_id, "advisor_id": v.advisor_id, "visit_type": v.visit_type, "visit_time": v.visit_time.isoformat(), "summary": v.summary, "audio_url": v.audio_url, "create_time": v.create_time.isoformat() if v.create_time else None, } async def list_visits( db: AsyncSession, user: SysUser, *, customer_id: int | None = None, page: int = 1, page_size: int = 10, ) -> dict: page, page_size, offset = normalize_pagination(page, page_size) if customer_id is not None: await ensure_customer_owned(db, user.id, customer_id) repo = AdvisorVisitRecordRepo(db) items = await repo.list_by_advisor( advisor_id=user.id, customer_id=customer_id, limit=page_size, offset=offset, ) total = await repo.count_by_advisor(advisor_id=user.id, customer_id=customer_id) return pagination_result( [_visit_item(v) for v in items], total, page=page, page_size=page_size ) async def create_visit(db: AsyncSession, user: SysUser, req: VisitCreateReq) -> dict: await ensure_customer_owned(db, user.id, req.customer_id) record = AdvisorVisitRecord( customer_id=req.customer_id, advisor_id=user.id, visit_type=req.visit_type, visit_time=req.visit_time, summary=req.summary, audio_url=req.audio_url, ) record = await AdvisorVisitRecordRepo(db).add(record) await write_audit( db, user_id=user.id, username=user.username, module="advisor", action="visit_create", target=str(record.id), detail={"customer_id": req.customer_id}, ) return {"visit_id": record.id} async def get_visit(db: AsyncSession, user: SysUser, visit_id: int) -> dict: record = await AdvisorVisitRecordRepo(db).get_by_advisor(visit_id, user.id) if record is None: raise NotFoundError("回访记录不存在") return _visit_item(record) async def update_visit( db: AsyncSession, user: SysUser, visit_id: int, req: VisitUpdateReq ) -> dict: changes = req.model_dump(exclude_unset=True) if not changes: raise ParamError("至少提供一项回访记录修改内容") record = await AdvisorVisitRecordRepo(db).get_by_advisor(visit_id, user.id) if record is None: raise NotFoundError("回访记录不存在") for field, value in changes.items(): setattr(record, field, value) await db.commit() await db.refresh(record) await write_audit( db, user_id=user.id, username=user.username, module="advisor", action="visit_update", target=str(record.id), detail={"customer_id": record.customer_id, "fields": list(changes)}, ) return _visit_item(record) def list_talk_templates() -> list[dict]: """合规话术库(内置,投顾参考;不自动发送)。""" return TALK_TEMPLATES async def list_touch_logs( db: AsyncSession, user: SysUser, customer_id: int, *, page: int = 1, page_size: int = 10 ) -> dict: """触达留痕:站内信(发送触达)+ 回访记录(人工沟通)聚合。 假设:V1.0 触达通道仅站内信与回访;短信/企微/电话统一落在回访记录。 """ page, page_size, offset = normalize_pagination(page, page_size) await ensure_customer_owned(db, user.id, customer_id) messages = await SysMessageRepo(db).list_by_user( customer_id, limit=page_size, offset=offset ) visits = await AdvisorVisitRecordRepo(db).list_by_advisor( advisor_id=user.id, customer_id=customer_id, limit=page_size, offset=offset, ) return { "messages": [ { "id": m.id, "msg_type": m.msg_type, "title": m.title, "content": m.content, "create_time": m.create_time.isoformat() if m.create_time else None, } for m in messages ], "visits": [_visit_item(v) for v in visits], }