feat(handoff): 转人工状态、待接管列表与接待时段
同会话内 AI 转人工,待接管列表按分组和在线状态展示,接管后保留上下文。
This commit is contained in:
1 parent
d9fe727c3f
commit
8e3a4d95da
6 files changed
+803
No files matched your search
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Taking the AI out of a conversation: the state transition behind the per-chat
|
||||
"hand to my team" action.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.services.human_routing import notify_customer, route_session_to_human
|
||||
from app.services.message_delivery import DeliveryResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_session(db, test_agent, test_customer, test_organization) -> SessionToAgent:
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
status=SessionStatus.OPEN,
|
||||
assigned_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def _silenced():
|
||||
"""Notifications and channel delivery are covered by their own tests."""
|
||||
return (
|
||||
patch("app.services.human_routing.notify_chat_event", new=AsyncMock()),
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_queues_the_chat_without_an_assignee(db, open_session):
|
||||
"""TRANSFERRED with user_id null is the state every AI guard checks."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(
|
||||
db, open_session, reason="DIRECT_REQUEST", description="test") is True
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.TRANSFERRED
|
||||
assert open_session.user_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_refuses_a_claimed_or_closed_chat(db, open_session, test_user):
|
||||
"""Nothing to take away from the AI — it is not answering either one."""
|
||||
notify, customer = _silenced()
|
||||
|
||||
open_session.user_id = test_user.id
|
||||
db.commit()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
open_session.user_id = None
|
||||
open_session.status = SessionStatus.CLOSED
|
||||
db.commit()
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.CLOSED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_is_idempotent(db, open_session):
|
||||
"""A second call must not re-notify or re-message the customer."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is True
|
||||
|
||||
with patch("app.services.human_routing.notify_chat_event", new=AsyncMock()) as n, \
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()) as c:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
assert not n.called
|
||||
assert not c.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handover_notice_is_delivered_against_its_stored_row(db, open_session):
|
||||
"""The notice's channel message id must land on the notice's own row."""
|
||||
open_session.channel = "whatsapp"
|
||||
db.commit()
|
||||
with patch("app.services.human_routing.deliver_to_customer",
|
||||
new=AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
|
||||
await notify_customer(db, open_session, "You're now connected with our team.")
|
||||
|
||||
notice = db.query(ChatHistory).filter_by(session_id=open_session.session_id).one()
|
||||
assert deliver.await_args.kwargs["message_id"] == notice.id
|
||||
Reference in new issue
Block a user