2026-09-11 15:06:44 +08:00
|
|
|
|
"""客服 Agent 的**画像问答**出口单元测试。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
为什么这个文件只剩画像:知识检索、适当性裁决、话题矩阵三个出口各有专门测试
|
|
|
|
|
|
(`test_customer_service_search_query.py` / `test_customer_service_suitability.py` /
|
|
|
|
|
|
`test_customer_service_topic_matrix.py`),本文件原先那些重复用例在合并时已删除——
|
|
|
|
|
|
两套测试各自锁同一份实现只会让"改一处要改两遍"。
|
|
|
|
|
|
|
|
|
|
|
|
本文件不连数据库、不调模型:`call_tool` 用替身替换。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import AgentRequest, RequestContext
|
|
|
|
|
|
from app.service.agent.implementations import customer_service as cs
|
|
|
|
|
|
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
|
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
CONTEXT = RequestContext(user_id="9001", trace_id="t", roles=("customer",))
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
PROFILE_OUTPUT: dict[str, Any] = {
|
2026-09-11 14:37:20 +08:00
|
|
|
|
"customer_id": "9001",
|
|
|
|
|
|
"profile": {
|
|
|
|
|
|
"investor_type": "C3",
|
|
|
|
|
|
"investment_horizon": "medium_term",
|
|
|
|
|
|
"trading_frequency": "medium",
|
|
|
|
|
|
"preferred_asset_class": ["bond_fund"],
|
|
|
|
|
|
"customer_tier": "platinum",
|
|
|
|
|
|
"assessment_expired": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
EXPIRED_OUTPUT: dict[str, Any] = {
|
|
|
|
|
|
"customer_id": "9001",
|
|
|
|
|
|
"profile": {
|
|
|
|
|
|
"investor_type": "C5",
|
|
|
|
|
|
"investment_horizon": "long_term",
|
|
|
|
|
|
"trading_frequency": "high",
|
|
|
|
|
|
"preferred_asset_class": ["equity_fund"],
|
|
|
|
|
|
"customer_tier": "gold",
|
|
|
|
|
|
"assessment_expired": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_request(message: str) -> AgentRequest:
|
|
|
|
|
|
return AgentRequest(
|
|
|
|
|
|
agent_type="customer_service", message=message, session_id="session-profile",
|
|
|
|
|
|
idempotency_key="k" * 16,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_agent(output: object) -> tuple[CustomerServiceAgent, AsyncMock]:
|
|
|
|
|
|
"""构造 Agent 并注入工具替身(不触达任何真实依赖)。"""
|
|
|
|
|
|
agent = CustomerServiceAgent(CustomerServiceAgent.definition)
|
|
|
|
|
|
call_tool = AsyncMock(return_value=output)
|
|
|
|
|
|
agent.call_tool = call_tool # type: ignore[method-assign]
|
|
|
|
|
|
return agent, call_tool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- ① 确定性识别:问"本人数据"走画像,问"规则"走知识 ------------------------------
|
|
|
|
|
|
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-09-11 15:06:44 +08:00
|
|
|
|
"""确定性识别:问"本人数据"才走画像;问"规则"走知识检索。
|
|
|
|
|
|
|
|
|
|
|
|
这是本出口存在的理由——知识库答不了"我的风险等级是多少",而把"风险等级怎么划分"
|
|
|
|
|
|
误判成画像问题会让政策解读类问题拿不到答案。
|
|
|
|
|
|
"""
|
2026-09-11 14:37:20 +08:00
|
|
|
|
assert cs.is_profile_question(message) is expected
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
# --- ② 取权威字段作答 ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 14:37:20 +08:00
|
|
|
|
@pytest.mark.asyncio
|
2026-09-11 15:06:44 +08:00
|
|
|
|
async def test_profile_question_answers_from_own_profile() -> None:
|
2026-09-11 14:37:20 +08:00
|
|
|
|
"""问本人画像 → 调画像工具、用权威字段作答,且**只查自己**。"""
|
2026-09-11 15:06:44 +08:00
|
|
|
|
agent, call_tool = build_agent(PROFILE_OUTPUT)
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
|
|
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
assert result.intent is not None and result.intent.intent == cs.INTENT_FAQ
|
|
|
|
|
|
assert "平衡型" in result.text and "C3" in result.text
|
|
|
|
|
|
assert result.transfer_required is False
|
|
|
|
|
|
# 只查本人:customer_id 取自 context.user_id,不从用户消息里解析
|
2026-09-11 14:37:20 +08:00
|
|
|
|
args = call_tool.await_args
|
|
|
|
|
|
assert args is not None
|
2026-09-11 15:06:44 +08:00
|
|
|
|
assert args.args[0] == cs.PROFILE_TOOL_NAME
|
2026-09-11 14:37:20 +08:00
|
|
|
|
assert args.args[1]["customer_id"] == "9001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-09-11 15:06:44 +08:00
|
|
|
|
async def test_expired_assessment_is_stated_not_hidden() -> None:
|
|
|
|
|
|
"""测评过期必须**明说**并引导重新测评,不能给出一个看起来有效的等级就完事。"""
|
|
|
|
|
|
agent, _ = build_agent(EXPIRED_OUTPUT)
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
|
|
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
assert "测评已过有效期" in result.text
|
2026-09-11 14:37:20 +08:00
|
|
|
|
assert "重新完成测评" in result.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-09-11 15:06:44 +08:00
|
|
|
|
async def test_profile_lookup_failure_falls_back_to_transfer() -> None:
|
|
|
|
|
|
"""工具失败/画像为空时**失败关闭为转人工**,绝不猜一个等级出来。"""
|
|
|
|
|
|
for output in ({}, {"profile": {}}, {"profile": None}):
|
|
|
|
|
|
agent, _ = build_agent(output)
|
|
|
|
|
|
result = await agent.handle(make_request("我的画像"), CONTEXT)
|
|
|
|
|
|
assert result.transfer_required is True, output
|
|
|
|
|
|
assert result.text == cs.FALLBACK_TEMPLATE, output
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-09-11 15:06:44 +08:00
|
|
|
|
async def test_policy_question_still_goes_to_knowledge_not_profile() -> None:
|
|
|
|
|
|
"""规则类问题不走画像工具(对照组:确保上面的关键词表没有过度捕获)。"""
|
|
|
|
|
|
agent, call_tool = build_agent({"hits": []})
|
|
|
|
|
|
agent._classified_intent = None # type: ignore[assignment]
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
result = await agent.handle(make_request("风险等级怎么划分"), CONTEXT)
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
2026-09-11 15:06:44 +08:00
|
|
|
|
# 未识别意图 → 引导人工;关键是**没有**去调画像工具
|
|
|
|
|
|
assert result.transfer_required is True
|
|
|
|
|
|
if call_tool.await_args is not None:
|
|
|
|
|
|
assert call_tool.await_args.args[0] != cs.PROFILE_TOOL_NAME
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- ③ 契约守卫 ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_definition_declares_profile_tool_in_code_ceiling() -> None:
|
|
|
|
|
|
"""代码上限必须声明画像工具,否则发布配置开了白名单也调不到(两段式取交集)。"""
|
|
|
|
|
|
assert cs.PROFILE_TOOL_NAME in CustomerServiceAgent.definition.allowed_tools
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_whitelist_intent_reuses_published_key() -> None:
|
|
|
|
|
|
"""画像工具调用复用的意图 key 必须是**已发布**的那个。
|
|
|
|
|
|
|
|
|
|
|
|
换新意图码会让 `allowed_tools_by_intent` 缺 key → 交集为空 → `AGENT_PERMISSION_DENIED`,
|
|
|
|
|
|
画像出口会以"权限被拒"的形式整体失效。
|
|
|
|
|
|
"""
|
|
|
|
|
|
assert cs.PROFILE_WHITELIST_INTENT == cs.INTENT_FAQ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_does_not_override_governance_methods() -> None:
|
|
|
|
|
|
"""治理方法是底座的责任,业务 Agent 覆盖会被 `BaseAgent.__init_subclass__` 拒绝。
|
|
|
|
|
|
|
|
|
|
|
|
这里断言类字典里没有这些名字,防止将来有人"顺手实现一个"而让合规/审计被绕过。
|
|
|
|
|
|
"""
|
|
|
|
|
|
overridden = {
|
|
|
|
|
|
"check_compliance", "_execute_governed", "call_tool", "generate_with_model",
|
|
|
|
|
|
"recall_memory", "classify_intent", "bind_governance",
|
|
|
|
|
|
} & set(CustomerServiceAgent.__dict__)
|
|
|
|
|
|
assert overridden == set()
|