602 lines
22 KiB
Python
602 lines
22 KiB
Python
|
|
# -*- 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"
|