Merge pull request 'feat:投顾agent模块优化' (#18) from develop_feature_qianduan into develop

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-09-13 21:26:05 +08:00
3 changed files with 120 additions and 15 deletions
+115 -12
View File
@@ -2,11 +2,11 @@
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
@@ -37,6 +37,7 @@ 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,
@@ -87,6 +88,48 @@ def _advisor_runtime(request: Request):
return getattr(getattr(app, "state", None), "advisor_agent_runtime", None)
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]:
@@ -143,29 +186,89 @@ async def _run_rebalance_background(
@router.post("/chat/stream")
async def chat_stream(
request: Request,
body: dict,
body: AdvisorChatReq,
user: SysUser = Depends(audited_advisor),
db: AsyncSession = Depends(get_db),
):
trace_id = _trace_id(request)
try:
chat_request = AdvisorChatReq.model_validate(body)
except ValidationError:
payload = agent_failure(
ERR_CODE_FORBIDDEN_CUSTOMER,
"对话请求缺少有效客户范围或参数",
trace_id=trace_id,
chat_request = body
customer_id = chat_request.customer_id
inferred_intent = chat_request.intent or _infer_chat_intent(chat_request.query)
# 请求体只传问题时,从问题中解析客户;解析结果仍必须经过投顾关系授权校验。
if customer_id is None:
customer_id, resolve_error = await _resolve_customer_from_query(
db,
advisor_id=user.id,
query=chat_request.query,
)
if resolve_error:
payload = agent_failure(
ERR_CODE_FORBIDDEN_CUSTOMER,
resolve_error,
trace_id=trace_id,
)
async def resolve_error_events():
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n"
return StreamingResponse(
resolve_error_events(),
media_type="text/event-stream",
headers={"X-Trace-Id": trace_id},
)
else:
payload = None
# 不带客户编号时只提供通用基金问答,不读取客户画像,也不生成个性化草稿。
if customer_id is None:
if inferred_intent in {
AGENT_INTENT_RECOMMEND,
"rebalance",
"fund_analysis",
"dialogue-script",
}:
if payload is None:
payload = agent_failure(
ERR_CODE_FORBIDDEN_CUSTOMER,
"个性化投顾分析需要在问题中明确客户编号或姓名",
trace_id=trace_id,
)
else:
runtime = _advisor_runtime(request)
llm_client = getattr(runtime, "llm_client", None)
if llm_client is None:
answer = "已收到问题。当前未配置通用投顾模型,请选择客户后使用个性化分析,或联系管理员配置 Agent 服务。"
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": "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},
)
else:
customer_id = chat_request.customer_id
relation = await ensure_customer_access(
db, advisor_id=user.id, customer_id=int(customer_id)
)
if chat_request.intent == AGENT_INTENT_RECOMMEND:
if inferred_intent == AGENT_INTENT_RECOMMEND:
memories = await _recall_advisor_memories(
request,
customer_id=int(customer_id),
query=chat_request.query or "",
query=chat_request.query,
)
runtime = _advisor_runtime(request)
context = await load_recommendation_context(
+2 -1
View File
@@ -12,6 +12,7 @@ pymilvus
neo4j
python-dotenv
pypdf
sqlglot
fastapi~=0.141.1
sqlalchemy~=2.0.52
@@ -34,4 +35,4 @@ neo4j~=6.3.0
redis~=8.1.0
pymilvus~=3.0.1
pydantic-settings~=2.15.0
pyjwt~=2.13.0
pyjwt~=2.13.0
+3 -2
View File
@@ -32,14 +32,15 @@ class AdvisorRebalanceRunReq(BaseModel):
class AdvisorChatReq(BaseModel):
customer_id: int = Field(gt=0)
# 通用投顾问答不需要客户上下文;个性化推荐时再传入客户编号。
customer_id: int | None = Field(default=None, gt=0)
intent: Literal[
AGENT_INTENT_RECOMMEND,
AGENT_INTENT_REBALANCE,
AGENT_INTENT_FUND_ANALYSIS,
AGENT_INTENT_DIALOGUE_SCRIPT,
] | None = None
query: str | None = Field(default=None, max_length=4000)
query: str = Field(min_length=1, max_length=4000)
class AdvisorFundAnalysisReq(BaseModel):