"""投顾 Agent HTTP 契约入口与本地业务编排。""" from __future__ import annotations import json import re from typing import Literal from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request from fastapi.responses import StreamingResponse from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from agent.advisor_agent.auth import ensure_customer_access from agent.advisor_agent.data_query import execute_advisor_data_query from agent.advisor_agent.intent.fund_analysis import build_fund_analysis from agent.advisor_agent.intent.classifier import classify_advisor_intent from agent.advisor_agent.intent.talk_script import build_talk_script from agent.advisor_agent.llm import generate_text from agent.advisor_agent.intent.generation_flow import ( generate_rebalance_draft, generate_recommendation_draft, ) from agent.advisor_agent.protocol import agent_failure, agent_success from common.common_const import ( AGENT_INTENT_CASUAL_CHAT, AGENT_INTENT_DATA_QUERY, AGENT_INTENT_DIALOGUE_SCRIPT, AGENT_INTENT_FUND_ANALYSIS, AGENT_INTENT_REBALANCE, AGENT_INTENT_RECOMMEND, CUSTOMER_REL_STATUS_SIGNED, ERR_CODE_DRAFT_NOT_FOUND, ERR_CODE_FORBIDDEN_CUSTOMER, ERR_CODE_LLM_ERROR, ERR_CODE_NOT_SIGNED_REBALANCE, DRAFT_STATUS_DISCARDED, DRAFT_STATUS_DRAFT, SSE_EVENT_TYPE_DONE, SSE_EVENT_TYPE_ERROR, SSE_EVENT_TYPE_META, SSE_EVENT_TYPE_TEXT, TALK_SCENE_CUSTOMER_COMPLAINT, TALK_SCENE_MARKET_FLUCTUATION, TALK_SCENE_PORTFOLIO_DIVERGENCE, TALK_SCENE_RISK_BLOCK_ORDER, ) from api.deps import audited_advisor from config.deps import get_db from config.database import mysql, redis as redis_db from model.sys_user import SysUser from repositories.advisor_draft import AdvisorDraftRepo from repositories.customer_relation import CustomerRelationRepo from service.advisor_agent.context import ( load_fund_analysis_context, load_customer_risk, load_rebalance_context, load_recommendation_context, ) from service.advisor_agent.customer_context import load_customer_context from service.event_publisher import publish_event from nl2sql.session_context import SessionContextStore, build_conversation_context from schemas.advisor_agent import ( AdvisorDraftOperateReq, AdvisorDraftSaveReq, AdvisorChatReq, AdvisorFundAnalysisReq, AdvisorRebalanceRunReq, AdvisorTalkScriptReq, AdvisorDataQueryReq, ) from service.nl2sql.query_service import QueryServiceError from service.advisor_agent.draft import ( detail_draft, discard_draft, ensure_draft_owner, get_draft as get_draft_service, list_drafts as list_drafts_service, save_draft as save_draft_service, ) from utils.exceptions import ApiError from utils.request_id import get_request_id, new_request_id from utils.logger import get_logger router = APIRouter(prefix="/advisor-agent") logger = get_logger("advisor_agent.router") _NOT_READY_CODE = ERR_CODE_LLM_ERROR _NOT_READY_MESSAGE = "投顾 Agent 核心能力尚未初始化" _DRAFT_NOT_FOUND_CODE = ERR_CODE_DRAFT_NOT_FOUND _DRAFT_NOT_FOUND_MESSAGE = "草稿不存在或者已废弃" def _trace_id(request: Request) -> str: return request.headers.get("X-Trace-Id") or get_request_id() or new_request_id() def _not_ready(request: Request): return agent_failure(_NOT_READY_CODE, _NOT_READY_MESSAGE, trace_id=_trace_id(request)) def _data_query_error_message(exc: QueryServiceError) -> str: """Expose dependency outages without leaking SQL or database details.""" message = str(exc) if message.startswith("投顾 Agent 依赖服务不可用"): return message return "客户数据查询失败,请稍后重试" def _advisor_runtime(request: Request): app = request.scope.get("app") return getattr(getattr(app, "state", None), "advisor_agent_runtime", None) @router.get("/session/{session_id}/history") async def advisor_session_history( session_id: str, user: SysUser = Depends(audited_advisor), ): """读取当前投顾自己的短期 Agent 会话记录。""" messages = await SessionContextStore(redis_db.client()).load(user.id, session_id) return agent_success(messages, trace_id=new_request_id()) def _infer_chat_intent(query: str) -> str | None: """从自然语言问题推断投顾意图;无法确定时保留通用问答。""" if any(word in query for word in ("调仓", "再平衡", "组合偏离")): return "rebalance" if any(word in query for word in ("沟通话术", "怎么和客户说", "解释给客户")): return "dialogue-script" if any(word in query for word in ("基金分析", "分析这只基金", "分析产品")): return "fund_analysis" if any(word in query for word in ("推荐", "产品建议", "买什么基金", "适合的基金")): return AGENT_INTENT_RECOMMEND return None async def _resolve_customer_from_query(db, *, advisor_id: int, query: str) -> tuple[int | None, str | None]: """解析问题中的客户编号或姓名,并限制在当前投顾客户范围内。""" number_match = re.search(r"(?:客户|用户)\s*[#编号号:]?\s*(\d+)", query) relation_repo = CustomerRelationRepo(db) if number_match: customer_id = int(number_match.group(1)) relation = await relation_repo.get_active_relation( customer_id=customer_id, advisor_id=advisor_id, ) if relation is None: return None, "问题中的客户不在当前投顾的授权范围内" return customer_id, None rows = await relation_repo.list_customer_rows(advisor_id=advisor_id, limit=100) matched = { int(account.id) for _relation, account, _profile in rows if account.real_name and account.real_name in query } if len(matched) == 1: return next(iter(matched)), None if len(matched) > 1: return None, "问题中的客户姓名无法唯一确定,请补充客户编号" if "客户" in query or "用户" in query: return None, "请在问题中补充客户编号或客户姓名" return None, None async def _recall_advisor_memories( request: Request, *, customer_id: int, query: str ) -> list[dict]: """从应用共享 runtime 读取记忆;未装配或故障时安全降级为空。""" app = request.scope.get("app") state = getattr(app, "state", None) runtime = getattr(state, "advisor_agent_runtime", None) provider = getattr(runtime, "memory_provider", None) if provider is None: return [] try: return await provider.recall(customer_id=customer_id, query=query) except Exception: logger.warning( "advisor memory recall failed: customer_id=%s", customer_id, exc_info=True ) return [] async def _ensure_draft_access(db, draft, advisor_id: int) -> None: ensure_draft_owner(draft, advisor_id=advisor_id) await ensure_customer_access( db, advisor_id=advisor_id, customer_id=draft.customer_id, ) async def _run_rebalance_background( *, advisor_id: int, customer_id: int, trace_id: str ) -> None: """后台任务使用独立会话,避免请求返回后复用已关闭的请求会话。""" try: async with mysql.get_session_factory()() as task_db: relation = await ensure_customer_access( task_db, advisor_id=advisor_id, customer_id=customer_id ) if relation.status != CUSTOMER_REL_STATUS_SIGNED: return context = await load_rebalance_context(task_db, customer_id=customer_id) if context is None: return await generate_rebalance_draft( draft_repo=AdvisorDraftRepo(task_db), publish=lambda **kwargs: publish_event(redis_db.client(), task_db, **kwargs), advisor_id=advisor_id, trace_id=trace_id, **context, ) except Exception: logger.warning("advisor rebalance background task failed", exc_info=True) @router.post("/chat/stream") async def chat_stream( request: Request, body: AdvisorChatReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): trace_id = _trace_id(request) try: chat_request = ( body if isinstance(body, AdvisorChatReq) else AdvisorChatReq.model_validate(body) ) except ValidationError: payload = agent_failure( ERR_CODE_FORBIDDEN_CUSTOMER, "对话请求缺少有效参数", trace_id=trace_id, ) async def validation_error_events(): yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n" return StreamingResponse( validation_error_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) runtime = _advisor_runtime(request) classification = await classify_advisor_intent( chat_request.query, getattr(runtime, "llm_client", None), explicit_intent=None, ) inferred_intent = classification.intent scope = chat_request.scope customer_id = chat_request.customer_id if not chat_request.query: code = ERR_CODE_FORBIDDEN_CUSTOMER message = "对话请求缺少有效参数" payload = agent_failure(code, message, trace_id=trace_id) async def empty_query_events(): yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n" return StreamingResponse( empty_query_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) payload = None # 客户 ID 是可选上下文;未选择客户时尝试从自然语言识别姓名,失败不阻断 Agent 判断。 if customer_id is not None: await ensure_customer_access(db, advisor_id=user.id, customer_id=int(customer_id)) else: resolved_customer_id, _resolve_error = await _resolve_customer_from_query( db, advisor_id=user.id, query=chat_request.query ) if resolved_customer_id is not None: customer_id = resolved_customer_id scope = "customer" if customer_id is None: if inferred_intent == AGENT_INTENT_DATA_QUERY: scope = "advisor" else: llm_client = getattr(runtime, "llm_client", None) if inferred_intent == AGENT_INTENT_CASUAL_CHAT: answer = "您好,我是投顾助手,可以协助您查询名下客户数据、分析基金和生成投顾辅助方案。" elif llm_client is None: answer = "已收到问题。当前未配置通用投顾模型,但您可以直接查询名下客户数据。" else: answer = await generate_text( llm_client, system_prompt="你是基金投顾助手。投顾未指定单个客户时,你可以回答通用问题或说明需要的客户范围;不得越权读取客户数据,不承诺收益,不代客交易。", user_prompt=chat_request.query, fallback=lambda: "当前模型暂时不可用,请稍后重试。", timeout=5.0, ) async def events(): for event in ( {"type": SSE_EVENT_TYPE_META, "intent": inferred_intent or "general_question"}, {"type": SSE_EVENT_TYPE_TEXT, "content": answer}, {"type": SSE_EVENT_TYPE_DONE}, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) if inferred_intent == AGENT_INTENT_DATA_QUERY: effective_scope = "customer" if customer_id is not None else "advisor" try: context_store = SessionContextStore(redis_db.client()) conversation_context = build_conversation_context( await context_store.load(user.id, chat_request.session_id) ) result = await execute_advisor_data_query( db, advisor_id=user.id, customer_id=int(customer_id) if customer_id is not None else None, scope=effective_scope, question=chat_request.query, trace_id=trace_id, session_id=chat_request.session_id, conversation_context=conversation_context, llm_client=getattr(_advisor_runtime(request), "llm_client", None), ) except QueryServiceError as exc: payload = agent_failure(ERR_CODE_LLM_ERROR, _data_query_error_message(exc), trace_id=trace_id) else: await context_store.append( user.id, chat_request.session_id, chat_request.query, result.get("answer") or result.get("summary") or "查询完成", ) async def events(): yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n" answer = result.get("answer") or result.get("summary") if answer: yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n" return StreamingResponse(events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}) if customer_id is not None: relation = await ensure_customer_access( db, advisor_id=user.id, customer_id=int(customer_id) ) if inferred_intent == AGENT_INTENT_RECOMMEND: memories = await _recall_advisor_memories( request, customer_id=int(customer_id), query=chat_request.query, ) runtime = _advisor_runtime(request) context = await load_recommendation_context( db, customer_id=int(customer_id) ) customer_context = await load_customer_context( db, customer_id=int(customer_id), memories=memories ) if context is not None: try: draft = await generate_recommendation_draft( draft_repo=AdvisorDraftRepo(db), advisor_id=user.id, relation_status=relation.status, trace_id=trace_id, memories=memories, llm_client=getattr(runtime, "llm_client", None), customer_context=customer_context, **context, ) except Exception: logger.warning("advisor recommendation generation failed", exc_info=True) payload = agent_failure( ERR_CODE_LLM_ERROR, "推荐方案生成失败,请稍后重试", trace_id=trace_id, ) else: async def events(): for event in ( { "type": SSE_EVENT_TYPE_META, "draft_id": draft.draft_id, "intent": draft.intent, "status": draft.status, }, {"type": SSE_EVENT_TYPE_TEXT, "content": draft.content}, {"type": SSE_EVENT_TYPE_DONE, "draft_id": draft.draft_id}, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) if inferred_intent == AGENT_INTENT_FUND_ANALYSIS: fund_codes = re.findall(r"[A-Za-z]{1,6}\d{3,8}", chat_request.query.upper()) contexts = await load_fund_analysis_context(db, fund_codes=fund_codes) if not contexts: payload = agent_failure( ERR_CODE_LLM_ERROR, "未找到可分析的基金数据", trace_id=trace_id, ) else: result = build_fund_analysis( contexts[0]["fund"], contexts[0]["performance"] ) runtime = _advisor_runtime(request) if getattr(runtime, "llm_client", None) is not None: customer_context = await load_customer_context( db, customer_id=int(customer_id), memories=await _recall_advisor_memories( request, customer_id=int(customer_id), query=chat_request.query, ), ) result["analysis_text"] = await generate_text( runtime.llm_client, system_prompt=( "你是合规的基金投顾分析助手。只能根据基金数据和客户上下文回答," "不得承诺收益,不得代客交易;如果信息不足要明确说明。" ), user_prompt=json.dumps( { "question": chat_request.query, "fund": contexts[0]["fund"], "performance": contexts[0]["performance"], "customer_context": customer_context, }, ensure_ascii=False, ), fallback=lambda: result["analysis_text"], timeout=5.0, ) async def events(): for event in ( {"type": SSE_EVENT_TYPE_META, "intent": AGENT_INTENT_FUND_ANALYSIS}, {"type": SSE_EVENT_TYPE_TEXT, "content": result["analysis_text"]}, {"type": SSE_EVENT_TYPE_DONE}, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) if inferred_intent == AGENT_INTENT_DIALOGUE_SCRIPT: if "市场" in chat_request.query or "波动" in chat_request.query: scene_type = TALK_SCENE_MARKET_FLUCTUATION elif "投诉" in chat_request.query: scene_type = TALK_SCENE_CUSTOMER_COMPLAINT elif "拦截" in chat_request.query or "风控" in chat_request.query: scene_type = TALK_SCENE_RISK_BLOCK_ORDER else: scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE result = build_talk_script(scene_type) runtime = _advisor_runtime(request) if getattr(runtime, "llm_client", None) is not None: memories = await _recall_advisor_memories( request, customer_id=int(customer_id), query=chat_request.query, ) customer_context = await load_customer_context( db, customer_id=int(customer_id), memories=memories ) result["content"] = await generate_text( runtime.llm_client, system_prompt=( "你是华夏基金合规投顾助手。请生成简洁、克制、尊重客户的沟通参考话术," "结合客户上下文但不要暴露内部字段,不承诺收益,不代客交易。" ), user_prompt=json.dumps( { "scene": scene_type, "request": chat_request.query, "customer_context": customer_context, }, ensure_ascii=False, ), fallback=lambda: result["content"], timeout=5.0, ) async def events(): for event in ( {"type": SSE_EVENT_TYPE_META, "intent": AGENT_INTENT_DIALOGUE_SCRIPT}, {"type": SSE_EVENT_TYPE_TEXT, "content": result["content"]}, {"type": SSE_EVENT_TYPE_DONE}, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) if inferred_intent == AGENT_INTENT_CASUAL_CHAT: async def events(): for event in ( {"type": SSE_EVENT_TYPE_META, "intent": AGENT_INTENT_CASUAL_CHAT}, {"type": SSE_EVENT_TYPE_TEXT, "content": "您好,我是投顾助手,可以协助您进行基金分析和投资组合管理。"}, {"type": SSE_EVENT_TYPE_DONE}, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) if payload is None: payload = agent_failure(_NOT_READY_CODE, _NOT_READY_MESSAGE, trace_id=trace_id) async def events(): yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n" return StreamingResponse( events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id}, ) @router.post("/data-query") async def advisor_data_query( request: Request, body: AdvisorDataQueryReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): """查询当前投顾选中客户的数据,不返回 SQL,也不生成草稿。""" trace_id = _trace_id(request) customer_id, resolve_error = await _resolve_customer_from_query( db, advisor_id=user.id, query=body.query ) if resolve_error or customer_id is None: return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=trace_id) try: result = await execute_advisor_data_query( db, advisor_id=user.id, customer_id=customer_id, question=body.query, trace_id=trace_id, session_id=body.session_id, max_rows=body.max_rows, page=body.page, page_size=body.page_size, sort_by=body.sort_by, sort_order=body.sort_order, ) except QueryServiceError as exc: return agent_failure( ERR_CODE_LLM_ERROR, _data_query_error_message(exc), trace_id=trace_id, ) result.pop("sql", None) return agent_success(result, trace_id=trace_id) @router.get("/draft/list") async def list_drafts( request: Request, advisor_id: int | None = Query(default=None), customer_id: int | None = Query(default=None), status: Literal[DRAFT_STATUS_DRAFT, DRAFT_STATUS_DISCARDED] | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=10, ge=1, le=100), user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): if advisor_id is not None and advisor_id != user.id: raise ApiError(ERR_CODE_FORBIDDEN_CUSTOMER, "无权操作该客户数据") if customer_id is not None: await ensure_customer_access(db, advisor_id=user.id, customer_id=customer_id) result = await list_drafts_service( AdvisorDraftRepo(db), advisor_id=user.id, customer_id=customer_id, status=status, page=page, page_size=page_size, ) return agent_success(result, trace_id=_trace_id(request)) @router.get("/draft/{draft_id}") async def get_draft( draft_id: str, request: Request, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): draft = await get_draft_service(AdvisorDraftRepo(db), draft_id) await _ensure_draft_access(db, draft, user.id) return agent_success(detail_draft(draft), trace_id=_trace_id(request)) @router.put("/draft/{draft_id}/save") async def save_draft( draft_id: str, request: Request, body: AdvisorDraftSaveReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): repo = AdvisorDraftRepo(db) draft = await get_draft_service(repo, draft_id) await _ensure_draft_access(db, draft, user.id) customer_risk = await load_customer_risk(db, customer_id=draft.customer_id) result = await save_draft_service( repo, draft_id, title=body.title, content=body.content, structured_data=body.structured_data, customer_risk=customer_risk, ) return agent_success(result, trace_id=_trace_id(request)) @router.post("/draft/{draft_id}/operate") async def operate_draft( draft_id: str, request: Request, body: AdvisorDraftOperateReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): if body.operation != "discard": raise ApiError(_DRAFT_NOT_FOUND_CODE, "不支持的草稿操作") repo = AdvisorDraftRepo(db) draft = await get_draft_service(repo, draft_id) await _ensure_draft_access(db, draft, user.id) result = await discard_draft(repo, draft_id) return agent_success(detail_draft(result), trace_id=_trace_id(request)) @router.post("/rebalance/run") async def run_rebalance( request: Request, body: AdvisorRebalanceRunReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), background_tasks: BackgroundTasks = None, ): customer_id, resolve_error = await _resolve_customer_from_query( db, advisor_id=user.id, query=body.query ) if resolve_error or customer_id is None: return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) relation = await ensure_customer_access( db, advisor_id=user.id, customer_id=int(customer_id) ) if relation.status != CUSTOMER_REL_STATUS_SIGNED: return agent_failure( ERR_CODE_NOT_SIGNED_REBALANCE, "客户尚未签约,禁止生成调仓草稿", trace_id=_trace_id(request), ) if background_tasks is None: background_tasks = BackgroundTasks() background_tasks.add_task( _run_rebalance_background, advisor_id=user.id, customer_id=customer_id, trace_id=_trace_id(request), ) return agent_success( {"accepted": True, "status": "queued"}, trace_id=_trace_id(request), ) @router.post("/fund-analysis") async def fund_analysis( request: Request, body: AdvisorFundAnalysisReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): customer_id, resolve_error = await _resolve_customer_from_query(db, advisor_id=user.id, query=body.query) if resolve_error or customer_id is None: return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) fund_codes = re.findall(r"[A-Za-z]{1,6}\d{3,8}", body.query.upper()) if not fund_codes: return agent_failure(ERR_CODE_LLM_ERROR, "请在问题中补充基金代码", trace_id=_trace_id(request)) memories = await _recall_advisor_memories(request, customer_id=customer_id, query=body.query) contexts = await load_fund_analysis_context(db, fund_codes=fund_codes) if not contexts: return _not_ready(request) if len(contexts) > 1: return agent_success( {"items": [build_fund_analysis(item["fund"], item["performance"]) for item in contexts]}, trace_id=_trace_id(request), ) result = build_fund_analysis(contexts[0]["fund"], contexts[0]["performance"]) runtime = _advisor_runtime(request) if getattr(runtime, "llm_client", None) is not None: result["analysis_text"] = await generate_text( runtime.llm_client, system_prompt="你是基金投顾助手,只基于给定历史数据生成谨慎的内部分析,不承诺收益。", user_prompt=json.dumps( {"analysis": result, "customer_memories": memories}, ensure_ascii=False, ), fallback=lambda: result["analysis_text"], timeout=5.0, ) return agent_success(result, trace_id=_trace_id(request)) @router.post("/generate-talk-script") async def generate_talk_script( request: Request, body: AdvisorTalkScriptReq, user: SysUser = Depends(audited_advisor), db: AsyncSession = Depends(get_db), ): customer_id, resolve_error = await _resolve_customer_from_query(db, advisor_id=user.id, query=body.query) if resolve_error or customer_id is None: return agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error or "请在问题中补充客户编号或客户姓名", trace_id=_trace_id(request)) scene_type = TALK_SCENE_MARKET_FLUCTUATION if "投诉" in body.query: scene_type = TALK_SCENE_CUSTOMER_COMPLAINT elif "拦截" in body.query or "风控" in body.query: scene_type = TALK_SCENE_RISK_BLOCK_ORDER elif "调仓" in body.query or "偏离" in body.query: scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE memories = await _recall_advisor_memories( request, customer_id=customer_id, query=body.query, ) try: result = build_talk_script( scene_type, customer_name="客户", ) except ValueError as exc: raise ApiError(ERR_CODE_LLM_ERROR, str(exc)) from exc runtime = _advisor_runtime(request) if getattr(runtime, "llm_client", None) is not None: result["content"] = await generate_text( runtime.llm_client, system_prompt="你是合规的基金投顾助手,只生成谨慎沟通话术,不承诺收益、不代客交易。", user_prompt=json.dumps( {"script": result, "customer_memories": memories}, ensure_ascii=False, ), fallback=lambda: result["content"], timeout=5.0, ) return agent_success(result, trace_id=_trace_id(request))