feat(rating): 结束服务、星级评价与管理员回看统计
结束会话后可提交 1-5 星与文字评价,重复提交更新原记录;管理端回看接待人与评分。
This commit is contained in:
1 parent
2c43a13774
commit
bf315b85ca
8 files changed
+3815
No files matched your search
@@ -0,0 +1,694 @@
|
||||
"""
|
||||
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 fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.services.feature_gate import check_feature_access
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.models.customer import Customer
|
||||
from app.models.rating import Rating
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import func, and_, or_, desc, distinct, case
|
||||
from typing import Optional, List
|
||||
from app.core.logger import get_logger
|
||||
from uuid import UUID
|
||||
|
||||
logger = get_logger(__name__)
|
||||
UPGRADE_MESSAGE = "Analytics are available on paid plans. Upgrade to see them."
|
||||
|
||||
|
||||
def analytics_enabled(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> None:
|
||||
"""Every analytics endpoint is a plan feature: the dashboard's upgrade
|
||||
overlay is not the gate, this is."""
|
||||
check_feature_access(db, current_user.organization_id, "analytics", UPGRADE_MESSAGE)
|
||||
|
||||
|
||||
router = APIRouter(dependencies=[Depends(analytics_enabled)])
|
||||
|
||||
def get_time_range_dates(time_range: str) -> tuple[datetime, datetime]:
|
||||
"""Get start and end dates based on time range"""
|
||||
# Use timezone-aware UTC so comparisons against timestamptz columns are correct
|
||||
# regardless of the database session timezone (naive bounds get interpreted in
|
||||
# the session tz, which silently drops recent rows when that tz isn't UTC).
|
||||
end_date = datetime.now(timezone.utc)
|
||||
|
||||
if time_range == '24h':
|
||||
start_date = end_date - timedelta(days=1)
|
||||
elif time_range == '7d':
|
||||
start_date = end_date - timedelta(days=7)
|
||||
elif time_range == '30d':
|
||||
start_date = end_date - timedelta(days=30)
|
||||
else: # 90d
|
||||
start_date = end_date - timedelta(days=90)
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
def get_interval(time_range: str) -> str:
|
||||
"""Get SQL interval based on time range"""
|
||||
if time_range == '24h':
|
||||
return 'hour'
|
||||
elif time_range == '7d':
|
||||
return 'day'
|
||||
elif time_range == '30d':
|
||||
return 'day'
|
||||
else: # 90d
|
||||
return 'week'
|
||||
|
||||
@router.get("/agent-performance")
|
||||
async def get_agent_performance(
|
||||
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get agent performance analytics data for the organization"""
|
||||
try:
|
||||
start_date, end_date = get_time_range_dates(time_range)
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Get bot agent performance
|
||||
bot_agents_query = db.query(
|
||||
Agent.id,
|
||||
Agent.name,
|
||||
func.count(SessionToAgent.session_id).label('total_chats'),
|
||||
func.sum(
|
||||
case((SessionToAgent.status == SessionStatus.CLOSED, 1), else_=0)
|
||||
).label('closed_chats'),
|
||||
func.avg(Rating.rating).label('avg_rating'),
|
||||
func.count(Rating.id).label('rating_count')
|
||||
).outerjoin(
|
||||
SessionToAgent,
|
||||
and_(
|
||||
SessionToAgent.agent_id == Agent.id,
|
||||
SessionToAgent.assigned_at.between(start_date, end_date)
|
||||
)
|
||||
).outerjoin(
|
||||
Rating,
|
||||
and_(
|
||||
Rating.session_id == SessionToAgent.session_id,
|
||||
Rating.created_at.between(start_date, end_date)
|
||||
)
|
||||
).filter(
|
||||
Agent.organization_id == org_id
|
||||
).group_by(
|
||||
Agent.id
|
||||
).order_by(
|
||||
desc('total_chats')
|
||||
)
|
||||
|
||||
bot_agents = bot_agents_query.all()
|
||||
|
||||
# Get human agent performance
|
||||
human_agents_query = db.query(
|
||||
User.id,
|
||||
User.full_name.label('name'),
|
||||
func.count(SessionToAgent.session_id).label('total_chats'),
|
||||
func.sum(
|
||||
case((SessionToAgent.status == SessionStatus.CLOSED, 1), else_=0)
|
||||
).label('closed_chats'),
|
||||
func.avg(Rating.rating).label('avg_rating'),
|
||||
func.count(Rating.id).label('rating_count')
|
||||
).outerjoin(
|
||||
SessionToAgent,
|
||||
and_(
|
||||
SessionToAgent.user_id == User.id,
|
||||
SessionToAgent.assigned_at.between(start_date, end_date)
|
||||
)
|
||||
).outerjoin(
|
||||
Rating,
|
||||
and_(
|
||||
Rating.session_id == SessionToAgent.session_id,
|
||||
Rating.created_at.between(start_date, end_date)
|
||||
)
|
||||
).filter(
|
||||
User.organization_id == org_id
|
||||
).group_by(
|
||||
User.id
|
||||
).order_by(
|
||||
desc('total_chats')
|
||||
)
|
||||
|
||||
human_agents = human_agents_query.all()
|
||||
|
||||
# Format the results
|
||||
bot_results = []
|
||||
for agent in bot_agents:
|
||||
bot_results.append({
|
||||
"id": str(agent.id),
|
||||
"name": agent.name,
|
||||
"total_chats": agent.total_chats,
|
||||
"closed_chats": agent.closed_chats,
|
||||
"avg_rating": float(agent.avg_rating) if agent.avg_rating else 0,
|
||||
"rating_count": agent.rating_count
|
||||
})
|
||||
|
||||
human_results = []
|
||||
for agent in human_agents:
|
||||
human_results.append({
|
||||
"id": str(agent.id),
|
||||
"name": agent.name or "Unknown User",
|
||||
"total_chats": agent.total_chats,
|
||||
"closed_chats": agent.closed_chats,
|
||||
"avg_rating": float(agent.avg_rating) if agent.avg_rating else 0,
|
||||
"rating_count": agent.rating_count
|
||||
})
|
||||
|
||||
return {
|
||||
"bot_agents": bot_results,
|
||||
"human_agents": human_results,
|
||||
"time_range": time_range
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting agent performance analytics: {str(e)}")
|
||||
raise
|
||||
|
||||
@router.get("")
|
||||
async def get_analytics(
|
||||
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get analytics data for the organization"""
|
||||
try:
|
||||
start_date, end_date = get_time_range_dates(time_range)
|
||||
interval = get_interval(time_range)
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Get conversation metrics
|
||||
conversations_query = db.query(
|
||||
func.count(SessionToAgent.session_id).label('total'),
|
||||
func.date_trunc(interval, SessionToAgent.assigned_at).label('period')
|
||||
).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at.between(start_date, end_date)
|
||||
).group_by('period').order_by('period')
|
||||
|
||||
conversations = conversations_query.all()
|
||||
|
||||
# Get previous period data for comparison
|
||||
prev_start = start_date - (end_date - start_date)
|
||||
prev_total = db.query(func.count(SessionToAgent.session_id)).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at.between(prev_start, start_date)
|
||||
).scalar() or 0
|
||||
|
||||
current_total = sum(c.total for c in conversations)
|
||||
conv_change = ((current_total - prev_total) / prev_total * 100) if prev_total > 0 else 0
|
||||
|
||||
# Get AI chat closures
|
||||
ai_closures_query = db.query(
|
||||
func.count(SessionToAgent.session_id).label('total'),
|
||||
func.date_trunc(interval, SessionToAgent.updated_at).label('period')
|
||||
).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.updated_at.between(start_date, end_date),
|
||||
SessionToAgent.status == SessionStatus.CLOSED,
|
||||
SessionToAgent.user_id == None # AI closures have no user_id
|
||||
).group_by('period').order_by('period')
|
||||
|
||||
ai_closures = ai_closures_query.all()
|
||||
|
||||
current_ai_closures = sum(c.total for c in ai_closures)
|
||||
prev_ai_closures = db.query(func.count(SessionToAgent.session_id)).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.updated_at.between(prev_start, start_date),
|
||||
SessionToAgent.status == SessionStatus.CLOSED,
|
||||
SessionToAgent.user_id == None
|
||||
).scalar() or 0
|
||||
|
||||
ai_closures_change = ((current_ai_closures - prev_ai_closures) / prev_ai_closures * 100) if prev_ai_closures > 0 else 0
|
||||
|
||||
# Get human transfers
|
||||
transfers_query = db.query(
|
||||
func.count(SessionToAgent.session_id).label('total'),
|
||||
func.date_trunc(interval, SessionToAgent.updated_at).label('period')
|
||||
).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.updated_at.between(start_date, end_date),
|
||||
SessionToAgent.user_id != None # Human transfers have a user_id
|
||||
).group_by('period').order_by('period')
|
||||
|
||||
transfers = transfers_query.all()
|
||||
|
||||
current_transfers = sum(t.total for t in transfers)
|
||||
prev_transfers = db.query(func.count(SessionToAgent.session_id)).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.updated_at.between(prev_start, start_date),
|
||||
SessionToAgent.user_id != None # Human transfers have a user_id
|
||||
).scalar() or 0
|
||||
|
||||
transfers_change = ((current_transfers - prev_transfers) / prev_transfers * 100) if prev_transfers > 0 else 0
|
||||
|
||||
# Get bot ratings
|
||||
bot_ratings_query = db.query(
|
||||
func.avg(Rating.rating).label('avg_rating'),
|
||||
func.count(Rating.id).label('total'),
|
||||
func.date_trunc(interval, Rating.created_at).label('period')
|
||||
).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(start_date, end_date),
|
||||
SessionToAgent.user_id == None # Bot sessions have no user_id
|
||||
).group_by('period').order_by('period')
|
||||
|
||||
bot_ratings = bot_ratings_query.all()
|
||||
|
||||
# Get human ratings
|
||||
human_ratings_query = db.query(
|
||||
func.avg(Rating.rating).label('avg_rating'),
|
||||
func.count(Rating.id).label('total'),
|
||||
func.date_trunc(interval, Rating.created_at).label('period')
|
||||
).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(start_date, end_date),
|
||||
SessionToAgent.user_id != None # Human sessions have a user_id
|
||||
).group_by('period').order_by('period')
|
||||
|
||||
human_ratings = human_ratings_query.all()
|
||||
|
||||
# Calculate rating averages and changes
|
||||
current_bot_avg = db.query(func.avg(Rating.rating)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(start_date, end_date),
|
||||
SessionToAgent.user_id == None
|
||||
).scalar() or 0
|
||||
|
||||
prev_bot_avg = db.query(func.avg(Rating.rating)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(prev_start, start_date),
|
||||
SessionToAgent.user_id == None
|
||||
).scalar() or 0
|
||||
|
||||
current_human_avg = db.query(func.avg(Rating.rating)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(start_date, end_date),
|
||||
SessionToAgent.user_id != None
|
||||
).scalar() or 0
|
||||
|
||||
prev_human_avg = db.query(func.avg(Rating.rating)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
Rating.created_at.between(prev_start, start_date),
|
||||
SessionToAgent.user_id != None
|
||||
).scalar() or 0
|
||||
|
||||
# Calculate rating counts
|
||||
bot_count = db.query(func.count(Rating.id)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
SessionToAgent.user_id == None
|
||||
).scalar() or 0
|
||||
|
||||
human_count = db.query(func.count(Rating.id)).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).filter(
|
||||
Rating.organization_id == org_id,
|
||||
SessionToAgent.user_id != None
|
||||
).scalar() or 0
|
||||
|
||||
# Calculate rating changes
|
||||
bot_change = ((current_bot_avg - prev_bot_avg) / prev_bot_avg * 100) if prev_bot_avg > 0 else 0
|
||||
human_change = ((current_human_avg - prev_human_avg) / prev_human_avg * 100) if prev_human_avg > 0 else 0
|
||||
|
||||
return {
|
||||
"conversations": {
|
||||
"total": current_total,
|
||||
"change": conv_change,
|
||||
"trend": "up" if conv_change >= 0 else "down",
|
||||
"data": [r.total for r in conversations],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in conversations]
|
||||
},
|
||||
"aiClosures": {
|
||||
"total": current_ai_closures,
|
||||
"change": ai_closures_change,
|
||||
"trend": "up" if ai_closures_change >= 0 else "down",
|
||||
"data": [r.total for r in ai_closures],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in ai_closures]
|
||||
},
|
||||
"transfers": {
|
||||
"total": current_transfers,
|
||||
"change": transfers_change,
|
||||
"trend": "up" if transfers_change >= 0 else "down",
|
||||
"data": [r.total for r in transfers],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in transfers]
|
||||
},
|
||||
"ratings": {
|
||||
"bot": {
|
||||
"data": [float(r.avg_rating or 0) for r in bot_ratings],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in bot_ratings],
|
||||
"change": bot_change,
|
||||
"trend": "up" if bot_change >= 0 else "down"
|
||||
},
|
||||
"human": {
|
||||
"data": [float(r.avg_rating or 0) for r in human_ratings],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in human_ratings],
|
||||
"change": human_change,
|
||||
"trend": "up" if human_change >= 0 else "down"
|
||||
},
|
||||
"bot_avg": float(current_bot_avg),
|
||||
"human_avg": float(current_human_avg),
|
||||
"bot_count": bot_count,
|
||||
"human_count": human_count,
|
||||
"bot_change": bot_change,
|
||||
"human_change": human_change,
|
||||
"bot_trend": "up" if bot_change >= 0 else "down",
|
||||
"human_trend": "up" if human_change >= 0 else "down"
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting analytics: {str(e)}")
|
||||
raise
|
||||
|
||||
@router.get("/customer-analytics")
|
||||
async def get_customer_analytics(
|
||||
time_range: str = Query('7d', pattern='^(24h|7d|30d|90d)$'),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get customer analytics data for the organization"""
|
||||
try:
|
||||
start_date, end_date = get_time_range_dates(time_range)
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Get base customer query
|
||||
customer_query = db.query(
|
||||
Customer.id,
|
||||
Customer.email,
|
||||
Customer.full_name,
|
||||
func.count(func.distinct(SessionToAgent.session_id)).label('total_chats'),
|
||||
func.max(SessionToAgent.assigned_at).label('last_interaction'),
|
||||
func.avg(Rating.rating).label('avg_rating'),
|
||||
func.count(Rating.id).label('rating_count')
|
||||
).outerjoin(
|
||||
SessionToAgent,
|
||||
and_(
|
||||
Customer.id == SessionToAgent.customer_id,
|
||||
SessionToAgent.assigned_at.between(start_date, end_date)
|
||||
)
|
||||
).outerjoin(
|
||||
Rating,
|
||||
and_(
|
||||
Rating.customer_id == Customer.id,
|
||||
Rating.organization_id == Customer.organization_id
|
||||
)
|
||||
).filter(
|
||||
Customer.organization_id == org_id
|
||||
).group_by(
|
||||
Customer.id
|
||||
).order_by(
|
||||
Customer.created_at.desc()
|
||||
)
|
||||
|
||||
# Get total count for pagination
|
||||
total_count = customer_query.count()
|
||||
|
||||
# Apply pagination
|
||||
customers = customer_query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
result = []
|
||||
for customer in customers:
|
||||
result.append({
|
||||
"id": str(customer.id),
|
||||
"email": customer.email,
|
||||
"full_name": customer.full_name,
|
||||
"total_chats": customer.total_chats,
|
||||
"last_interaction": customer.last_interaction.isoformat() if customer.last_interaction else None,
|
||||
"avg_rating": float(customer.avg_rating) if customer.avg_rating else 0,
|
||||
"rating_count": customer.rating_count
|
||||
})
|
||||
|
||||
return {
|
||||
"customers": result,
|
||||
"time_range": time_range,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": (total_count + page_size - 1) // page_size
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting customer analytics: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting customer analytics: {str(e)}")
|
||||
|
||||
@router.get("/customer-details/{customer_id}")
|
||||
async def get_customer_details(
|
||||
customer_id: UUID,
|
||||
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get detailed information about a specific customer"""
|
||||
try:
|
||||
start_date, end_date = get_time_range_dates(time_range)
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Verify customer belongs to organization
|
||||
customer = db.query(Customer).filter(
|
||||
Customer.id == customer_id,
|
||||
Customer.organization_id == org_id
|
||||
).first()
|
||||
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Get customer feedback
|
||||
feedback_query = db.query(
|
||||
Rating.rating,
|
||||
Rating.feedback,
|
||||
Rating.created_at,
|
||||
Agent.name.label('agent_name'),
|
||||
User.full_name.label('user_name')
|
||||
).join(
|
||||
SessionToAgent, Rating.session_id == SessionToAgent.session_id
|
||||
).outerjoin(
|
||||
Agent, SessionToAgent.agent_id == Agent.id
|
||||
).outerjoin(
|
||||
User, SessionToAgent.user_id == User.id
|
||||
).filter(
|
||||
Rating.customer_id == customer_id,
|
||||
Rating.organization_id == org_id
|
||||
).order_by(
|
||||
desc(Rating.created_at)
|
||||
)
|
||||
|
||||
feedback = feedback_query.all()
|
||||
|
||||
# Format the results
|
||||
feedback_results = []
|
||||
for item in feedback:
|
||||
agent_name = item.agent_name
|
||||
if item.user_name: # If handled by a human agent
|
||||
agent_name = item.user_name
|
||||
|
||||
feedback_results.append({
|
||||
"rating": item.rating,
|
||||
"feedback": item.feedback,
|
||||
"created_at": item.created_at.isoformat(),
|
||||
"agent_name": agent_name
|
||||
})
|
||||
|
||||
return {
|
||||
"feedback": feedback_results
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting customer details: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sentiment")
|
||||
async def get_sentiment_analytics(
|
||||
time_range: str = Query('7d', regex='^(24h|7d|30d|90d)$'),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get sentiment distribution and trends for the organization"""
|
||||
try:
|
||||
start_date, end_date = get_time_range_dates(time_range)
|
||||
interval = get_interval(time_range)
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Sentiment distribution (counts of positive/neutral/negative)
|
||||
distribution = db.query(
|
||||
ChatHistory.sentiment_label,
|
||||
func.count(ChatHistory.id).label('count')
|
||||
).filter(
|
||||
ChatHistory.organization_id == org_id,
|
||||
ChatHistory.created_at.between(start_date, end_date),
|
||||
ChatHistory.message_type == 'user',
|
||||
ChatHistory.sentiment_label.isnot(None)
|
||||
).group_by(
|
||||
ChatHistory.sentiment_label
|
||||
).all()
|
||||
|
||||
dist_dict = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
for row in distribution:
|
||||
if row.sentiment_label in dist_dict:
|
||||
dist_dict[row.sentiment_label] = row.count
|
||||
|
||||
total_analyzed = sum(dist_dict.values())
|
||||
|
||||
# Sentiment trend over time
|
||||
trend = db.query(
|
||||
func.date_trunc(interval, ChatHistory.created_at).label('period'),
|
||||
func.avg(ChatHistory.sentiment_score).label('avg_score'),
|
||||
func.count(ChatHistory.id).label('message_count')
|
||||
).filter(
|
||||
ChatHistory.organization_id == org_id,
|
||||
ChatHistory.created_at.between(start_date, end_date),
|
||||
ChatHistory.message_type == 'user',
|
||||
ChatHistory.sentiment_score.isnot(None)
|
||||
).group_by('period').order_by('period').all()
|
||||
|
||||
# Average sentiment score
|
||||
avg_score = db.query(
|
||||
func.avg(ChatHistory.sentiment_score)
|
||||
).filter(
|
||||
ChatHistory.organization_id == org_id,
|
||||
ChatHistory.created_at.between(start_date, end_date),
|
||||
ChatHistory.message_type == 'user',
|
||||
ChatHistory.sentiment_score.isnot(None)
|
||||
).scalar() or 0.0
|
||||
|
||||
# Previous period comparison
|
||||
prev_start = start_date - (end_date - start_date)
|
||||
prev_avg = db.query(
|
||||
func.avg(ChatHistory.sentiment_score)
|
||||
).filter(
|
||||
ChatHistory.organization_id == org_id,
|
||||
ChatHistory.created_at.between(prev_start, start_date),
|
||||
ChatHistory.message_type == 'user',
|
||||
ChatHistory.sentiment_score.isnot(None)
|
||||
).scalar() or 0.0
|
||||
|
||||
score_change = ((float(avg_score) - float(prev_avg)) / abs(float(prev_avg)) * 100) if prev_avg else 0.0
|
||||
|
||||
# Sessions with negative sentiment (for agent attention)
|
||||
negative_sessions = db.query(
|
||||
SessionToAgent.session_id,
|
||||
SessionToAgent.sentiment_label,
|
||||
SessionToAgent.sentiment_score,
|
||||
SessionToAgent.status
|
||||
).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at.between(start_date, end_date),
|
||||
SessionToAgent.sentiment_label == 'negative'
|
||||
).order_by(
|
||||
SessionToAgent.sentiment_score.asc()
|
||||
).limit(10).all()
|
||||
|
||||
return {
|
||||
"distribution": dist_dict,
|
||||
"total_analyzed": total_analyzed,
|
||||
"avg_score": round(float(avg_score), 4),
|
||||
"score_change": round(score_change, 2),
|
||||
"score_trend": "up" if score_change >= 0 else "down",
|
||||
"trend": {
|
||||
"data": [round(float(r.avg_score or 0), 4) for r in trend],
|
||||
"labels": [r.period.strftime("%Y-%m-%d") for r in trend],
|
||||
"message_counts": [r.message_count for r in trend]
|
||||
},
|
||||
"negative_sessions": [
|
||||
{
|
||||
"session_id": str(s.session_id),
|
||||
"sentiment_label": s.sentiment_label,
|
||||
"sentiment_score": round(float(s.sentiment_score), 4) if s.sentiment_score else None,
|
||||
"status": s.status
|
||||
}
|
||||
for s in negative_sessions
|
||||
],
|
||||
"time_range": time_range
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sentiment analytics: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting sentiment analytics: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/session-sentiment/{session_id}")
|
||||
async def get_session_sentiment(
|
||||
session_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permissions("view_analytics"))
|
||||
):
|
||||
"""Get sentiment breakdown for a specific session"""
|
||||
try:
|
||||
org_id = current_user.organization_id
|
||||
|
||||
# Verify session belongs to organization
|
||||
session = db.query(SessionToAgent).filter(
|
||||
SessionToAgent.session_id == session_id,
|
||||
SessionToAgent.organization_id == org_id
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Get per-message sentiment for customer messages
|
||||
messages = db.query(
|
||||
ChatHistory.id,
|
||||
ChatHistory.message,
|
||||
ChatHistory.sentiment_label,
|
||||
ChatHistory.sentiment_score,
|
||||
ChatHistory.created_at
|
||||
).filter(
|
||||
ChatHistory.session_id == session_id,
|
||||
ChatHistory.message_type == 'user',
|
||||
ChatHistory.sentiment_label.isnot(None)
|
||||
).order_by(
|
||||
ChatHistory.created_at.asc()
|
||||
).all()
|
||||
|
||||
return {
|
||||
"session_id": str(session_id),
|
||||
"overall_sentiment": {
|
||||
"label": session.sentiment_label,
|
||||
"score": round(float(session.sentiment_score), 4) if session.sentiment_score else None
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"message": m.message[:200] if m.message else None, # Truncate for privacy
|
||||
"sentiment_label": m.sentiment_label,
|
||||
"sentiment_score": round(float(m.sentiment_score), 4) if m.sentiment_score else None,
|
||||
"created_at": m.created_at.isoformat()
|
||||
}
|
||||
for m in messages
|
||||
]
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting session sentiment: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
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 Tuple, List, Optional
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Try to import TextBlob, fall back to basic keyword analysis if unavailable
|
||||
try:
|
||||
from textblob import TextBlob
|
||||
TEXTBLOB_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEXTBLOB_AVAILABLE = False
|
||||
logger.warning("TextBlob not installed. Using basic keyword-based sentiment analysis. "
|
||||
"Install with: pip install textblob")
|
||||
|
||||
|
||||
# Basic keyword lists for fallback sentiment analysis
|
||||
POSITIVE_KEYWORDS = {
|
||||
'thank', 'thanks', 'great', 'awesome', 'excellent', 'good', 'love',
|
||||
'perfect', 'amazing', 'wonderful', 'helpful', 'appreciate', 'happy',
|
||||
'pleased', 'fantastic', 'brilliant', 'nice', 'best', 'super', 'cool',
|
||||
'resolved', 'fixed', 'works', 'working', 'solved'
|
||||
}
|
||||
|
||||
NEGATIVE_KEYWORDS = {
|
||||
'bad', 'terrible', 'awful', 'horrible', 'worst', 'hate', 'angry',
|
||||
'frustrated', 'annoyed', 'disappointed', 'useless', 'broken', 'bug',
|
||||
'error', 'fail', 'failed', 'wrong', 'issue', 'problem', 'slow',
|
||||
'crash', 'crashed', 'stuck', 'ridiculous', 'unacceptable', 'poor',
|
||||
'waste', 'stupid', 'pathetic', 'sucks', 'rubbish'
|
||||
}
|
||||
|
||||
|
||||
def _analyze_with_textblob(text: str) -> Tuple[str, float]:
|
||||
"""Analyze sentiment using TextBlob."""
|
||||
blob = TextBlob(text)
|
||||
polarity = blob.sentiment.polarity # -1.0 to 1.0
|
||||
|
||||
if polarity > 0.1:
|
||||
label = "positive"
|
||||
elif polarity < -0.1:
|
||||
label = "negative"
|
||||
else:
|
||||
label = "neutral"
|
||||
|
||||
return label, round(polarity, 4)
|
||||
|
||||
|
||||
def _analyze_with_keywords(text: str) -> Tuple[str, float]:
|
||||
"""Fallback keyword-based sentiment analysis."""
|
||||
words = set(text.lower().split())
|
||||
|
||||
positive_count = len(words & POSITIVE_KEYWORDS)
|
||||
negative_count = len(words & NEGATIVE_KEYWORDS)
|
||||
|
||||
total = positive_count + negative_count
|
||||
if total == 0:
|
||||
return "neutral", 0.0
|
||||
|
||||
# Simple scoring: range from -1 to 1
|
||||
score = (positive_count - negative_count) / total
|
||||
|
||||
if score > 0.1:
|
||||
label = "positive"
|
||||
elif score < -0.1:
|
||||
label = "negative"
|
||||
else:
|
||||
label = "neutral"
|
||||
|
||||
return label, round(score, 4)
|
||||
|
||||
|
||||
def analyze_sentiment(text: str) -> Tuple[str, float]:
|
||||
"""
|
||||
Analyze the sentiment of a text message.
|
||||
|
||||
Returns:
|
||||
Tuple of (sentiment_label, sentiment_score)
|
||||
- sentiment_label: 'positive', 'neutral', or 'negative'
|
||||
- sentiment_score: float from -1.0 (most negative) to 1.0 (most positive)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "neutral", 0.0
|
||||
|
||||
try:
|
||||
if TEXTBLOB_AVAILABLE:
|
||||
return _analyze_with_textblob(text)
|
||||
else:
|
||||
return _analyze_with_keywords(text)
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing sentiment: {str(e)}")
|
||||
return "neutral", 0.0
|
||||
|
||||
|
||||
def compute_session_sentiment(
|
||||
sentiment_scores: List[float],
|
||||
sentiment_labels: List[str]
|
||||
) -> Tuple[Optional[str], Optional[float]]:
|
||||
"""
|
||||
Compute the overall sentiment for a session from individual message sentiments.
|
||||
|
||||
Args:
|
||||
sentiment_scores: List of sentiment scores from customer messages
|
||||
sentiment_labels: List of sentiment labels from customer messages
|
||||
|
||||
Returns:
|
||||
Tuple of (overall_label, overall_score) or (None, None) if no data
|
||||
"""
|
||||
if not sentiment_scores:
|
||||
return None, None
|
||||
|
||||
avg_score = sum(sentiment_scores) / len(sentiment_scores)
|
||||
|
||||
if avg_score > 0.1:
|
||||
label = "positive"
|
||||
elif avg_score < -0.1:
|
||||
label = "negative"
|
||||
else:
|
||||
label = "neutral"
|
||||
|
||||
return label, round(avg_score, 4)
|
||||
Reference in new issue
Block a user