One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
694 lines
27 KiB
Python
694 lines
27 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 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)) |