449c6d4875
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS
## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS
## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)
## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code
## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)
## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过
## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务
## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记✅已实施
- 新增架构图/时序图/类图(mermaid)
## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
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"
|