286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""
|
|||
|
|
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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Any, Dict, Optional
|
||
|
|
import asyncio
|
||
|
|
from agno.agent import Agent
|
||
|
|
from app.agents.guardrail_policy import INJECTION_CLAUSE, visitor_data_block
|
||
|
|
from app.utils.agno_utils import create_model
|
||
|
|
from app.repositories.customer import CustomerRepository
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.utils.business_hours import DEFAULT_BUSINESS_HOURS, is_within_business_hours
|
||
|
|
from app.repositories.group import GroupRepository
|
||
|
|
from app.tools.jira_toolkit import JiraTools
|
||
|
|
from app.models.schemas.chat import ChatResponse
|
||
|
|
from app.utils.response_parser import parse_response_content
|
||
|
|
from app.core.config import settings
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
# Wording used when the model can't produce a handoff line in time. The routing
|
||
|
|
# decision itself is computed from business hours and agent availability, so a
|
||
|
|
# timeout costs only the phrasing — the handoff still goes ahead.
|
||
|
|
TRANSFER_TIMEOUT_MESSAGE = "Let me connect you with someone from our team."
|
||
|
|
FOLLOW_UP_TIMEOUT_MESSAGE = "Our team will get back to you shortly."
|
||
|
|
|
||
|
|
class TransferResponseAgent:
|
||
|
|
def __init__(self, api_key: str, model_name: str, model_type: str = "OPENAI", agent_name: Optional[str] = None, base_url: str = None):
|
||
|
|
model = create_model(
|
||
|
|
model_type=model_type,
|
||
|
|
api_key=api_key,
|
||
|
|
model_name=model_name,
|
||
|
|
max_tokens=1000,
|
||
|
|
base_url=base_url
|
||
|
|
)
|
||
|
|
|
||
|
|
# Define instructions for transfer response agent
|
||
|
|
instructions = [
|
||
|
|
INJECTION_CLAUSE,
|
||
|
|
"You need to explain why you're transferring the chat to a another agent.",
|
||
|
|
"If within business hours and agents are available, explain that you need to transfer to a better qualified agent to help.",
|
||
|
|
"If outside business hours or no agents available, apologize and explain that the team will contact them via email.",
|
||
|
|
"Be empathetic and professional in your response.",
|
||
|
|
"Keep responses concise and clear.",
|
||
|
|
"If already transferred, do not transfer again. Tell that someone will get back to them shortly."
|
||
|
|
]
|
||
|
|
|
||
|
|
if agent_name:
|
||
|
|
# Prepend the identity instruction to the list instead of replacing all instructions
|
||
|
|
instructions = [f"You are {agent_name}, representing our company."] + instructions
|
||
|
|
|
||
|
|
self.agent = Agent(
|
||
|
|
name="Transfer Response Agent",
|
||
|
|
model=model,
|
||
|
|
instructions=instructions,
|
||
|
|
markdown=True,
|
||
|
|
debug_mode=settings.ENVIRONMENT == "development",
|
||
|
|
system_message_role="system",
|
||
|
|
user_message_role="user",
|
||
|
|
num_history_responses=10
|
||
|
|
|
||
|
|
)
|
||
|
|
logger.debug(f"Transfer Response Agent: {self.agent.instructions}")
|
||
|
|
|
||
|
|
async def get_business_context(self, business_hours: dict, available_agents: int) -> str:
|
||
|
|
"""Format business context for the agent"""
|
||
|
|
try:
|
||
|
|
# Format business hours for each day
|
||
|
|
business_hours_text = []
|
||
|
|
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||
|
|
|
||
|
|
for day in days:
|
||
|
|
if day in business_hours:
|
||
|
|
day_hours = business_hours[day]
|
||
|
|
if day_hours.get('enabled', True):
|
||
|
|
business_hours_text.append(
|
||
|
|
f"{day.capitalize()}: {day_hours['start']} - {day_hours['end']}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
business_hours_text.append(f"{day.capitalize()}: Closed")
|
||
|
|
|
||
|
|
return (
|
||
|
|
"Business Hours:\n" +
|
||
|
|
"\n".join(business_hours_text) +
|
||
|
|
f"\nAvailable Agents: {available_agents}\n"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error formatting business context: {str(e)}")
|
||
|
|
# Fallback to basic format
|
||
|
|
return f"Business Hours: Standard working hours\nAvailable Agents: {available_agents}\n"
|
||
|
|
|
||
|
|
async def get_transfer_response(
|
||
|
|
self,
|
||
|
|
chat_history: list,
|
||
|
|
business_hours: dict,
|
||
|
|
available_agents: int,
|
||
|
|
is_business_hours: bool,
|
||
|
|
customer_email: str = None,
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""Get contextual transfer response from agent"""
|
||
|
|
logger.debug(f"Transfer Response Agent: {self.agent.instructions}")
|
||
|
|
# Format context for agent
|
||
|
|
business_context = await self.get_business_context(business_hours, available_agents)
|
||
|
|
|
||
|
|
# Add customer email context if available
|
||
|
|
email_context = f"\nCustomer email: {customer_email}" if customer_email else "\nNo customer email available"
|
||
|
|
|
||
|
|
# Format chat history
|
||
|
|
formatted_history = []
|
||
|
|
for msg in chat_history:
|
||
|
|
role = "User" if msg.message_type == "user" else "Bot"
|
||
|
|
formatted_history.append(f"{role}: {msg.message}")
|
||
|
|
|
||
|
|
chat_history_text = "\n".join(formatted_history[-5:]) # Get last 5 messages
|
||
|
|
|
||
|
|
prompt = (
|
||
|
|
f"Based on the following context, generate an appropriate response for communicating the transfer of the chat to the different agent:\n\n"
|
||
|
|
f"Business Context:\n{business_context}\n"
|
||
|
|
f"Currently within business hours: {is_business_hours}"
|
||
|
|
f"{email_context}\n\n"
|
||
|
|
f"{visitor_data_block('CONVERSATION', chat_history_text)}\n\n"
|
||
|
|
f"Instructions for response:\n"
|
||
|
|
f"1. If within business hours ({is_business_hours}) and agents available ({available_agents} online), "
|
||
|
|
f"explain that you need to transfer to a human agent who can better assist them.\n"
|
||
|
|
f"2. If outside business hours or no agents available, if jira tool is available, "
|
||
|
|
f"create a ticket so the team can follow up. "
|
||
|
|
f"{('Tell them the team will follow up at ' + customer_email + '. ') if customer_email else 'No email is on file. Simply reassure them that the team will follow up. Do NOT ask for an email; do NOT mention, reference, or link to any form; and never write a URL, a bracketed placeholder, or any email address. '}"
|
||
|
|
f"\n"
|
||
|
|
f"3. Keep the response professional and empathetic.\n"
|
||
|
|
f"4. Never show a placeholder or fake email address. Make it clear whether they should expect "
|
||
|
|
f"immediate help (transfer) or a follow-up.\n"
|
||
|
|
f"Generate a natural-sounding response:"
|
||
|
|
)
|
||
|
|
|
||
|
|
will_transfer = is_business_hours and available_agents > 0
|
||
|
|
|
||
|
|
# Bounded like the chat run: this call happens inside the visitor's turn,
|
||
|
|
# so a stuck run would hang the widget even though the handoff decision
|
||
|
|
# is already made (#269).
|
||
|
|
try:
|
||
|
|
response = await asyncio.wait_for(
|
||
|
|
self.agent.arun(message=prompt, stream=False),
|
||
|
|
timeout=settings.AGENT_RUN_TIMEOUT
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
logger.warning(
|
||
|
|
f"Transfer agent run timed out after {settings.AGENT_RUN_TIMEOUT}s and was "
|
||
|
|
f"cancelled; falling back to a plain handoff line "
|
||
|
|
f"(will_transfer={will_transfer})"
|
||
|
|
)
|
||
|
|
return {
|
||
|
|
"message": TRANSFER_TIMEOUT_MESSAGE if will_transfer else FOLLOW_UP_TIMEOUT_MESSAGE,
|
||
|
|
"transfer_to_human": will_transfer
|
||
|
|
}
|
||
|
|
|
||
|
|
# Use the utility function to parse the response
|
||
|
|
response_content = parse_response_content(response)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"message": response_content.message,
|
||
|
|
"transfer_to_human": will_transfer
|
||
|
|
}
|
||
|
|
|
||
|
|
async def get_agent_availability_response(
|
||
|
|
agent,
|
||
|
|
customer_id: str,
|
||
|
|
chat_history: list,
|
||
|
|
db,
|
||
|
|
api_key: str,
|
||
|
|
model_name: str,
|
||
|
|
model_type: str,
|
||
|
|
session_id: str,
|
||
|
|
transfer_group_id: str = None,
|
||
|
|
base_url: str = None
|
||
|
|
) -> dict:
|
||
|
|
customer_repo = CustomerRepository(db)
|
||
|
|
group_repo = GroupRepository(db)
|
||
|
|
|
||
|
|
# Check if we have a specific transfer group ID (for workflow transfers)
|
||
|
|
if transfer_group_id:
|
||
|
|
# For workflow transfers, use the specific group
|
||
|
|
try:
|
||
|
|
db_group = group_repo.get_group_with_users(transfer_group_id)
|
||
|
|
if not db_group:
|
||
|
|
return {
|
||
|
|
"message": "I apologize, but I'm unable to transfer the chat at this time.",
|
||
|
|
"transfer_to_human": False
|
||
|
|
}
|
||
|
|
agent_groups = [db_group]
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"Error getting transfer group {transfer_group_id}: {str(e)}")
|
||
|
|
return {
|
||
|
|
"message": "I apologize, but I'm unable to transfer the chat at this time.",
|
||
|
|
"transfer_to_human": False
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
# Check if agent has groups (normal transfer)
|
||
|
|
agent_groups = agent.get("groups") if isinstance(agent, dict) else agent.groups
|
||
|
|
if not agent or not agent_groups:
|
||
|
|
return {
|
||
|
|
"message": "I apologize, but I'm unable to transfer the chat at this time.",
|
||
|
|
"transfer_to_human": False
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get customer email if customer_id provided. Treat the auto-generated anonymous
|
||
|
|
# placeholder (…@noemail.com) as "no email" so the bot never promises to follow up
|
||
|
|
# at a fake address — the handoff contact form collects a real one instead.
|
||
|
|
customer_email = None
|
||
|
|
if customer_id:
|
||
|
|
customer_email = customer_repo.get_customer_email(customer_id)
|
||
|
|
if CustomerRepository.is_placeholder_email(customer_email):
|
||
|
|
customer_email = None
|
||
|
|
|
||
|
|
# Get available users with proper session handling
|
||
|
|
available_users = []
|
||
|
|
for group in agent_groups:
|
||
|
|
# Reload group with users relationship
|
||
|
|
db_group = group_repo.get_group_with_users(group.id)
|
||
|
|
if db_group:
|
||
|
|
for user in db_group.users:
|
||
|
|
if user.is_online and user.is_active:
|
||
|
|
available_users.append(user)
|
||
|
|
|
||
|
|
# Get organization's business hours
|
||
|
|
org = agent.get("organization") if isinstance(agent, dict) else getattr(agent, 'organization', None)
|
||
|
|
|
||
|
|
# The raw table still feeds the prompt (it tells the visitor when we're
|
||
|
|
# open); the open/closed decision is shared with the widget's presence line
|
||
|
|
# so the visitor is never told the team is around while the AI says
|
||
|
|
# otherwise.
|
||
|
|
business_hours = getattr(org, 'business_hours', None) or DEFAULT_BUSINESS_HOURS
|
||
|
|
is_business_hours = is_within_business_hours(org)
|
||
|
|
|
||
|
|
|
||
|
|
# Create transfer response agent
|
||
|
|
# The caller already loaded the agent; the name comes from it rather than a
|
||
|
|
# second lookup on a session nobody would close.
|
||
|
|
agent_name = (
|
||
|
|
(agent.get("display_name") or agent.get("name")) if isinstance(agent, dict)
|
||
|
|
else (getattr(agent, "display_name", None) or getattr(agent, "name", None))
|
||
|
|
)
|
||
|
|
transfer_agent = TransferResponseAgent(
|
||
|
|
api_key=api_key,
|
||
|
|
model_name=model_name,
|
||
|
|
model_type=model_type,
|
||
|
|
agent_name=agent_name,
|
||
|
|
base_url=base_url
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check if Jira is enabled for this agent
|
||
|
|
jira_enabled = False
|
||
|
|
if isinstance(agent, dict):
|
||
|
|
jira_enabled = agent.get("jira_enabled", False)
|
||
|
|
else:
|
||
|
|
# Handle the case when jira_enabled attribute doesn't exist
|
||
|
|
jira_enabled = getattr(agent, 'jira_enabled', False)
|
||
|
|
|
||
|
|
if jira_enabled:
|
||
|
|
jira_tools = JiraTools(
|
||
|
|
agent_id=agent.get("id") if isinstance(agent, dict) else agent.id,
|
||
|
|
org_id=org.id,
|
||
|
|
session_id=session_id
|
||
|
|
)
|
||
|
|
transfer_agent.agent.tools = [jira_tools]
|
||
|
|
|
||
|
|
# Get contextual response
|
||
|
|
response = await transfer_agent.get_transfer_response(
|
||
|
|
chat_history=chat_history or [],
|
||
|
|
business_hours=business_hours,
|
||
|
|
available_agents=len(available_users),
|
||
|
|
is_business_hours=is_business_hours,
|
||
|
|
customer_email=customer_email,
|
||
|
|
)
|
||
|
|
|
||
|
|
return response
|