diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py new file mode 100644 index 0000000..d9b0454 --- /dev/null +++ b/backend/app/api/analytics.py @@ -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)) \ No newline at end of file diff --git a/backend/app/services/sentiment.py b/backend/app/services/sentiment.py new file mode 100644 index 0000000..df45b22 --- /dev/null +++ b/backend/app/services/sentiment.py @@ -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) diff --git a/frontend/src/components/analytics/AgentPerformance.vue b/frontend/src/components/analytics/AgentPerformance.vue new file mode 100644 index 0000000..1e28b60 --- /dev/null +++ b/frontend/src/components/analytics/AgentPerformance.vue @@ -0,0 +1,303 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/analytics/AnalyticsDashboard.vue b/frontend/src/components/analytics/AnalyticsDashboard.vue new file mode 100644 index 0000000..99d3086 --- /dev/null +++ b/frontend/src/components/analytics/AnalyticsDashboard.vue @@ -0,0 +1,1011 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/analytics/CustomerAnalytics.vue b/frontend/src/components/analytics/CustomerAnalytics.vue new file mode 100644 index 0000000..54a18bb --- /dev/null +++ b/frontend/src/components/analytics/CustomerAnalytics.vue @@ -0,0 +1,889 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/analytics/SentimentAnalytics.vue b/frontend/src/components/analytics/SentimentAnalytics.vue new file mode 100644 index 0000000..f7f436b --- /dev/null +++ b/frontend/src/components/analytics/SentimentAnalytics.vue @@ -0,0 +1,702 @@ + + + + + + + diff --git a/frontend/src/utils/endChat.ts b/frontend/src/utils/endChat.ts new file mode 100644 index 0000000..5a1d2c4 --- /dev/null +++ b/frontend/src/utils/endChat.ts @@ -0,0 +1,54 @@ +/* +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. +*/ + +/** + * Channels served by our own widget rather than an external messaging platform. + * Mirrors WIDGET_CHANNELS in backend/app/channels/constants.py — keep the two + * in step. 'shopify' is the widget embedded in a storefront: a separate label + * in the inbox, the same chat surface. + */ +const WIDGET_CHANNELS = ['web', 'shopify'] + +/** + * Rating is a widget-only feature. External channels (WhatsApp, Messenger, + * Instagram, Telegram, ...) render plain text and have no rating UI, so asking + * there leaves the customer a question they cannot answer. The backend applies + * the same rule to AI-ended chats. + */ +export const canRequestRating = (channel?: string | null): boolean => + !channel || WIDGET_CHANNELS.includes(channel) + +/** Closing message shown to the customer when an agent ends the chat. */ +export const endChatMessage = (channel?: string | null): string => + canRequestRating(channel) + ? '感谢您的咨询。方便为我们的服务评分吗?' + : '感谢您的咨询。' + +interface EndChatCandidate { + attributes?: { end_chat?: boolean } | null +} + +/** + * Whether a widget message is the one that actually ends the conversation. + * + * `attributes.end_chat` is the only signal for this — message_type and the + * presence of a session_id are not. The takeover notice and form prompts are + * ordinary conversation messages that happen to carry a real session_id, and + * treating those as an end-chat closed live conversations the moment a human + * agent joined. + */ +export const isEndChatMessage = (message?: EndChatCandidate | null): boolean => + Boolean(message?.attributes?.end_chat) diff --git a/frontend/src/views/AnalyticsView.vue b/frontend/src/views/AnalyticsView.vue new file mode 100644 index 0000000..a211c4d --- /dev/null +++ b/frontend/src/views/AnalyticsView.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file