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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import ValidationError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from agent.advisor_agent.auth import ensure_customer_access
|
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 config.database import mysql, redis as redis_db
|
||||||
from model.sys_user import SysUser
|
from model.sys_user import SysUser
|
||||||
from repositories.advisor_draft import AdvisorDraftRepo
|
from repositories.advisor_draft import AdvisorDraftRepo
|
||||||
|
from repositories.customer_relation import CustomerRelationRepo
|
||||||
from service.advisor_agent.context import (
|
from service.advisor_agent.context import (
|
||||||
load_fund_analysis_context,
|
load_fund_analysis_context,
|
||||||
load_customer_risk,
|
load_customer_risk,
|
||||||
@@ -87,6 +88,48 @@ def _advisor_runtime(request: Request):
|
|||||||
return getattr(getattr(app, "state", None), "advisor_agent_runtime", None)
|
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(
|
async def _recall_advisor_memories(
|
||||||
request: Request, *, customer_id: int, query: str
|
request: Request, *, customer_id: int, query: str
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -143,29 +186,89 @@ async def _run_rebalance_background(
|
|||||||
@router.post("/chat/stream")
|
@router.post("/chat/stream")
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: dict,
|
body: AdvisorChatReq,
|
||||||
user: SysUser = Depends(audited_advisor),
|
user: SysUser = Depends(audited_advisor),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
trace_id = _trace_id(request)
|
trace_id = _trace_id(request)
|
||||||
try:
|
chat_request = body
|
||||||
chat_request = AdvisorChatReq.model_validate(body)
|
customer_id = chat_request.customer_id
|
||||||
except ValidationError:
|
inferred_intent = chat_request.intent or _infer_chat_intent(chat_request.query)
|
||||||
payload = agent_failure(
|
|
||||||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
# 请求体只传问题时,从问题中解析客户;解析结果仍必须经过投顾关系授权校验。
|
||||||
"对话请求缺少有效客户范围或参数",
|
if customer_id is None:
|
||||||
trace_id=trace_id,
|
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:
|
else:
|
||||||
customer_id = chat_request.customer_id
|
|
||||||
relation = await ensure_customer_access(
|
relation = await ensure_customer_access(
|
||||||
db, advisor_id=user.id, customer_id=int(customer_id)
|
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(
|
memories = await _recall_advisor_memories(
|
||||||
request,
|
request,
|
||||||
customer_id=int(customer_id),
|
customer_id=int(customer_id),
|
||||||
query=chat_request.query or "",
|
query=chat_request.query,
|
||||||
)
|
)
|
||||||
runtime = _advisor_runtime(request)
|
runtime = _advisor_runtime(request)
|
||||||
context = await load_recommendation_context(
|
context = await load_recommendation_context(
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ pymilvus
|
|||||||
neo4j
|
neo4j
|
||||||
python-dotenv
|
python-dotenv
|
||||||
pypdf
|
pypdf
|
||||||
|
sqlglot
|
||||||
|
|
||||||
fastapi~=0.141.1
|
fastapi~=0.141.1
|
||||||
sqlalchemy~=2.0.52
|
sqlalchemy~=2.0.52
|
||||||
@@ -34,4 +35,4 @@ neo4j~=6.3.0
|
|||||||
redis~=8.1.0
|
redis~=8.1.0
|
||||||
pymilvus~=3.0.1
|
pymilvus~=3.0.1
|
||||||
pydantic-settings~=2.15.0
|
pydantic-settings~=2.15.0
|
||||||
pyjwt~=2.13.0
|
pyjwt~=2.13.0
|
||||||
|
|||||||
@@ -32,14 +32,15 @@ class AdvisorRebalanceRunReq(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdvisorChatReq(BaseModel):
|
class AdvisorChatReq(BaseModel):
|
||||||
customer_id: int = Field(gt=0)
|
# 通用投顾问答不需要客户上下文;个性化推荐时再传入客户编号。
|
||||||
|
customer_id: int | None = Field(default=None, gt=0)
|
||||||
intent: Literal[
|
intent: Literal[
|
||||||
AGENT_INTENT_RECOMMEND,
|
AGENT_INTENT_RECOMMEND,
|
||||||
AGENT_INTENT_REBALANCE,
|
AGENT_INTENT_REBALANCE,
|
||||||
AGENT_INTENT_FUND_ANALYSIS,
|
AGENT_INTENT_FUND_ANALYSIS,
|
||||||
AGENT_INTENT_DIALOGUE_SCRIPT,
|
AGENT_INTENT_DIALOGUE_SCRIPT,
|
||||||
] | None = None
|
] | None = None
|
||||||
query: str | None = Field(default=None, max_length=4000)
|
query: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
class AdvisorFundAnalysisReq(BaseModel):
|
class AdvisorFundAnalysisReq(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user