Files
group_fqcd_jr/tests/unit/service/test_customer_service_agent.py
T
qyqy 57c4add7d8 merge: 客服Agent+RAG+画像 与 架构师最新 qyqy_develop 合并
- customer_service.py 以架构师实现为骨架(三档置信/适当性/会话记忆/话题矩阵),嫁接本人画像出口
- 知识检索契约合并两条链路:架构师 search_knowledge(KnowledgeSearchInput) + 本线
  query_knowledge 链路所需常量(ALLOWED_CONSTANTS/VECTOR_DIM/intent_for_qa_id)
- bootstrap 保留架构师 6 工具/3 Agent,补回 query_customer_profile 与 get_milvus_knowledge_writer
- model_gateway 能力映射修正 intent_classification→chat,保留空集回退兜底
- governance 免责声明限定面向客户 Agent(agent_type 由定义透传),风控结构化输出不再被追加
- 修 JWT 密钥路径(config/jwt/dev)、文档 21 号撞号→25
- 测试基线 934 passed / 1 failed(既有空集缺陷)
2026-09-11 15:06:44 +08:00

174 lines
6.8 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 的**画像问答**出口单元测试。
为什么这个文件只剩画像:知识检索、适当性裁决、话题矩阵三个出口各有专门测试
(`test_customer_service_search_query.py` / `test_customer_service_suitability.py` /
`test_customer_service_topic_matrix.py`),本文件原先那些重复用例在合并时已删除——
两套测试各自锁同一份实现只会让"改一处要改两遍"。
本文件不连数据库、不调模型:`call_tool` 用替身替换。
"""
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
CONTEXT = RequestContext(user_id="9001", trace_id="t", roles=("customer",))
PROFILE_OUTPUT: dict[str, Any] = {
"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,
},
}
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
# --- ① 确定性识别:问"本人数据"走画像,问"规则"走知识 ------------------------------
@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() -> None:
"""问本人画像 → 调画像工具、用权威字段作答,且**只查自己**。"""
agent, call_tool = build_agent(PROFILE_OUTPUT)
result = await agent.handle(make_request("我的风险等级是多少"), CONTEXT)
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,不从用户消息里解析
args = call_tool.await_args
assert args is not None
assert args.args[0] == cs.PROFILE_TOOL_NAME
assert args.args[1]["customer_id"] == "9001"
@pytest.mark.asyncio
async def test_expired_assessment_is_stated_not_hidden() -> None:
"""测评过期必须**明说**并引导重新测评,不能给出一个看起来有效的等级就完事。"""
agent, _ = build_agent(EXPIRED_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() -> 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
@pytest.mark.asyncio
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]
result = await agent.handle(make_request("风险等级怎么划分"), CONTEXT)
# 未识别意图 → 引导人工;关键是**没有**去调画像工具
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()