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:
+115
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user