Files
group_fqcd_jr/tests/unit/service/test_customer_service_agent.py
T

456 lines
18 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.
"""客服 Agent(Task 9)单测:安全路由先行 + 知识检索三档置信度 + 契约自洽。
全部用 fake:不连 Milvus / Redis / 真实模型(Milvus 与 Redis 当前不可用),
工具与模型都用 `AsyncMock` 注入,端点解析用 monkeypatch 顶掉。
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock
import pytest
from app.core.contracts import AgentRequest, RequestContext
from app.core.customer_service_rules import (
COMPLIANCE_REPLY,
P0_REPLY,
P1_REPLY,
P2_REPLY,
P4_REPLY,
)
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.implementations import customer_service as cs
from app.service.agent.implementations.customer_service import CustomerServiceAgent
CONTEXT = RequestContext(
user_id="9001", trace_id="trace-task9", roles=("customer",), permissions=("agent:run",),
)
FAQ_SNIPPET = "基金申购后,一般在 T+1 个交易日确认份额,请以基金公告为准。"
def make_request(message: str) -> AgentRequest:
return AgentRequest(
agent_type="customer_service", message=message, session_id="session-task9",
idempotency_key="k" * 16,
)
def hit(snippet: str, *, score: float | None = 0.9, intent: str | None = None,
collection: str = "fin_faq_collection", knowledge_id: str = "101") -> dict[str, Any]:
return {
"knowledge_id": knowledge_id, "collection": collection, "title": "客服 FAQ",
"snippet": snippet, "score": score, "tags": [], "version": "v1", "intent": intent,
}
def build_agent(
monkeypatch: pytest.MonkeyPatch,
*,
output: object,
endpoints: list[Any] | None = None,
model_text: str = "合并后的标准答复。",
) -> tuple[CustomerServiceAgent, AsyncMock, AsyncMock]:
"""构造 Agent 并注入工具/模型/端点三处 fake(不触达任何真实依赖)。"""
agent = CustomerServiceAgent(CustomerServiceAgent.definition)
call_tool = AsyncMock(return_value=output)
generate = AsyncMock(return_value=type("Execution", (), {"text": model_text})())
agent.call_tool = call_tool # type: ignore[method-assign]
agent.generate_with_model = generate # type: ignore[method-assign]
monkeypatch.setattr(
cs, "resolve_chat_endpoints",
AsyncMock(return_value=list(endpoints if endpoints is not None else [])),
)
return agent, call_tool, generate
def endpoint() -> Any:
return type("Endpoint", (), {"endpoint_code": "primary", "timeout_ms": 1000})()
# --- ① 安全路由命中:直返固定话术且不走检索 -------------------------------------
@pytest.mark.parametrize(
("message", "reply", "intent", "transfer_required", "transfer_reason"),
[
("有人打电话让我提供短信验证码,是不是骗子", P0_REPLY, "transfer_human", True,
"P0_safety_risk"),
("有什么年化5%以上的理财推荐吗", COMPLIANCE_REPLY, "product_inquiry", False, None),
("帮我查一下我的持仓和收益", P1_REPLY, "transfer_human", False, None),
("帮我下单买1000块的那只基金", P2_REPLY, "transfer_human", True, "P2_human_requested"),
],
)
@pytest.mark.asyncio
async def test_safety_route_short_circuits_without_retrieval(
monkeypatch: pytest.MonkeyPatch, message: str, reply: str, intent: str,
transfer_required: bool, transfer_reason: str | None,
) -> None:
agent, call_tool, generate = build_agent(
monkeypatch, output={"hits": [hit(FAQ_SNIPPET)], "degraded": False}
)
result = await agent.handle(make_request(message), CONTEXT)
assert result.text == reply
assert result.intent is not None
assert result.intent.intent == intent
assert result.intent.confidence == 1.0
assert result.transfer_required is transfer_required
assert result.transfer_reason == transfer_reason
# 安全关键路径不依赖检索/模型:两者都必须没被调用。
call_tool.assert_not_awaited()
generate.assert_not_awaited()
assert result.source_references == ()
# --- ② 高置信:faq 命中 1 条 → 直返原文且不调模型 -------------------------------
@pytest.mark.asyncio
async def test_single_hit_returns_original_text_without_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, call_tool, generate = build_agent(
monkeypatch, output={"hits": [hit(FAQ_SNIPPET, score=0.87)], "degraded": False},
endpoints=[endpoint()],
)
result = await agent.handle(make_request("基金申购后多久确认"), CONTEXT)
assert result.text == FAQ_SNIPPET
assert result.intent is not None
assert result.intent.intent == "faq"
assert result.intent.confidence == pytest.approx(0.87)
assert result.transfer_required is False
generate.assert_not_awaited()
call_tool.assert_awaited_once()
assert call_tool.await_args.args[0] == cs.TOOL_NAME
assert call_tool.await_args.kwargs["intent"] == cs.TOOL_WHITELIST_INTENT
@pytest.mark.asyncio
async def test_tool_arguments_route_collections_and_cap_top_k(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, call_tool, _ = build_agent(
monkeypatch, output={"hits": [hit(FAQ_SNIPPET)], "degraded": False}
)
await agent.handle(make_request("基金申购后多久确认"), CONTEXT)
arguments = call_tool.await_args.args[1]
assert arguments["query"] == "基金申购后多久确认"
assert tuple(arguments["intents"]) == cs.RETRIEVAL_INTENTS
assert arguments["top_k"] == cs.TOP_K
@pytest.mark.asyncio
async def test_chitchat_hit_reports_chitchat_intent_with_full_confidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, _, _ = build_agent(monkeypatch, output={
"hits": [hit("你好呀,我是奶龙基金智能助手。", score=0.72, intent="chitchat")],
"degraded": False,
})
result = await agent.handle(make_request("你好呀"), CONTEXT)
assert result.intent is not None
assert result.intent.intent == "chitchat"
assert result.intent.confidence == 1.0
@pytest.mark.asyncio
async def test_degraded_single_hit_appends_incompleteness_note(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, _, generate = build_agent(
monkeypatch, output={"hits": [hit(FAQ_SNIPPET, score=None)], "degraded": True}
)
result = await agent.handle(make_request("基金申购后多久确认"), CONTEXT)
assert FAQ_SNIPPET in result.text
assert "以上信息可能不完整" in result.text
assert result.intent is not None and result.intent.intent == "faq"
generate.assert_not_awaited()
# --- ③ 中置信:命中多条 → 调模型且 Prompt 强约束"不得新增事实" ------------------
@pytest.mark.asyncio
async def test_multiple_hits_call_model_with_no_new_facts_constraint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
second = "申购费率以基金合同和销售机构公示为准。"
agent, _, generate = build_agent(
monkeypatch,
output={"hits": [hit(FAQ_SNIPPET, score=0.91), hit(second, score=0.88, knowledge_id="102")],
"degraded": False},
endpoints=[endpoint()],
model_text="申购一般 T+1 确认份额;费率以基金合同为准。",
)
result = await agent.handle(make_request("基金申购后多久确认、费率怎么算"), CONTEXT)
generate.assert_awaited_once()
prompt = generate.await_args.args[1]
assert "不得添加任何未出现在原文中的新事实" in prompt
assert "不得承诺收益" in prompt
assert FAQ_SNIPPET in prompt and second in prompt
assert result.text.startswith("申购一般 T+1 确认份额")
assert "以上信息可能不完整" in result.text
assert result.intent is not None and result.intent.intent == "faq"
@pytest.mark.asyncio
async def test_multiple_hits_without_chat_endpoint_fall_back_to_originals(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""库里只有 embedding 端点(当前实测状态)时必须失败关闭,不拿它去发聊天请求。"""
second = "申购费率以基金合同和销售机构公示为准。"
agent, _, generate = build_agent(
monkeypatch,
output={"hits": [hit(FAQ_SNIPPET, score=0.91), hit(second, score=0.88, knowledge_id="102")],
"degraded": False},
endpoints=[],
)
result = await agent.handle(make_request("基金申购后多久确认、费率怎么算"), CONTEXT)
generate.assert_not_awaited()
assert FAQ_SNIPPET in result.text and second in result.text
assert "以上信息可能不完整" in result.text
@pytest.mark.asyncio
async def test_model_answer_adding_new_risk_words_is_discarded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
second = "申购费率以基金合同和销售机构公示为准。"
agent, _, _ = build_agent(
monkeypatch,
output={"hits": [hit(FAQ_SNIPPET, score=0.91), hit(second, score=0.88, knowledge_id="102")],
"degraded": False},
endpoints=[endpoint()],
model_text="这只产品年化收益率5%,保本无风险。",
)
result = await agent.handle(make_request("基金申购后多久确认、费率怎么算"), CONTEXT)
assert "年化" not in result.text
assert "保本" not in result.text
assert FAQ_SNIPPET in result.text and second in result.text
# --- ④ 低置信:无命中 / 低于阈值 → 兜底话术 + 建议转人工 ------------------------
@pytest.mark.asyncio
async def test_no_hits_returns_fallback_and_suggests_human(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, call_tool, generate = build_agent(
monkeypatch, output={"hits": [], "degraded": False}, endpoints=[endpoint()]
)
result = await agent.handle(make_request("奶龙基金的总部大楼有几个停车位"), CONTEXT)
assert result.text == P4_REPLY
assert result.intent is not None
assert result.intent.intent == "transfer_human"
assert result.intent.confidence == 0.0
assert result.intent.needs_clarification is True
assert result.transfer_required is True
assert result.transfer_reason == "P4_low_confidence"
call_tool.assert_awaited_once()
generate.assert_not_awaited()
@pytest.mark.asyncio
async def test_hit_below_score_threshold_is_treated_as_low_confidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, _, generate = build_agent(
monkeypatch,
output={"hits": [hit("可能相关的答案", score=cs.MIN_SCORE - 0.1)], "degraded": False},
endpoints=[endpoint()],
)
result = await agent.handle(make_request("基金申购后多久确认"), CONTEXT)
assert result.text == P4_REPLY
assert result.intent is not None
assert result.intent.intent == "transfer_human"
assert result.intent.confidence == pytest.approx(cs.MIN_SCORE - 0.1)
assert result.transfer_required is True
generate.assert_not_awaited()
@pytest.mark.asyncio
async def test_blank_snippet_hit_is_not_treated_as_an_answer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent, _, generate = build_agent(
monkeypatch, output={"hits": [hit(" ", score=0.95)], "degraded": False}
)
result = await agent.handle(make_request("基金申购后多久确认"), CONTEXT)
assert result.text == P4_REPLY
generate.assert_not_awaited()
# --- ⑤ handle() 必须显式返回 intent(不得留空交给底座分类结果回填) ------------
@pytest.mark.asyncio
async def test_handle_always_returns_explicit_intent(monkeypatch: pytest.MonkeyPatch) -> None:
cases: list[tuple[str, object]] = [
("有人让我提供验证码", {"hits": [], "degraded": False}),
("基金申购后多久确认", {"hits": [hit(FAQ_SNIPPET)], "degraded": False}),
("你好", {"hits": [], "degraded": False}),
]
for message, output in cases:
agent, _, _ = build_agent(monkeypatch, output=output)
result = await agent.handle(make_request(message), CONTEXT)
assert result.intent is not None, message
assert result.intent.intent in cs.SUPPORTED_INTENTS, message
assert 0.0 <= result.intent.confidence <= 1.0, message
# --- ⑥ 定义声明自洽 + 已注册进工厂 ----------------------------------------------
def test_definition_matches_task_contract() -> None:
definition = CustomerServiceAgent.definition
assert definition.agent_type == "customer_service"
assert definition.allowed_roles == ("customer",)
assert definition.allowed_portals == ("api",)
# Task 9 契约原文只写了 `query_knowledge`;2026-09-11 追加了本人画像只读工具
# (`query_customer_profile`,用于回答"我的风险等级是多少"这类无法从知识库得到的问题)。
# 两个都是**只读**工具,且实际可用范围仍由发布配置的意图白名单收窄。
assert definition.allowed_tools == ("query_knowledge", "query_customer_profile")
assert definition.supported_intents == (
"faq", "product_inquiry", "policy_explain", "chitchat", "transfer_human",
)
def test_agent_does_not_override_governance_methods() -> None:
"""回归约束:覆盖治理方法会被 `BaseAgent.__init_subclass__` 抛 TypeError。"""
forbidden = {
"execute", "validate_input", "validate_access", "resolve_config", "recall_memory",
"check_compliance", "_execute_governed", "call_tool", "generate_with_model",
"bind_governance", "classify_intent",
}
assert forbidden.isdisjoint(CustomerServiceAgent.__dict__)
def test_bootstrap_registers_customer_service_agent() -> None:
factory = get_agent_factory()
definition = factory.definition("customer_service")
assert definition == CustomerServiceAgent.definition
agent = factory.create("customer_service", CONTEXT)
assert isinstance(agent, CustomerServiceAgent)
# --- ⑤ 画像问题:确定性识别 + 取本人权威字段 -------------------------------------
PROFILE_OUTPUT = {
"customer_id": "9001",
"version": "2",
"profile": {
"investor_type": "C3",
"investment_horizon": "medium_term",
"trading_frequency": "medium",
"preferred_asset_class": ["bond_fund"],
"customer_tier": "platinum",
"assessment_expired": False,
},
}
@pytest.mark.parametrize(
("message", "expected"),
[
("我的风险等级是多少", True),
("我的风险测评什么时候到期", True),
("测评过期了吗", True),
("我是什么风险类型的投资者", True),
("我的投资偏好是什么", True),
("我的画像", True),
# 对照:问"规则"而不是"本人数据" → 不该走画像(应走知识检索)
("风险等级怎么划分", False),
("投资者分类标准是什么", False),
("基金申购后多久确认", False),
("有什么债券基金", False),
],
)
def test_profile_question_detection(message: str, expected: bool) -> None:
"""确定性识别:问"本人数据"才走画像;问"规则"走知识检索。"""
assert cs.is_profile_question(message) is expected
@pytest.mark.asyncio
async def test_profile_question_answers_from_own_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""问本人画像 → 调画像工具、用权威字段作答,且**只查自己**。"""
agent, call_tool, _ = build_agent(monkeypatch, output=PROFILE_OUTPUT)
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
assert result.intent is not None and result.intent.intent == "faq"
assert "C3" in result.text or "平衡型" in result.text
assert "测评" in result.text
# 只查本人:customer_id 取自 context.user_id
args = call_tool.await_args
assert args is not None
assert args.args[0] == "query_customer_profile"
assert args.args[1]["customer_id"] == "9001"
@pytest.mark.asyncio
async def test_expired_assessment_is_stated_not_hidden(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""测评过期必须**明说**并引导重新测评(失败关闭口径)。"""
output = {
"customer_id": "9001",
"version": "1",
"profile": {"investor_type": "C1", "assessment_expired": True},
}
agent, _, _ = build_agent(monkeypatch, output=output)
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
assert "已过有效期" in result.text
assert "重新完成测评" in result.text
@pytest.mark.asyncio
async def test_profile_lookup_failure_falls_back_to_transfer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""查不到画像(工具抛错 / 画像为空)→ 兜底转人工,**不猜一个等级**。"""
agent, call_tool, _ = build_agent(monkeypatch, output=PROFILE_OUTPUT)
call_tool.side_effect = RuntimeError("工具不可用")
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
assert result.text == P4_REPLY
assert result.transfer_required is True
# 画像为空(如无当前版本)同样兜底
agent2, _, _ = build_agent(monkeypatch, output={"customer_id": "9001", "profile": {}})
result2 = await agent2.handle(make_request("我的风险等级是多少"), CONTEXT)
assert result2.text == P4_REPLY
assert result2.transfer_required is True
@pytest.mark.asyncio
async def test_policy_question_still_goes_to_knowledge_not_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""问"规则"类问题不得被画像分支截走(必须走知识检索)。"""
agent, call_tool, _ = build_agent(monkeypatch, output=[hit(FAQ_SNIPPET)])
await agent.handle(make_request("风险等级怎么划分"), CONTEXT)
args = call_tool.await_args
assert args is not None
assert args.args[0] == "query_knowledge"