Files

602 lines
22 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""分诊交互模块回归测试 — T02
测试范围:
H5端接口(7个): start_success, start_timeout, start_dify_unavailable,
submit_step, skip_step, transfer, complete
坐席端接口(6个): list_pending, get_stats, get_detail, route_session,
get_history, exclude_options_ws_push
Service层(2个): determine_urgency_keywords, determine_urgency_confidence
测试依赖: conftest.py 提供的 client / db_session fixtures
"""
import asyncio
import uuid
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.triage_session import TriageSession
from app.services.triage_service import TriageService
# ============================================================================
# 辅助函数
# ============================================================================
async def _login(client: AsyncClient, db_session: AsyncSession, role: str = "agent") -> str:
"""登录并返回 Bearer token。
Args:
client: 测试客户端
db_session: 数据库会话
role: 角色(agent/admin
Returns:
str: Bearer token
"""
from app.models.role import Role
from app.models.user_role import UserRole
user_id = f"test_{role}_{uuid.uuid4().hex[:8]}"
# 确保角色存在
stmt = select(Role).where(Role.name == role)
result = await db_session.execute(stmt)
db_role = result.scalars().first()
if not db_role:
display = "坐席" if role == "agent" else "管理员"
db_role = Role(
name=role, display_name=display,
description=f"{display}角色", permissions=[],
)
db_session.add(db_role)
await db_session.flush()
# 创建 UserRole 关联
db_session.add(UserRole(
employee_id=user_id,
role_id=db_role.id,
source="manual",
assigned_by="test_fixture",
))
await db_session.flush()
# 登录
resp = await client.post("/agents/login", json={
"user_id": user_id,
"name": f"测试{role}",
})
return resp.json()["data"]["token"]
async def _create_triage_session(db_session: AsyncSession, **kwargs) -> TriageSession:
"""在数据库中创建分诊会话记录。
Args:
db_session: 数据库会话
**kwargs: 覆盖默认字段值
Returns:
TriageSession: 创建的会话对象
"""
defaults = {
"conversation_id": f"conv-{uuid.uuid4().hex[:8]}",
"user_id": "test_user_001",
"user_name": "测试用户",
"user_dept": "技术部",
"request_title": "测试问题标题",
"request_content": "测试问题内容",
"source": "wecom_h5",
"status": "pending",
"urgency": "medium",
}
defaults.update(kwargs)
session = TriageSession(**defaults)
db_session.add(session)
await db_session.flush()
return session
# ============================================================================
# Fixture: Mock Dify 分诊服务
# ============================================================================
@pytest.fixture
def mock_dify_triage():
"""Mock Dify triage service on the singleton TriageService。
TriageService 是单例,dify_service 在 __init__ 中赋值。
此 fixture 替换 dify_service 为 AsyncMock,测试后恢复原值。
"""
from app.services.triage_service import get_triage_service
service = get_triage_service()
original = service.dify_service
mock = AsyncMock()
service.dify_service = mock
yield mock
service.dify_service = original
# ============================================================================
# Section A — H5 端接口测试(7个)
# ============================================================================
class TestH5Triage:
"""H5 端分诊交互接口测试。"""
@pytest.mark.asyncio
async def test_start_triage_success(self, client, db_session, mock_dify_triage):
"""正常发起分诊 — code=0, 返回 triage_id/steps/confidence/urgency。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.85,
"urgency": "medium",
"suggested_route": "ai_self",
"problem_type": "软件",
"problem_category": "Outlook",
"matched_knowledge": "FAQ-001",
"match_score": 0.92,
"context_tags": ["email"],
"triage_steps": [
{
"question": "您遇到的问题是?",
"options": [
{"label": "无法登录", "probability": 0.7},
{"label": "邮件发不出", "probability": 0.3},
],
}
],
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-test-001",
"question": "我的Outlook打不开了",
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
result = data["data"]
assert "triage_id" in result
assert len(result["steps"]) == 1
assert result["confidence"] == 0.85
# "打不开" 是中级关键词 → medium
assert result["urgency"] == "medium"
@pytest.mark.asyncio
async def test_start_triage_timeout(self, client, db_session, mock_dify_triage):
"""Dify 超时 — status=timeout, 自动转人工。"""
token = await _login(client, db_session)
# 模拟 Dify 超时(asyncio.wait_for 捕获 TimeoutError
mock_dify_triage.analyze.side_effect = asyncio.TimeoutError()
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-test-002",
"question": "密码过期了怎么办",
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "timeout"
assert "triage_id" in data["data"]
@pytest.mark.asyncio
async def test_start_triage_dify_unavailable(self, client, db_session, mock_dify_triage):
"""Dify 不可用 — 降级转人工。"""
token = await _login(client, db_session)
# 模拟 Dify 不可用(RuntimeError 触发降级转人工)
mock_dify_triage.analyze.side_effect = RuntimeError("Dify unavailable")
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-test-003",
"question": "VPN连不上了",
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "timeout"
assert "triage_id" in data["data"]
@pytest.mark.asyncio
async def test_submit_step_success(self, client, db_session, mock_dify_triage):
"""提交步骤选择 — 返回 next_step 和 collected_context。"""
token = await _login(client, db_session)
# 先发起分诊
mock_dify_triage.analyze.return_value = {
"confidence": 0.8,
"urgency": "low",
"triage_steps": [
{"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]},
{"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]},
],
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-step-001",
"question": "打印机问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
# 提交步骤0的选择
resp = await client.post("/h5/triage/step", json={
"triage_id": triage_id,
"step_index": 0,
"selected_label": "选项A",
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["next_step"] is not None
assert data["data"]["next_step"]["question"] == "问题2"
assert "选项A" in data["data"]["collected_context"]
@pytest.mark.asyncio
async def test_skip_step_success(self, client, db_session, mock_dify_triage):
"""跳过步骤 — 返回 next_step。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.7,
"urgency": "low",
"triage_steps": [
{"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]},
{"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]},
],
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-skip-001",
"question": "网络问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
resp = await client.post("/h5/triage/skip", json={
"triage_id": triage_id,
"step_index": 0,
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["next_step"] is not None
assert data["data"]["next_step"]["question"] == "问题2"
@pytest.mark.asyncio
async def test_transfer_to_human_success(self, client, db_session, mock_dify_triage):
"""转人工 — status=waiting_agent。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.6,
"urgency": "low",
"triage_steps": [{"question": "问题1", "options": []}],
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-transfer-001",
"question": "硬件问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
resp = await client.post("/h5/triage/transfer", json={
"triage_id": triage_id,
"context": ["用户选择的上下文"],
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "waiting_agent"
assert "conversation_id" in data["data"]
@pytest.mark.asyncio
async def test_complete_triage_success(self, client, db_session, mock_dify_triage):
"""分诊完成 — 返回 reply 和 confidence。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.9,
"urgency": "low",
"triage_steps": [
{"question": "问题1", "options": [{"label": "选项A", "probability": 0.8}]}
],
}
mock_dify_triage.generate_reply.return_value = {
"reply": "建议您重启Outlook客户端。",
"confidence": 0.88,
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-complete-001",
"question": "软件使用问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
resp = await client.post("/h5/triage/complete", json={
"triage_id": triage_id,
"context": ["选项A"],
}, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "reply" in data["data"]
assert data["data"]["confidence"] == 0.88
# ============================================================================
# Section B — 坐席端接口测试(6个)
# ============================================================================
class TestAgentTriage:
"""坐席端分诊看板接口测试。"""
@pytest.mark.asyncio
async def test_list_pending_sorted_by_urgency(self, client, db_session):
"""待分诊列表按紧急度排序 high > medium > low。"""
token = await _login(client, db_session)
# 创建3条不同紧急度的待分诊记录(创建顺序故意打乱)
await _create_triage_session(db_session, urgency="low", request_title="低优先级")
await _create_triage_session(db_session, urgency="high", request_title="高优先级")
await _create_triage_session(db_session, urgency="medium", request_title="中优先级")
await db_session.flush()
resp = await client.get(
"/agent/triage/pending",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
items = data["data"]["items"]
assert len(items) == 3
# high 应排在最前
assert items[0]["urgency"] == "high"
assert items[1]["urgency"] == "medium"
assert items[2]["urgency"] == "low"
@pytest.mark.asyncio
async def test_get_stats(self, client, db_session):
"""统计概要返回6项指标。"""
token = await _login(client, db_session)
# 创建测试数据
await _create_triage_session(db_session, status="pending", urgency="high")
await _create_triage_session(db_session, status="triaging", urgency="medium")
await _create_triage_session(
db_session, status="routed", route_action="ai_self",
operated_at=datetime.now(),
)
await db_session.flush()
resp = await client.get(
"/agent/triage/stats",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
stats = data["data"]
# 验证6项指标字段都存在
assert "pending_total" in stats
assert "today_triaged" in stats
assert "ai_self_count" in stats
assert "human_count" in stats
assert "auto_approval_count" in stats
assert "avg_duration_sec" in stats
# 验证待分诊数(1 pending + 1 triaging = 2
assert stats["pending_total"] == 2
@pytest.mark.asyncio
async def test_get_detail(self, client, db_session, mock_dify_triage):
"""获取分诊详情。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.85,
"urgency": "medium",
"triage_steps": [{"question": "问题1", "options": []}],
"problem_type": "软件",
"problem_category": "Outlook",
}
# 发起分诊创建会话
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-detail-001",
"question": "Outlook问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
# 获取详情
resp = await client.get(
f"/agent/triage/{triage_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
detail = data["data"]
assert detail["id"] == triage_id
assert detail["problem_category"] == "Outlook"
assert detail["confidence"] == 0.85
@pytest.mark.asyncio
async def test_route_session(self, client, db_session, mock_dify_triage):
"""坐席路由操作覆盖 AI 建议。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.7,
"urgency": "low",
"triage_steps": [{"question": "问题1", "options": []}],
"suggested_route": "ai_self",
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-route-001",
"question": "一般问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
# 坐席路由为转人工(覆盖AI建议的ai_self)
resp = await client.post(
f"/agent/triage/{triage_id}/route",
json={
"route_action": "human",
"route_note": "需要人工排查",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["route_action"] == "human"
assert data["data"]["status"] == "routed"
assert data["data"]["route_note"] == "需要人工排查"
@pytest.mark.asyncio
async def test_get_history(self, client, db_session):
"""历史列表返回 routed/skipped/timeout 状态的记录。"""
token = await _login(client, db_session)
# 创建历史记录
await _create_triage_session(db_session, status="routed", route_action="ai_self")
await _create_triage_session(db_session, status="routed", route_action="human")
await _create_triage_session(db_session, status="skipped")
# pending 不应出现在历史中
await _create_triage_session(db_session, status="pending")
await db_session.flush()
resp = await client.get(
"/agent/triage/history",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
items = data["data"]["items"]
assert len(items) == 3 # 只有 routed/skipped
for item in items:
assert item["status"] in ("routed", "skipped", "timeout")
@pytest.mark.asyncio
async def test_exclude_options_ws_push(self, client, db_session, mock_dify_triage):
"""排除选项通过 WS 推送到 H5。"""
token = await _login(client, db_session)
mock_dify_triage.analyze.return_value = {
"confidence": 0.8,
"urgency": "low",
"triage_steps": [{"question": "问题1", "options": [
{"label": "选项A", "probability": 0.5},
{"label": "选项B", "probability": 0.3},
]}],
}
resp = await client.post("/h5/triage/start", json={
"conversation_id": "conv-exclude-001",
"question": "测试问题",
}, headers={"Authorization": f"Bearer {token}"})
triage_id = resp.json()["data"]["triage_id"]
# Mock WS manager 的 send_to_employee 方法
with patch(
"app.services.ws_manager.manager.send_to_employee",
new_callable=AsyncMock,
) as mock_ws:
resp = await client.post(
f"/agent/triage/{triage_id}/exclude-options",
json={
"excluded_labels": ["选项A"],
"recommended_label": "选项B",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["excluded"] is True
# 验证 WS 推送被调用
mock_ws.assert_called_once()
# 验证推送数据格式
call_args = mock_ws.call_args
ws_data = call_args[0][1] # 第二个位置参数
assert ws_data["type"] == "triage_exclude"
assert "选项A" in ws_data["data"]["excluded_labels"]
assert ws_data["data"]["recommended_label"] == "选项B"
# ============================================================================
# Section C — Service 层测试(2个)
# ============================================================================
class TestTriageService:
"""TriageService 业务逻辑测试。"""
def test_determine_urgency_keywords(self):
"""紧急度判断关键词规则。
规则:
- 高级关键词(紧急/宕机/崩溃等)→ high
- 中级关键词(报错/失败/连不上等)→ medium
- 无关键词 → low
"""
# 高级关键词 → high
assert TriageService.determine_urgency("系统宕机了") == "high"
assert TriageService.determine_urgency("紧急!密码过期") == "high"
assert TriageService.determine_urgency("电脑蓝屏了") == "high"
assert TriageService.determine_urgency("系统崩溃了") == "high"
# 中级关键词 → medium
assert TriageService.determine_urgency("VPN连不上") == "medium"
assert TriageService.determine_urgency("打印机报错") == "medium"
assert TriageService.determine_urgency("登录失败") == "medium"
assert TriageService.determine_urgency("页面打不开") == "medium"
# 无关键词 → low
assert TriageService.determine_urgency("我想查一下工资条") == "low"
assert TriageService.determine_urgency("请问年假怎么申请") == "low"
def test_determine_urgency_confidence(self):
"""置信度低于 0.5 为 high。
规则:
- confidence < 0.5 → high(即使没有关键词)
- 高级关键词始终优先于置信度
- 置信度优先于中级关键词
"""
# 置信度 < 0.5 → high(即使没有关键词)
assert TriageService.determine_urgency("一般问题", confidence=0.3) == "high"
assert TriageService.determine_urgency("普通咨询", confidence=0.49) == "high"
# 置信度 >= 0.5 且无关键词 → low
assert TriageService.determine_urgency("一般问题", confidence=0.5) == "low"
assert TriageService.determine_urgency("普通咨询", confidence=0.9) == "low"
# 置信度 < 0.5 但有中级关键词 → high(置信度优先于中级关键词)
assert TriageService.determine_urgency("VPN连不上", confidence=0.3) == "high"
# 高级关键词始终优先(即使置信度很高)
assert TriageService.determine_urgency("宕机", confidence=0.9) == "high"
assert TriageService.determine_urgency("宕机", confidence=0.1) == "high"