"""客服 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()