# ============================================================================= # 企微IT智能服务台 — WingmanService 单元测试 # ============================================================================= # 测试覆盖: # 1. _build_context_messages() — 消息角色映射 # 2. _parse_json_response() — JSON 解析三种场景 # 3. _estimate_confidence() — 置信度估算 # 4. generate_draft() — 草稿生成 + 降级 # 5. generate_summary() — 摘要生成 + 降级 # 6. suggest_tags() — 标签建议 + 降级 # ============================================================================= import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.services.wingman_service import WingmanService # ============================================================================= # _build_context_messages 测试 # ============================================================================= class TestBuildContextMessages: """测试消息角色映射逻辑。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() def test_employee_messages_mapped_to_user(self): """员工消息应映射为 user 角色。""" messages = [ {"sender_type": "employee", "content": "我的电脑开不了机"}, ] result = self.service._build_context_messages(messages, "测试 system prompt") assert len(result) == 2 # system prompt + 1 条消息 assert result[0]["role"] == "system" assert result[1]["role"] == "user" assert result[1]["content"] == "我的电脑开不了机" def test_agent_messages_mapped_to_assistant(self): """坐席消息应映射为 assistant 角色。""" messages = [ {"sender_type": "agent", "content": "请尝试重启电脑"}, ] result = self.service._build_context_messages(messages, "测试 prompt") assert result[1]["role"] == "assistant" def test_ai_messages_mapped_to_assistant(self): """AI 消息应映射为 assistant 角色。""" messages = [ {"sender_type": "ai", "content": "建议您检查电源线连接"}, ] result = self.service._build_context_messages(messages, "测试 prompt") assert result[1]["role"] == "assistant" def test_system_messages_are_skipped(self): """系统消息应被跳过(已有 system prompt)。""" messages = [ {"sender_type": "system", "content": "坐席已接入"}, {"sender_type": "employee", "content": "你好"}, ] result = self.service._build_context_messages(messages, "测试 prompt") # system prompt + employee message, system消息被跳过 assert len(result) == 2 assert result[1]["role"] == "user" assert result[1]["content"] == "你好" def test_empty_content_messages_are_skipped(self): """内容为空的消息应被跳过。""" messages = [ {"sender_type": "employee", "content": ""}, {"sender_type": "agent", "content": "收到"}, ] result = self.service._build_context_messages(messages, "测试 prompt") # system prompt + agent message, 空 content 的 employee 消息被跳过 assert len(result) == 2 assert result[1]["role"] == "assistant" def test_unknown_sender_type_defaults_to_user(self): """未知发送者类型应默认映射为 user。""" messages = [ {"sender_type": "unknown_type", "content": "未知消息"}, ] result = self.service._build_context_messages(messages, "测试 prompt") assert result[1]["role"] == "user" def test_full_conversation_ordering(self): """多轮对话应保持正确的顺序。""" messages = [ {"sender_type": "employee", "content": "VPN连不上"}, {"sender_type": "agent", "content": "请问是哪个VPN?"}, {"sender_type": "employee", "content": "公司内网VPN"}, {"sender_type": "ai", "content": "建议检查VPN客户端版本"}, ] result = self.service._build_context_messages(messages, "测试 prompt") # system prompt + 4 条消息 assert len(result) == 5 assert result[1]["role"] == "user" assert result[2]["role"] == "assistant" assert result[3]["role"] == "user" assert result[4]["role"] == "assistant" # ============================================================================= # _parse_json_response 测试 # ============================================================================= class TestParseJsonResponse: """测试 AI 返回 JSON 解析的三种场景。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() def test_parse_pure_json(self): """纯 JSON 字符串应直接解析成功。""" content = '{"problem": "VPN断连", "cause": "证书过期", "solution": "更新证书"}' default = {"problem": "", "cause": "", "solution": ""} result = self.service._parse_json_response(content, default) assert result["problem"] == "VPN断连" assert result["cause"] == "证书过期" assert result["solution"] == "更新证书" def test_parse_markdown_code_block(self): """markdown 代码块中的 JSON 应被提取并解析。""" content = '```json\n{"suggested_tags": ["VPN", "网络"], "category": "网络", "priority": "high"}\n```' default = {"suggested_tags": [], "category": "", "priority": "medium"} result = self.service._parse_json_response(content, default) assert result["suggested_tags"] == ["VPN", "网络"] assert result["category"] == "网络" assert result["priority"] == "high" def test_parse_markdown_code_block_without_language(self): """无语言标记的 markdown 代码块也应被解析。""" content = '```\n{"problem": "密码错误", "cause": "输入错误", "solution": "重置密码"}\n```' default = {"problem": "", "cause": "", "solution": ""} result = self.service._parse_json_response(content, default) assert result["problem"] == "密码错误" def test_parse_curly_brace_extraction(self): """含有额外文本的 AI 返回应通过首尾花括号提取 JSON。""" content = '这是AI的分析结果:{"problem": "邮箱满了", "cause": "未清理", "solution": "清理邮箱"} 希望对你有帮助' default = {"problem": "", "cause": "", "solution": ""} result = self.service._parse_json_response(content, default) assert result["problem"] == "邮箱满了" assert result["solution"] == "清理邮箱" def test_parse_empty_content_returns_default(self): """空内容应返回默认值。""" default = {"problem": "无法自动生成摘要", "cause": "", "solution": ""} result = self.service._parse_json_response("", default) assert result == default def test_parse_invalid_content_returns_default(self): """无法解析的内容应返回默认值。""" content = "这不是JSON格式的内容" default = {"problem": "无法自动生成摘要", "cause": "", "solution": ""} result = self.service._parse_json_response(content, default) assert result == default def test_parse_none_content_returns_default(self): """None 内容应返回默认值。""" default = {"suggested_tags": [], "category": "", "priority": "medium"} result = self.service._parse_json_response(None, default) assert result == default # ============================================================================= # _estimate_confidence 测试 # ============================================================================= class TestEstimateConfidence: """测试 AI 草稿置信度估算。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() def test_short_content_low_confidence(self): """过短内容应返回低置信度。""" result = self.service._estimate_confidence("hi") # len("hi") < 5,应返回 0.2 assert result == 0.2 def test_very_short_content_low_confidence(self): """极短内容(<10字符)应降低置信度。""" result = self.service._estimate_confidence("试试看") # len("试试看") = 3 < 5,返回 0.2 assert result == 0.2 def test_moderate_content_confidence(self): """适中内容应返回中等置信度。""" # 30+ 字符,无不确定措辞,无确定措辞 content = "您好,请检查您的网络连接是否正常,然后重新启动应用程序即可。" result = self.service._estimate_confidence(content) # 基础 0.8,长度 >= 30 不减,无不确定措辞不减,无确定措辞不加 assert 0.7 <= result <= 1.0 def test_uncertain_phrases_lower_confidence(self): """包含不确定措辞应降低置信度。""" content_with_uncertain = "可能是网络问题,建议您检查一下连接" content_without_uncertain = "这是网络问题,请检查网络连接设置" conf_with = self.service._estimate_confidence(content_with_uncertain) conf_without = self.service._estimate_confidence(content_without_uncertain) # 含"可能"和"建议您"的置信度应更低 assert conf_with < conf_without def test_confident_phrases_raise_confidence(self): """包含确定措辞(步骤、链接等)应提高置信度。""" content = "请按以下步骤操作:1.打开设置 2.点击网络 3.选择连接。详细请查看 http://help.example.com" result = self.service._estimate_confidence(content) # 包含"步骤"、"请按以下"、"http" 至少 +0.15 assert result >= 0.9 def test_confidence_bounded_to_range(self): """置信度应限制在 0.0-1.0 范围内。""" # 多个不确定措辞 + 短内容 content = "可能大概也许不确定建议您" result = self.service._estimate_confidence(content) assert result >= 0.0 # 多个确定措辞 content = "请按以下步骤操作:点击打开 http://link1 http://link2" result = self.service._estimate_confidence(content) assert result <= 1.0 def test_empty_string_returns_minimum(self): """空字符串应返回低置信度。""" result = self.service._estimate_confidence("") assert result == 0.2 def test_whitespace_only_returns_minimum(self): """仅空白字符应返回低置信度。""" result = self.service._estimate_confidence(" ") assert result == 0.2 # ============================================================================= # generate_draft 降级测试 # ============================================================================= class TestGenerateDraft: """测试草稿生成的降级处理。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() @pytest.mark.asyncio async def test_draft_degradation_when_api_returns_none(self): """Wingman API 返回 None 时应返回降级默认值。""" self.service._call_wingman_api = AsyncMock(return_value=None) result = await self.service.generate_draft( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "VPN连不上"}], ) assert result["content"] == "" assert result["confidence"] == 0.0 assert "不可用" in result["reasoning"] @pytest.mark.asyncio async def test_draft_degradation_when_api_raises_exception(self): """Wingman API 抛异常时应返回降级默认值(不抛异常)。""" self.service._call_wingman_api = AsyncMock(side_effect=Exception("连接超时")) result = await self.service.generate_draft( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "VPN连不上"}], ) assert result["content"] == "" assert result["confidence"] == 0.0 assert "异常" in result["reasoning"] @pytest.mark.asyncio async def test_draft_success_returns_structured_result(self): """Wingman API 正常返回时应返回结构化结果。""" self.service._call_wingman_api = AsyncMock( return_value="请尝试重启VPN客户端并重新输入密码" ) result = await self.service.generate_draft( conversation_id="conv-001", messages=[ {"sender_type": "employee", "content": "VPN连不上"}, {"sender_type": "agent", "content": "请问报什么错?"}, ], ) assert result["content"] == "请尝试重启VPN客户端并重新输入密码" assert 0.0 < result["confidence"] <= 1.0 assert "推理" in result["reasoning"] or "对话上下文" in result["reasoning"] # ============================================================================= # generate_summary 降级测试 # ============================================================================= class TestGenerateSummary: """测试摘要生成的降级处理。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() @pytest.mark.asyncio async def test_summary_degradation_when_api_returns_none(self): """Wingman API 返回 None 时应返回降级默认摘要。""" self.service._call_wingman_api = AsyncMock(return_value=None) result = await self.service.generate_summary( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "求助"}], ) assert result["problem"] == "无法自动生成摘要" assert result["cause"] == "" assert result["solution"] == "" @pytest.mark.asyncio async def test_summary_degradation_when_api_raises_exception(self): """Wingman API 抛异常时应返回降级默认摘要。""" self.service._call_wingman_api = AsyncMock(side_effect=Exception("超时")) result = await self.service.generate_summary( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "求助"}], ) assert result["problem"] == "无法自动生成摘要" @pytest.mark.asyncio async def test_summary_success_parses_json_response(self): """Wingman API 正常返回 JSON 时应正确解析摘要。""" self.service._call_wingman_api = AsyncMock( return_value='{"problem": "VPN断连", "cause": "证书过期", "solution": "更新证书"}' ) result = await self.service.generate_summary( conversation_id="conv-001", messages=[ {"sender_type": "employee", "content": "VPN连不上"}, ], ) assert result["problem"] == "VPN断连" assert result["cause"] == "证书过期" assert result["solution"] == "更新证书" # ============================================================================= # suggest_tags 降级测试 # ============================================================================= class TestSuggestTags: """测试标签建议的降级处理。""" def setup_method(self): """每个测试方法执行前的初始化。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://test-api" mock_settings.dify_wingman_api_key = "test-key" mock_settings.dify_wingman_timeout = 10 self.service = WingmanService() @pytest.mark.asyncio async def test_tags_degradation_when_api_returns_none(self): """Wingman API 返回 None 时应返回降级默认标签。""" self.service._call_wingman_api = AsyncMock(return_value=None) result = await self.service.suggest_tags( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "求助"}], ) assert result["suggested_tags"] == [] assert result["category"] == "" assert result["priority"] == "medium" @pytest.mark.asyncio async def test_tags_degradation_when_api_raises_exception(self): """Wingman API 抛异常时应返回降级默认标签。""" self.service._call_wingman_api = AsyncMock(side_effect=Exception("超时")) result = await self.service.suggest_tags( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "求助"}], ) assert result["suggested_tags"] == [] assert result["priority"] == "medium" @pytest.mark.asyncio async def test_tags_success_parses_json_response(self): """Wingman API 正常返回 JSON 时应正确解析标签。""" self.service._call_wingman_api = AsyncMock( return_value='{"suggested_tags": ["VPN", "网络"], "category": "网络", "priority": "high"}' ) result = await self.service.suggest_tags( conversation_id="conv-001", messages=[{"sender_type": "employee", "content": "VPN连不上"}], ) assert result["suggested_tags"] == ["VPN", "网络"] assert result["category"] == "网络" assert result["priority"] == "high" # ============================================================================= # WingmanService 初始化测试 # ============================================================================= class TestWingmanServiceInit: """测试 WingmanService 初始化。""" def test_service_reads_config_from_settings(self): """WingmanService 应从 settings 正确读取配置。""" with patch("app.services.wingman_service.settings") as mock_settings: mock_settings.dify_wingman_api_url = "http://custom-api-url" mock_settings.dify_wingman_api_key = "custom-api-key" mock_settings.dify_wingman_timeout = 60 service = WingmanService() assert service.api_url == "http://custom-api-url" assert service.api_key == "custom-api-key" assert service.timeout == 60