Files
group_xinghuo_jinrong/tests/test_wave3_customer_service.py
T
zhanghongyu_0626 9d4d4aaa6d feat(chat): Enhance session management with new API endpoints and filtering options
- Added `status` query parameter to `list_sessions_api` for filtering sessions by their status (active/closed).
- Introduced `close_all_sessions_api` endpoint to allow users to close all active sessions for the current actor.
- Updated `SessionRepository` to support status filtering in session listing and implemented logic for closing active sessions.
- Improved Redis connection settings for better performance and reliability.

This update enhances the chat functionality by providing more control over session management, improving user experience and system efficiency.
2026-09-10 22:30:07 +08:00

418 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CS Wave 3:客户 Agent LangGraph 编排测试(mock LLM/RAG/工具/Redis)。"""
from __future__ import annotations
import pytest
from app.model.schemas import AuthContext
from app.service import customer_service as cs
from app.service.profile_service import CustomerMemoryService
# ---------------------------------------------------------------------------
# 测试替身
# ---------------------------------------------------------------------------
class FakeMsg:
def __init__(self, content: str) -> None:
self.content = content
class FakeLLM:
def __init__(self, responses: list[str]) -> None:
self.responses = list(responses)
self.calls: list[list[dict]] = []
def invoke(self, messages):
self.calls.append(messages)
return FakeMsg(self.responses.pop(0) if self.responses else "{}")
class FakeRag:
def __init__(self, context: str, sources: list[dict] | None = None) -> None:
self._context = context
self._sources = sources or []
def retrieve(self, intent, query, top_k=5):
return self._context, self._sources
def _ctx() -> AuthContext:
return AuthContext(
sub="CUST-9527",
token_type="customer",
roles=["customer"],
trace_id="t1",
agent_type="customer",
jti="j1",
)
@pytest.fixture
def env(monkeypatch, fake_redis):
"""统一替身环境:LLM / 记忆 / 热缓存 / 后台线程同步化 / 归档。"""
llm = FakeLLM([])
recorded = {"intents": [], "extract": [], "archive": [], "idle": []}
monkeypatch.setattr(cs, "_build_llm", lambda: llm)
monkeypatch.setattr(cs, "CustomerMemoryService", lambda: CustomerMemoryService(redis_client=fake_redis))
monkeypatch.setattr(cs, "ProfileHotCache", lambda repo=None: _FakeHotCache())
monkeypatch.setattr(cs, "record_intent", lambda cid, intent: recorded["intents"].append((cid, intent)))
monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a)) # 同步执行,便于断言
monkeypatch.setattr(
cs, "extract_profile",
lambda cid, window, tid="": recorded["extract"].append((cid, window, tid)) or [],
)
monkeypatch.setattr(
cs, "archive_session",
lambda sid, cid, tid, reason: recorded["archive"].append((sid, cid, tid, reason)) or True,
)
monkeypatch.setattr(
cs, "archive_idle_sessions",
lambda tid, exclude_sid="", limit=None: recorded["idle"].append((tid, exclude_sid)) or [],
)
monkeypatch.setattr("app.config.database.get_redis_client", lambda: fake_redis)
return {"llm": llm, "fake": fake_redis, "recorded": recorded}
class _FakeHotCache:
def get_style_tags(self, cid):
return {"basic": {"city": {"value": "上海", "source": "user_declared", "confidence": 0.9}}}
def invalidate(self, cid):
pass
# ---------------------------------------------------------------------------
# 意图路由(关键词快路由,零 LLM)
# ---------------------------------------------------------------------------
def test_route_reject_advice_keyword(env):
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "帮我推荐个基金", "s1", "CUST-9527")
assert intent == "reject"
assert "投资建议" in reply
assert transfer is False
assert env["llm"].calls == [] # 未消耗 LLM
def test_route_transfer_human(env):
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "转人工", "s1", "CUST-9527")
assert intent == "transfer_human"
assert transfer is True
assert "人工客服" in reply
def test_route_fallback_on_invalid_llm_label(env):
env["llm"].responses = ["what_is_this"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "今天天气不错", "s1", "CUST-9527")
assert intent == "fallback"
assert reply == cs.FALLBACK_TEXT
# ---------------------------------------------------------------------------
# 数据查询分支:param_extract → tool_call → interpret
# ---------------------------------------------------------------------------
def test_holding_query_end_to_end(env, monkeypatch):
captured: dict = {}
def fake_holdings(cid, repo=None):
captured["cid"] = cid
return {"tool": "holding_query", "ok": True, "facts": [], "error": None,
"fact_text": "您当前持有 3 只产品,合计市值 149,940.00 元。"}
monkeypatch.setitem(cs._TOOL_BY_INTENT, "holding_query", fake_holdings)
env["llm"].responses = ["您当前持有 3 只产品,合计市值 149,940.00 元,如需了解单只产品可继续询问。"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527")
assert intent == "holding_query"
assert captured["cid"] == "CUST-9527"
assert "149,940.00" in reply
assert disc is False and transfer is False
def test_transaction_query_month_extraction(env, monkeypatch):
captured: dict = {}
def fake_trades(cid, months=None, repo=None):
captured["months"] = months
return {"tool": "transaction_query", "ok": True, "facts": [], "error": None,
"fact_text": "近 6 个月共 2 笔交易。"}
monkeypatch.setitem(cs._TOOL_BY_INTENT, "transaction_query", fake_trades)
env["llm"].responses = [
'{"months": 6, "product_keyword": null, "risk_level": null}',
"您近 6 个月共 2 笔交易,均为申购。",
]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "最近半年的流水", "s1", "CUST-9527")
assert intent == "transaction_query"
assert captured["months"] == 6
assert "2 笔交易" in reply
def test_suitability_risk_level_normalized(env, monkeypatch):
captured: dict = {}
def fake_suit(cid, product_keyword=None, risk_level=None, repo=None):
captured["risk_level"] = risk_level
captured["product_keyword"] = product_keyword
return {"tool": "suitability_check", "ok": True, "facts": [], "error": None,
"fact_text": "R3 汇总:匹配 2 只,需双录 5 只,不匹配 1 只。"}
monkeypatch.setitem(cs._TOOL_BY_INTENT, "suitability_check", fake_suit)
env["llm"].responses = [
'{"months": null, "product_keyword": null, "risk_level": "r3"}',
"R3 产品中匹配 2 只、需双录 5 只、不匹配 1 只。",
]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我能买R3产品吗", "s1", "CUST-9527")
assert intent == "suitability_check"
assert captured["risk_level"] == "R3"
assert captured["product_keyword"] is None
def test_tool_error_returns_data_error_text(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "holding_query",
lambda cid, repo=None: {"tool": "holding_query", "ok": False, "facts": None,
"fact_text": "", "error": "db_down"},
)
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527")
assert intent == "holding_query"
assert reply == cs.DATA_ERROR_TEXT
def test_interpret_sanitized_on_forbidden_reply(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "holding_query",
lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None,
"fact_text": "您当前持有 3 只产品。"},
)
env["llm"].responses = ["我建议您买入更多高风险产品。"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527")
assert transfer is False
assert "3 只产品" in reply
assert "无法提供投资建议" not in reply
def test_interpret_falls_back_to_fact_text_when_llm_down(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "holding_query",
lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None,
"fact_text": "您当前持有 3 只产品。"},
)
def _boom(*a, **kw):
raise RuntimeError("llm down")
monkeypatch.setattr(cs, "_build_llm", _boom)
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "我的持仓怎么样", "s1", "CUST-9527")
assert "3 只产品" in reply # 数值不经 LLM 也能返回
# ---------------------------------------------------------------------------
# RAG 分支
# ---------------------------------------------------------------------------
def test_rag_generate_with_disclaimer(env, monkeypatch):
monkeypatch.setattr(cs, "VisitorRagService", lambda: FakeRag("债券基金以债券为主要投资标的。"))
env["llm"].responses = ["product_consult", "债券基金以债券为主要投资标的,风险相对较低。"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "债券基金是什么", "s1", "CUST-9527")
assert intent == "product_consult"
assert disc is True
assert "风险提示" in reply
# 画像注入 prompt(红线:仅语气参考)
assert "所在城市:上海" in env["llm"].calls[1][1]["content"]
def test_rag_empty_falls_back(env, monkeypatch):
monkeypatch.setattr(cs, "VisitorRagService", lambda: FakeRag(""))
env["llm"].responses = ["faq"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "怎么修改手机号", "s1", "CUST-9527")
assert intent == "fallback"
assert reply == cs.FALLBACK_TEXT
# ---------------------------------------------------------------------------
# 闲聊分支(画像语境注入)
# ---------------------------------------------------------------------------
def test_greeting_chitchat_without_llm(env, monkeypatch):
"""「你好」走关键词 chit_chat;DeepSeek 不可用时仍返回闲聊降级话术。"""
monkeypatch.setattr(
cs,
"_invoke",
lambda _s, _u: (_ for _ in ()).throw(RuntimeError("no deepseek")),
)
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "你好", "s1", "CUST-9527")
assert intent == "chit_chat"
assert "暂时无法回答" not in reply
assert "您好" in reply or "财富助手" in reply
assert transfer is False
def test_chitchat_with_profile_context(env):
env["llm"].responses = ["您好呀,很高兴为您服务。"]
reply, disc, intent, transfer = cs.run_customer_chat(_ctx(), "你好呀", "s1", "CUST-9527")
assert intent == "chit_chat"
assert "您好" in reply
assert disc is False
user_prompt = env["llm"].calls[0][1]["content"]
assert "所在城市:上海" in user_prompt
# ---------------------------------------------------------------------------
# C-05 / C-11 数据查询扩展
# ---------------------------------------------------------------------------
def test_nav_query_keyword(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "nav_query",
lambda cid, product_keyword=None, repo=None: {
"tool": "nav_query", "ok": True, "facts": [],
"fact_text": "单位净值 1.2345(净值日期 2026-09-09)",
},
)
env["llm"].responses = ["nav_query", "单位净值 1.2345(净值日期 2026-09-09)"]
reply, disc, intent, transfer = cs.run_customer_chat(
_ctx(), "005827的净值是多少", "s1", "CUST-9527",
)
assert intent == "nav_query"
assert transfer is False
assert "1.2345" in reply
def test_eligible_products_routes_suitability_not_reject(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "suitability_check",
lambda cid, product_keyword=None, risk_level=None, repo=None: {
"tool": "suitability_check", "ok": True, "facts": [],
"fact_text": "按您当前风评,在售产品适当性匹配汇总:",
},
)
env["llm"].responses = [
"suitability_check",
"按您当前风评,在售产品适当性匹配汇总:",
]
reply, disc, intent, transfer = cs.run_customer_chat(
_ctx(), "我能买什么产品", "s1", "CUST-9527",
)
assert intent == "suitability_check"
assert transfer is False
# ---------------------------------------------------------------------------
# 记忆保存 / 画像抽槽节流 / 归档
# ---------------------------------------------------------------------------
def test_save_memory_writes_consult_line(env, monkeypatch):
monkeypatch.setitem(
cs._TOOL_BY_INTENT, "holding_query",
lambda cid, repo=None: {"tool": "holding_query", "ok": True, "facts": [], "error": None,
"fact_text": "您持有 1 只产品。"},
)
env["llm"].responses = ["您持有 1 只产品。"]
reply, _, intent, _ = cs.run_customer_chat(_ctx(), "我的持仓", "s1", "CUST-9527")
mem = CustomerMemoryService(redis_client=env["fake"])
consult = mem.recall("s1", "consult")
assert [m["content"] for m in consult] == ["我的持仓", "您持有 1 只产品。"]
assert env["recorded"]["intents"] == [("CUST-9527", "holding_query")]
def test_profile_extract_triggered_every_5_rounds(env):
env["fake"].strings["customer:s1:rounds"] = "4" # 已累计 4 轮
env["llm"].responses = ["您好。"]
cs.run_customer_chat(_ctx(), "你好", "s1", "CUST-9527")
# 轮数达到 5 → 触发抽槽(env 中已同步化并记录调用),计数清零
assert env["fake"].strings.get("customer:s1:rounds") is None
assert len(env["recorded"]["extract"]) == 1
cid, window, tid = env["recorded"]["extract"][0]
assert cid == "CUST-9527"
assert tid == "t1"
assert "你好" in window # 抽槽窗口含本轮对话(双线合并)
def test_profile_extract_not_triggered_before_threshold(env):
env["fake"].strings["customer:s1:rounds"] = "1"
env["llm"].responses = ["您好。"]
cs.run_customer_chat(_ctx(), "你好", "s1", "CUST-9527")
assert env["fake"].strings.get("customer:s1:rounds") == "2" # 仅计数
assert env["recorded"]["extract"] == []
def test_archive_on_end_session(env):
reply, disc, intent, transfer = cs.run_customer_chat(
_ctx(), "转人工", "s1", "CUST-9527", end_session=True
)
assert intent == "transfer_human"
assert env["recorded"]["archive"] == [("s1", "CUST-9527", "t1", "explicit")]
# 懒扫描始终执行,排除当前会话
assert env["recorded"]["idle"] == [("t1", "s1")]
def test_archive_idle_scan_every_round(env):
cs.run_customer_chat(_ctx(), "转人工", "s1", "CUST-9527")
assert env["recorded"]["idle"] == [("t1", "s1")]
assert env["recorded"]["archive"] == [] # 未显式结束不归档当前会话
# ---------------------------------------------------------------------------
# /api/chat 分流(X-Agent-Type: customer)
# ---------------------------------------------------------------------------
def test_chat_endpoint_customer_branch(client, monkeypatch):
captured: dict = {}
def fake_run(ctx, message, session_id, customer_id, end_session=False):
captured["customer_id"] = customer_id
captured["end_session"] = end_session
return ("您好,我是您的客服助手。", False, "chit_chat", False)
monkeypatch.setattr("app.api.chat.run_customer_chat", fake_run)
login = client.post("/api/auth/login", json={"actor_id": "CUST-9527", "token_type": "customer"})
token = login.json()["data"]["access_token"]
resp = client.post(
"/api/chat",
json={"message": "你好", "end_session": True},
headers={"Authorization": f"Bearer {token}", "X-Agent-Type": "customer"},
)
assert resp.status_code == 200
body = resp.json()
assert body["agent_type"] == "customer"
assert body["intent"] == "chit_chat"
assert body["transfer_to_human"] is False
assert captured["customer_id"] == "CUST-9527"
assert captured["end_session"] is True
def test_chat_endpoint_non_customer_unchanged(client, monkeypatch):
"""非 customer 分流仍走 agent_service;仅断言响应不含客服专属字段。"""
monkeypatch.setattr(
"app.api.chat.agent_service.chat",
lambda *a, **kw: {"reply": "ok", "has_disclaimer": False},
)
login = client.post("/api/auth/login", json={"actor_id": "STAFF-20001", "token_type": "staff"})
token = login.json()["data"]["access_token"]
resp = client.post(
"/api/chat",
json={"message": "hello"},
headers={"Authorization": f"Bearer {token}", "X-Agent-Type": "analyst"},
)
assert resp.status_code == 200
body = resp.json()
assert body.get("intent") is None
assert body.get("transfer_to_human") is False