feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
This commit is contained in:
1 parent
738e37fbb0
commit
362d79a5c2
65 files changed
+33409
No files matched your search
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user