""" Executive Summary Services - Aggregation, AI narratives, predictive analytics, and recommendations This module provides service classes for: - ExecutiveSummaryService: Aggregates daily metrics and dashboard data - AINarrativeService: Generates AI-powered narrative summaries (English and Arabic) - PredictiveAnalyticsService: Detects anomalies, predicts SLA breaches, calculates risk scores - RecommendationService: Generates AI recommendations based on insights All services use Django ORM queries following patterns from apps/dashboard/views.py and leverage LiteLLM for AI-powered analysis. """ import logging import statistics from datetime import timedelta, date from decimal import Decimal from typing import Any, Optional from django.db.models import Avg, Count, Q, Sum from django.utils import timezone from django.utils.translation import gettext_lazy as _ logger = logging.getLogger(__name__) # ============================================================================= # ExecutiveSummaryService # ============================================================================= class ExecutiveSummaryService: """ Service for aggregating executive metrics and generating dashboard data. Aggregates data from complaints, surveys, PX actions, observations, call center interactions, and physician ratings across all hospitals. """ def __init__(self) -> None: from apps.complaints.models import Complaint, ComplaintStatus from apps.complaints.models import Inquiry from apps.observations.models import Observation from apps.px_action_center.models import PXAction from apps.surveys.models import SurveyInstance self.Complaint = Complaint self.ComplaintStatus = ComplaintStatus self.Inquiry = Inquiry self.PXAction = PXAction self.SurveyInstance = SurveyInstance self.Observation = Observation def aggregate_daily_metrics(self, target_date: Optional[date] = None, hospital=None) -> dict[str, Decimal]: """ Aggregate daily metrics across all hospitals (or a specific hospital) for a given date. Args: target_date: Date to aggregate metrics for (defaults to today) hospital: Optional Hospital instance to filter by Returns: Dictionary of metric_type -> Decimal value """ if target_date is None: target_date = timezone.now().date() start_of_day = timezone.make_aware(timezone.datetime.combine(target_date, timezone.datetime.min.time())) end_of_day = start_of_day + timedelta(days=1) base_filters: dict[str, Any] = {"created_at__gte": start_of_day, "created_at__lt": end_of_day} if hospital: base_filters["hospital"] = hospital metrics: dict[str, Decimal] = {} # --- Complaints --- complaints_qs = self.Complaint.objects.filter(**base_filters) metrics["complaints_total"] = Decimal(complaints_qs.count()) metrics["complaints_critical"] = Decimal(complaints_qs.filter(severity="critical").count()) metrics["complaints_overdue"] = Decimal( complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() ) resolved_complaints = complaints_qs.filter( status="closed", closed_at__isnull=False, ) if resolved_complaints.exists(): total_hours = sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved_complaints) avg_hours = total_hours / resolved_complaints.count() metrics["complaints_resolution_time"] = Decimal(str(avg_hours)).quantize(Decimal("0.01")) else: metrics["complaints_resolution_time"] = Decimal("0") # --- Surveys --- survey_base_filters: dict[str, Any] = {"completed_at__gte": start_of_day, "completed_at__lt": end_of_day} if hospital: survey_base_filters["hospital"] = hospital surveys_qs = self.SurveyInstance.objects.filter(**survey_base_filters) metrics["surveys_total"] = Decimal(surveys_qs.count()) satisfaction = surveys_qs.filter(total_score__isnull=False).aggregate(avg_score=Avg("total_score"))["avg_score"] if satisfaction is not None: metrics["surveys_satisfaction"] = Decimal(str(satisfaction)).quantize(Decimal("0.01")) else: metrics["surveys_satisfaction"] = Decimal("0") # NPS-style calculation total_surveys = surveys_qs.count() if total_surveys > 0: positive = surveys_qs.filter(is_negative=False).count() negative = surveys_qs.filter(is_negative=True).count() nps = Decimal(((positive - negative) / total_surveys) * 100).quantize(Decimal("0.01")) else: nps = Decimal("0") metrics["surveys_nps"] = nps surveys_sent = self.SurveyInstance.objects.filter( sent_at__gte=start_of_day, sent_at__lt=end_of_day, **({"hospital": hospital} if hospital else {}), ).count() if surveys_sent > 0: metrics["surveys_response_rate"] = Decimal(str((total_surveys / surveys_sent) * 100)).quantize( Decimal("0.01") ) else: metrics["surveys_response_rate"] = Decimal("0") # --- PX Actions --- action_base_filters: dict[str, Any] = {"created_at__gte": start_of_day, "created_at__lt": end_of_day} if hospital: action_base_filters["hospital"] = hospital actions_qs = self.PXAction.objects.filter(**action_base_filters) metrics["actions_total"] = Decimal(actions_qs.count()) metrics["actions_open"] = Decimal(actions_qs.filter(status="open").count()) metrics["actions_overdue"] = Decimal( actions_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() ) metrics["actions_closed"] = Decimal(actions_qs.filter(status="closed").count()) # --- Observations --- obs_base_filters: dict[str, Any] = {"created_at__gte": start_of_day, "created_at__lt": end_of_day} if hospital: obs_base_filters["hospital"] = hospital observations_qs = self.Observation.objects.filter(**obs_base_filters) metrics["observations_total"] = Decimal(observations_qs.count()) metrics["observations_critical"] = Decimal(observations_qs.filter(severity="critical").count()) # --- Inquiries --- inquiry_base_filters: dict[str, Any] = {"created_at__gte": start_of_day, "created_at__lt": end_of_day} if hospital: inquiry_base_filters["hospital"] = hospital inquiries_qs = self.Inquiry.objects.filter(**inquiry_base_filters) metrics["inquiries_total"] = Decimal(inquiries_qs.count()) metrics["inquiries_resolved"] = Decimal(inquiries_qs.filter(status="resolved").count()) # --- Call Center (if available) --- try: from apps.callcenter.models import CallCenterInteraction call_base_filters: dict[str, Any] = { "call_started_at__gte": start_of_day, "call_started_at__lt": end_of_day, } if hospital: call_base_filters["hospital"] = hospital calls_qs = CallCenterInteraction.objects.filter(**call_base_filters) metrics["call_center_total"] = Decimal(calls_qs.count()) total_calls = calls_qs.count() if total_calls > 0: low_ratings = calls_qs.filter(is_low_rating=True).count() satisfaction_rate = ((total_calls - low_ratings) / total_calls) * 100 metrics["call_center_satisfaction"] = Decimal(str(satisfaction_rate)).quantize(Decimal("0.01")) else: metrics["call_center_satisfaction"] = Decimal("0") except Exception: metrics["call_center_total"] = Decimal("0") metrics["call_center_satisfaction"] = Decimal("0") # --- Physician Ratings --- try: from apps.organizations.models import Staff from apps.physicians.models import PhysicianMonthlyRating now = timezone.now() physician_qs = PhysicianMonthlyRating.objects.filter(year=now.year, month=now.month) if hospital: physician_qs = physician_qs.filter(staff__hospital=hospital) avg_rating = physician_qs.aggregate(avg=Avg("average_rating"))["avg"] if avg_rating is not None: metrics["physician_avg_rating"] = Decimal(str(avg_rating)).quantize(Decimal("0.01")) else: metrics["physician_avg_rating"] = Decimal("0") except Exception: metrics["physician_avg_rating"] = Decimal("0") return metrics def get_dashboard_data(self, hospital=None, date_range: int = 30) -> dict[str, Any]: """ Return comprehensive dashboard data with KPIs, trends, and hospital comparisons. Args: hospital: Optional Hospital instance to scope data date_range: Number of days to include (default 30) Returns: Dictionary containing: - kpis: current period KPI values - trends: trend data for each metric type - hospital_comparison: comparison across hospitals - variance: period-over-period changes """ now = timezone.now() current_start = now - timedelta(days=date_range) previous_start = now - timedelta(days=date_range * 2) # Current period metrics current_metrics = self._aggregate_period_metrics(current_start, now, hospital) previous_metrics = self._aggregate_period_metrics(previous_start, current_start, hospital) # Calculate variances variances = {} for key in current_metrics: current_val = current_metrics.get(key, 0) previous_val = previous_metrics.get(key, 0) variances[key] = self.calculate_variance(current_val, previous_val) # Hospital leaderboard leaderboard = self.get_hospital_leaderboard() # Trend data trend_data = {} for metric_type in [ "complaints_total", "surveys_satisfaction", "actions_total", "observations_total", ]: trend_data[metric_type] = self.get_trend_data(metric_type, days=date_range, hospital=hospital) return { "kpis": current_metrics, "previous_kpis": previous_metrics, "variances": variances, "hospital_leaderboard": leaderboard, "trends": trend_data, "date_range": date_range, "generated_at": now.isoformat(), } def calculate_variance(self, current: float | Decimal, previous: float | Decimal) -> dict[str, Any]: """ Calculate percentage variance between current and previous values. Args: current: Current period value previous: Previous period value Returns: Dictionary with percentage, direction, and absolute_change """ current_val = float(current) previous_val = float(previous) if previous_val == 0: return { "percentage": 0.0, "direction": "neutral", "absolute_change": current_val, } percentage = ((current_val - previous_val) / previous_val) * 100 absolute_change = current_val - previous_val if percentage > 0: direction = "up" elif percentage < 0: direction = "down" else: direction = "neutral" return { "percentage": round(percentage, 1), "direction": direction, "absolute_change": round(absolute_change, 2), } def get_hospital_leaderboard(self) -> list[dict[str, Any]]: """ Rank hospitals by satisfaction, complaint resolution, and overall performance. Returns: List of dictionaries with hospital rankings and scores. """ from apps.organizations.models import Hospital now = timezone.now() last_30d = now - timedelta(days=30) hospitals = Hospital.objects.filter(status="active") leaderboard = [] for hospital in hospitals: # Complaint resolution rate total_complaints = self.Complaint.objects.filter(hospital=hospital, created_at__gte=last_30d).count() resolved_complaints = self.Complaint.objects.filter( hospital=hospital, status="closed", created_at__gte=last_30d ).count() resolution_rate = (resolved_complaints / total_complaints * 100) if total_complaints > 0 else 0 # Satisfaction score surveys = self.SurveyInstance.objects.filter( hospital=hospital, completed_at__gte=last_30d, total_score__isnull=False ) avg_satisfaction = surveys.aggregate(avg_score=Avg("total_score"))["avg_score"] or 0 # Overdue rate (lower is better) overdue_complaints = self.Complaint.objects.filter( hospital=hospital, is_overdue=True, status__in=["open", "in_progress"] ).count() active_complaints = self.Complaint.objects.filter( hospital=hospital, status__in=["open", "in_progress"] ).count() overdue_rate = (overdue_complaints / active_complaints * 100) if active_complaints > 0 else 0 # Action closure rate total_actions = self.PXAction.objects.filter(hospital=hospital, created_at__gte=last_30d).count() closed_actions = self.PXAction.objects.filter( hospital=hospital, status="closed", created_at__gte=last_30d ).count() action_closure_rate = (closed_actions / total_actions * 100) if total_actions > 0 else 0 # Composite score (weighted) composite_score = ( (resolution_rate * 0.3) + (float(avg_satisfaction) * 20 * 0.3) + ((100 - overdue_rate) * 0.2) + (action_closure_rate * 0.2) ) leaderboard.append( { "hospital": hospital, "hospital_name": hospital.name, "resolution_rate": round(resolution_rate, 1), "satisfaction_score": round(float(avg_satisfaction), 1), "overdue_rate": round(overdue_rate, 1), "action_closure_rate": round(action_closure_rate, 1), "composite_score": round(composite_score, 1), } ) # Sort by composite score descending leaderboard.sort(key=lambda x: x["composite_score"], reverse=True) # Add rank for idx, entry in enumerate(leaderboard, start=1): entry["rank"] = idx return leaderboard def get_trend_data( self, metric_type: str, days: int = 30, hospital=None, ) -> list[dict[str, Any]]: """ Return trend data for charts over the specified number of days. Args: metric_type: One of the metric types (complaints_total, surveys_satisfaction, etc.) days: Number of days to include (default 30) hospital: Optional Hospital to filter by Returns: List of dicts with date and value for each day. """ now = timezone.now() start_date = now - timedelta(days=days) trend_data: list[dict[str, Any]] = [] for day_offset in range(days): day_date = start_date + timedelta(days=day_offset) day_start = timezone.make_aware(timezone.datetime.combine(day_date.date(), timezone.datetime.min.time())) day_end = day_start + timedelta(days=1) day_filters: dict[str, Any] = {"created_at__gte": day_start, "created_at__lt": day_end} survey_day_filters: dict[str, Any] = {"completed_at__gte": day_start, "completed_at__lt": day_end} call_day_filters: dict[str, Any] = {"call_started_at__gte": day_start, "call_started_at__lt": day_end} if hospital: day_filters["hospital"] = hospital survey_day_filters["hospital"] = hospital call_day_filters["hospital"] = hospital value = 0.0 if metric_type.startswith("complaints"): qs = self.Complaint.objects.filter(**day_filters) if metric_type == "complaints_total": value = float(qs.count()) elif metric_type == "complaints_critical": value = float(qs.filter(severity="critical").count()) elif metric_type == "complaints_overdue": value = float(qs.filter(is_overdue=True).count()) elif metric_type == "complaints_resolution_time": resolved = qs.filter(status="closed", closed_at__isnull=False) if resolved.exists(): avg_hours = ( sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved) / resolved.count() ) value = avg_hours elif metric_type.startswith("surveys"): qs = self.SurveyInstance.objects.filter(**survey_day_filters) if metric_type == "surveys_total": value = float(qs.count()) elif metric_type == "surveys_satisfaction": agg = qs.filter(total_score__isnull=False).aggregate(avg_score=Avg("total_score")) value = float(agg["avg_score"] or 0) elif metric_type == "surveys_nps": total = qs.count() if total > 0: positive = qs.filter(is_negative=False).count() negative = qs.filter(is_negative=True).count() value = ((positive - negative) / total) * 100 elif metric_type.startswith("actions"): qs = self.PXAction.objects.filter(**day_filters) if metric_type == "actions_total": value = float(qs.count()) elif metric_type == "actions_open": value = float(qs.filter(status="open").count()) elif metric_type == "actions_overdue": value = float(qs.filter(is_overdue=True).count()) elif metric_type == "actions_closed": value = float(qs.filter(status="closed").count()) elif metric_type.startswith("observations"): qs = self.Observation.objects.filter(**day_filters) if metric_type == "observations_total": value = float(qs.count()) elif metric_type == "observations_critical": value = float(qs.filter(severity="critical").count()) elif metric_type.startswith("inquiries"): from apps.complaints.models import Inquiry qs = Inquiry.objects.filter(**day_filters) if metric_type == "inquiries_total": value = float(qs.count()) elif metric_type == "inquiries_resolved": value = float(qs.filter(status="resolved").count()) elif metric_type.startswith("call_center"): try: from apps.callcenter.models import CallCenterInteraction qs = CallCenterInteraction.objects.filter(**call_day_filters) if metric_type == "call_center_total": value = float(qs.count()) elif metric_type == "call_center_satisfaction": total = qs.count() if total > 0: low = qs.filter(is_low_rating=True).count() value = ((total - low) / total) * 100 except Exception: value = 0.0 elif metric_type == "physician_avg_rating": try: from apps.physicians.models import PhysicianMonthlyRating qs = PhysicianMonthlyRating.objects.filter(created_at__gte=day_start, created_at__lt=day_end) if hospital: qs = qs.filter(staff__hospital=hospital) agg = qs.aggregate(avg=Avg("average_rating")) value = float(agg["avg"] or 0) except Exception: value = 0.0 trend_data.append( { "date": day_date.date().isoformat(), "value": round(value, 2), } ) return trend_data # ------------------------------------------------------------------------- # Internal helpers # ------------------------------------------------------------------------- def _aggregate_period_metrics( self, start_date, end_date, hospital=None, ) -> dict[str, float]: """Aggregate metrics for a given period.""" base_filters: dict[str, Any] = {"created_at__gte": start_date, "created_at__lt": end_date} if hospital: base_filters["hospital"] = hospital survey_base_filters: dict[str, Any] = {"completed_at__gte": start_date, "completed_at__lt": end_date} if hospital: survey_base_filters["hospital"] = hospital action_base_filters: dict[str, Any] = {"created_at__gte": start_date, "created_at__lt": end_date} if hospital: action_base_filters["hospital"] = hospital obs_base_filters: dict[str, Any] = {"created_at__gte": start_date, "created_at__lt": end_date} if hospital: obs_base_filters["hospital"] = hospital inquiry_base_filters: dict[str, Any] = {"created_at__gte": start_date, "created_at__lt": end_date} if hospital: inquiry_base_filters["hospital"] = hospital metrics: dict[str, float] = {} # Complaints complaints_qs = self.Complaint.objects.filter(**base_filters) metrics["complaints_total"] = float(complaints_qs.count()) metrics["complaints_critical"] = float(complaints_qs.filter(severity="critical").count()) metrics["complaints_overdue"] = float( complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() ) resolved = complaints_qs.filter(status="closed", closed_at__isnull=False) if resolved.exists(): avg_hours = sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved) / resolved.count() metrics["complaints_resolution_time"] = avg_hours else: metrics["complaints_resolution_time"] = 0.0 # Surveys surveys_qs = self.SurveyInstance.objects.filter(**survey_base_filters) metrics["surveys_total"] = float(surveys_qs.count()) satisfaction = surveys_qs.filter(total_score__isnull=False).aggregate(avg_score=Avg("total_score"))["avg_score"] metrics["surveys_satisfaction"] = float(satisfaction or 0) total_surveys = surveys_qs.count() if total_surveys > 0: positive = surveys_qs.filter(is_negative=False).count() negative = surveys_qs.filter(is_negative=True).count() metrics["surveys_nps"] = ((positive - negative) / total_surveys) * 100 else: metrics["surveys_nps"] = 0.0 # PX Actions actions_qs = self.PXAction.objects.filter(**action_base_filters) metrics["actions_total"] = float(actions_qs.count()) metrics["actions_open"] = float(actions_qs.filter(status="open").count()) metrics["actions_overdue"] = float( actions_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count() ) metrics["actions_closed"] = float(actions_qs.filter(status="closed").count()) # Observations observations_qs = self.Observation.objects.filter(**obs_base_filters) metrics["observations_total"] = float(observations_qs.count()) metrics["observations_critical"] = float(observations_qs.filter(severity="critical").count()) # Inquiries from apps.complaints.models import Inquiry inquiries_qs = Inquiry.objects.filter(**inquiry_base_filters) metrics["inquiries_total"] = float(inquiries_qs.count()) metrics["inquiries_resolved"] = float(inquiries_qs.filter(status="resolved").count()) # Call Center try: from apps.callcenter.models import CallCenterInteraction call_base_filters: dict[str, Any] = { "call_started_at__gte": start_date, "call_started_at__lt": end_date, } if hospital: call_base_filters["hospital"] = hospital calls_qs = CallCenterInteraction.objects.filter(**call_base_filters) total_calls = calls_qs.count() metrics["call_center_total"] = float(total_calls) if total_calls > 0: low = calls_qs.filter(is_low_rating=True).count() metrics["call_center_satisfaction"] = ((total_calls - low) / total_calls) * 100 else: metrics["call_center_satisfaction"] = 0.0 except Exception: metrics["call_center_total"] = 0.0 metrics["call_center_satisfaction"] = 0.0 # Physician Ratings try: from apps.physicians.models import PhysicianMonthlyRating now = timezone.now() physician_qs = PhysicianMonthlyRating.objects.filter(year=now.year, month=now.month) if hospital: physician_qs = physician_qs.filter(staff__hospital=hospital) avg_rating = physician_qs.aggregate(avg=Avg("average_rating"))["avg"] metrics["physician_avg_rating"] = float(avg_rating or 0) except Exception: metrics["physician_avg_rating"] = 0.0 return metrics # ============================================================================= # AINarrativeService # ============================================================================= class AINarrativeService: """ Service for generating AI-powered narrative summaries in English and Arabic. Uses LiteLLM with OpenRouter to analyze performance data and generate bilingual executive narratives with insights, achievements, and concerns. """ MODEL_NAME = "openrouter/google/gemma-3-27b-it:free" def __init__(self) -> None: self.summary_service = ExecutiveSummaryService() def generate_weekly_narrative( self, start_date: date, end_date: date, hospital=None, ) -> dict[str, Any]: """Generate an English narrative summary for a weekly period.""" return self._generate_narrative( start_date=start_date, end_date=end_date, hospital=hospital, language="en", period_label="weekly", ) def generate_monthly_narrative( self, start_date: date, end_date: date, hospital=None, ) -> dict[str, Any]: """Generate an English narrative summary for a monthly period.""" return self._generate_narrative( start_date=start_date, end_date=end_date, hospital=hospital, language="en", period_label="monthly", ) def generate_quarterly_narrative( self, start_date: date, end_date: date, hospital=None, ) -> dict[str, Any]: """Generate an English narrative summary for a quarterly period.""" return self._generate_narrative( start_date=start_date, end_date=end_date, hospital=hospital, language="en", period_label="quarterly", ) def generate_arabic_narrative( self, start_date: date, end_date: date, hospital=None, report_type: str = "weekly", ) -> dict[str, Any]: """ Generate an Arabic narrative summary. Args: start_date: Start of the reporting period end_date: End of the reporting period hospital: Optional Hospital to scope the narrative report_type: weekly, monthly, or quarterly Returns: Dictionary with Arabic narrative, highlights, concerns, and metadata """ period_map = { "weekly": "أسبوعي", "monthly": "شهري", "quarterly": "ربع سنوي", } period_label = period_map.get(report_type, "أسبوعي") return self._generate_narrative( start_date=start_date, end_date=end_date, hospital=hospital, language="ar", period_label=period_label, ) def _generate_narrative( self, start_date: date, end_date: date, hospital=None, language: str = "en", period_label: str = "weekly", ) -> dict[str, Any]: """Internal unified narrative generator supporting EN/AR and any period.""" import json import time start_time = time.time() try: metrics_data = self._collect_period_metrics(start_date, end_date, hospital) formatted_metrics = self.format_metrics_for_ai(metrics_data) hospital_name = hospital.name if hospital else ("All Hospitals" if language == "en" else "جميع المستشفيات") if language == "ar": prompt = f"""أنت محلل تنفيذي لمنصة تجربة المرضى (PX) في قطاع الرعاية الصحية. أعد ملخصًا {period_label}ًا سرديًا شاملاً يحلل بيانات الأداء لـ {hospital_name}. ## فترة التقرير: من {start_date.isoformat()} إلى {end_date.isoformat()} ## بيانات الأداء: {formatted_metrics} قم بتحليل البيانات وتقديم: 1. **الملخص التنفيذي**: فقرتان إلى ثلاث فقرات توضح نظرة عامة على أداء الفترة {period_label}، مع إبراز أهم الاتجاهات والأنماط. 2. **أبرز الإنجازات**: اذكر 3-5 نتائج إيجابية أو تحسينات أو نجاحات تم تحديدها في البيانات. 3. **أهم المخاوف**: اذكر 3-5 مجالات تحتاج إلى اهتمام، بما في ذلك تراجع المؤشرات أو خرق اتفاقيات مستوى الخدمة أو المخاطر الناشئة. 4. **رؤى قابلة للتنفيذ**: قدم 3-5 توصيات محددة قائمة على البيانات للتحسين. 5. **تحليل الاتجاهات**: حدد أي اتجاهات تصاعدية أو تنازلية ملحوظة مقارنة بالفترة السابقة. اكتب بلغة مهنية مناسبة للمستوى التنفيذي. كن محددًا بالأرقام والنسب المئوية. ركّز على تجربة المرضى، وحل الشكاوى، ودرجات الرضا، والكفاءة التشغيلية. أعد استجابتك بتنسيق JSON بالمفاتيح التالية: - "executive_summary": نص (فقرتان إلى ثلاث فقرات) - "highlights": مصفوفة نصوص (3-5 إنجازات) - "concerns": مصفوفة نصوص (3-5 مخاوف) - "actionable_insights": مصفوفة نصوص (3-5 توصيات) - "trend_analysis": نص (ملخص الاتجاهات) """ result_keys = { "narrative_key": "narrative_ar", "highlights_key": "highlights_ar", "concerns_key": "concerns_ar", "actionable_key": "actionable_insights_ar", "trend_key": "trend_analysis_ar", "fallback_text": f"تعذر إنشاء السرد بسبب خطأ", } else: prompt = f"""You are an executive analyst for a healthcare patient experience (PX) platform. Generate a comprehensive {period_label} narrative summary analyzing performance data for {hospital_name}. ## Reporting Period: {start_date.isoformat()} to {end_date.isoformat()} ## Performance Data: {formatted_metrics} Please analyze the data and provide: 1. **Executive Summary**: A 2-3 paragraph overview of the {period_label}'s performance, highlighting the most significant trends and patterns. 2. **Key Achievements**: List 3-5 positive outcomes, improvements, or successes identified in the data. 3. **Key Concerns**: List 3-5 areas that require attention, including declining metrics, SLA breaches, or emerging risks. 4. **Actionable Insights**: Provide 3-5 specific, data-driven recommendations for improvement. 5. **Trend Analysis**: Identify notable upward or downward trends compared to the previous period. Write in a professional, executive-appropriate tone. Be specific with numbers and percentages. Focus on patient experience, complaint resolution, satisfaction scores, and operational efficiency. Format your response as JSON with these keys: - "executive_summary": string (2-3 paragraphs) - "highlights": array of strings (3-5 achievements) - "concerns": array of strings (3-5 concerns) - "actionable_insights": array of strings (3-5 recommendations) - "trend_analysis": string (trend summary) """ result_keys = { "narrative_key": "narrative_en", "highlights_key": "highlights_en", "concerns_key": "concerns_en", "actionable_key": "actionable_insights_en", "trend_key": "trend_analysis_en", "fallback_text": "Unable to generate narrative due to an error", } response = self._call_ai(prompt) try: result = json.loads(self._clean_ai_response(response)) except json.JSONDecodeError: cleaned = self._clean_ai_response(response) if cleaned.startswith("{"): try: result = json.loads(cleaned) except json.JSONDecodeError: result = { "executive_summary": cleaned, "highlights": [], "concerns": [], "actionable_insights": [], "trend_analysis": "", } else: result = { "executive_summary": cleaned, "highlights": [], "concerns": [], "actionable_insights": [], "trend_analysis": "", } generation_time_ms = int((time.time() - start_time) * 1000) return { result_keys["narrative_key"]: result.get("executive_summary", ""), result_keys["highlights_key"]: result.get("highlights", []), result_keys["concerns_key"]: result.get("concerns", []), result_keys["actionable_key"]: result.get("actionable_insights", []), result_keys["trend_key"]: result.get("trend_analysis", ""), "ai_model": self.MODEL_NAME, "status": "completed", "generation_time_ms": generation_time_ms, } except Exception as e: logger.error(f"Error generating {language} {period_label} narrative: {e}", exc_info=True) generation_time_ms = int((time.time() - start_time) * 1000) return { result_keys["narrative_key"]: f"{result_keys['fallback_text']}: {str(e)}", result_keys["highlights_key"]: [], result_keys["concerns_key"]: [], result_keys["actionable_key"]: [], result_keys["trend_key"]: "", "ai_model": self.MODEL_NAME, "status": "failed", "error_message": str(e), "generation_time_ms": generation_time_ms, } def format_metrics_for_ai(self, metrics_data: dict[str, Any]) -> str: """ Format metrics data into a human-readable prompt for AI analysis. Args: metrics_data: Dictionary of metric names to values with optional variances Returns: Formatted string suitable for AI prompt input. """ lines = [] # Complaints lines.append("### Complaints") lines.append(f"- Total complaints: {metrics_data.get('complaints_total', 0)}") lines.append(f"- Critical complaints: {metrics_data.get('complaints_critical', 0)}") lines.append(f"- Overdue complaints: {metrics_data.get('complaints_overdue', 0)}") lines.append(f"- Average resolution time: {metrics_data.get('complaints_resolution_time', 0):.1f} hours") if "complaints_variance" in metrics_data: lines.append(f"- Period variance: {metrics_data['complaints_variance']:+.1f}%") lines.append("") # Surveys lines.append("### Surveys") lines.append(f"- Total surveys completed: {metrics_data.get('surveys_total', 0)}") lines.append(f"- Average satisfaction score: {metrics_data.get('surveys_satisfaction', 0):.1f}/5.0") lines.append(f"- NPS score: {metrics_data.get('surveys_nps', 0):.1f}") lines.append(f"- Survey response rate: {metrics_data.get('surveys_response_rate', 0):.1f}%") if "surveys_variance" in metrics_data: lines.append(f"- Period variance: {metrics_data['surveys_variance']:+.1f}%") lines.append("") # PX Actions lines.append("### PX Actions") lines.append(f"- Total actions: {metrics_data.get('actions_total', 0)}") lines.append(f"- Open actions: {metrics_data.get('actions_open', 0)}") lines.append(f"- Overdue actions: {metrics_data.get('actions_overdue', 0)}") lines.append(f"- Closed actions: {metrics_data.get('actions_closed', 0)}") lines.append("") # Observations lines.append("### Observations") lines.append(f"- Total observations: {metrics_data.get('observations_total', 0)}") lines.append(f"- Critical observations: {metrics_data.get('observations_critical', 0)}") lines.append("") # Inquiries lines.append("### Inquiries") lines.append(f"- Total inquiries: {metrics_data.get('inquiries_total', 0)}") lines.append(f"- Resolved inquiries: {metrics_data.get('inquiries_resolved', 0)}") lines.append("") # Call Center lines.append("### Call Center") lines.append(f"- Total interactions: {metrics_data.get('call_center_total', 0)}") lines.append(f"- Satisfaction rate: {metrics_data.get('call_center_satisfaction', 0):.1f}%") lines.append("") # Physician Ratings lines.append("### Physician Ratings") lines.append(f"- Average physician rating: {metrics_data.get('physician_avg_rating', 0):.2f}/5.0") return "\n".join(lines) # ------------------------------------------------------------------------- # Internal helpers # ------------------------------------------------------------------------- # ------------------------------------------------------------------------- # Tab-specific on-demand AI analysis # ------------------------------------------------------------------------- def generate_overview_analysis( self, start_date: date, end_date: date, hospital=None, ) -> dict[str, Any]: """Generate on-demand AI analysis for the Overview tab.""" import json as _json import time as _time t0 = _time.time() try: metrics_data = self._collect_period_metrics(start_date, end_date, hospital) formatted = self.format_metrics_for_ai(metrics_data) hospital_name = hospital.name if hospital else "All Hospitals" prompt = f"""You are an executive healthcare PX analyst. Analyze the following KPI overview for {hospital_name} ({start_date.isoformat()} to {end_date.isoformat()}): {formatted} Provide: 1. A 2-3 paragraph executive overview of current performance. 2. 3-5 key highlights (positive outcomes). 3. 3-5 key concerns (areas needing attention). 4. 3-5 actionable recommendations. Format as JSON: {{"narrative": "...", "highlights": ["..."], "concerns": ["..."], "recommendations": ["..."]}}""" response = self._call_ai(prompt) result = _json.loads(self._clean_ai_response(response)) return { "narrative": result.get("narrative", ""), "highlights": result.get("highlights", []), "concerns": result.get("concerns", []), "recommendations": result.get("recommendations", []), "generation_time_ms": int((_time.time() - t0) * 1000), "status": "completed", } except Exception as e: logger.error(f"Error generating overview analysis: {e}", exc_info=True) return { "narrative": f"Analysis unavailable: {e}", "highlights": [], "concerns": [], "recommendations": [], "generation_time_ms": int((_time.time() - t0) * 1000), "status": "failed", } def generate_trends_analysis( self, start_date: date, end_date: date, hospital=None, trends_data: dict[str, list] | None = None, leaderboard: list[dict] | None = None, ) -> dict[str, Any]: """Generate on-demand AI analysis for the Trends tab.""" import json as _json import time as _time t0 = _time.time() try: hospital_name = hospital.name if hospital else "All Hospitals" trend_summary_parts = [] for metric_key, points in (trends_data or {}).items(): values = [p.get("value", 0) for p in points] if values: trend_summary_parts.append( f"- {metric_key}: min={min(values):.1f}, max={max(values):.1f}, " f"avg={sum(values) / len(values):.1f}, latest={values[-1]:.1f}" ) lb_parts = [] for entry in (leaderboard or [])[:5]: lb_parts.append( f" #{entry.get('rank', '?')} {entry.get('hospital_name', '?')} " f"- composite: {entry.get('composite_score', 0):.1f}, " f"resolution: {entry.get('resolution_rate', 0):.1f}%, " f"satisfaction: {entry.get('satisfaction_score', 0):.1f}" ) prompt = f"""You are an executive healthcare PX analyst specializing in trend analysis. Analyze trends for {hospital_name} ({start_date.isoformat()} to {end_date.isoformat()}): ## Metric Trends: {chr(10).join(trend_summary_parts) or "No trend data available."} ## Hospital Leaderboard: {chr(10).join(lb_parts) or "No leaderboard data available."} Provide: 1. A 2-3 paragraph trend analysis narrative. 2. 3-5 key highlights (positive patterns). 3. 3-5 key concerns (negative or emerging patterns). 4. 3-5 actionable recommendations based on trends. Format as JSON: {{"narrative": "...", "highlights": ["..."], "concerns": ["..."], "recommendations": ["..."]}}""" response = self._call_ai(prompt) result = _json.loads(self._clean_ai_response(response)) return { "narrative": result.get("narrative", ""), "highlights": result.get("highlights", []), "concerns": result.get("concerns", []), "recommendations": result.get("recommendations", []), "generation_time_ms": int((_time.time() - t0) * 1000), "status": "completed", } except Exception as e: logger.error(f"Error generating trends analysis: {e}", exc_info=True) return { "narrative": f"Analysis unavailable: {e}", "highlights": [], "concerns": [], "recommendations": [], "generation_time_ms": int((_time.time() - t0) * 1000), "status": "failed", } def generate_insights_analysis( self, hospital=None, risk_alerts: list | None = None, ai_recommendations: list | None = None, ) -> dict[str, Any]: """Generate on-demand AI analysis for the Insights tab.""" import json as _json import time as _time t0 = _time.time() try: hospital_name = hospital.name if hospital else "All Hospitals" alert_parts = [] for alert in (risk_alerts or [])[:10]: alert_parts.append( f"- [{alert.severity}] {alert.title_en} " f"({alert.get_insight_type_display()}) " f"{'- ' + alert.hospital.name if alert.hospital else ''}" ) rec_parts = [] for rec in (ai_recommendations or [])[:5]: rec_parts.append(f"- [{rec.get_priority_display()}] {rec.title_en}: {rec.description_en[:120]}...") prompt = f"""You are an executive healthcare PX risk analyst. Analyze the current risk landscape for {hospital_name}: ## Active Risk Alerts ({len(risk_alerts or [])} total): {chr(10).join(alert_parts) or "No active risk alerts."} ## AI Recommendations ({len(ai_recommendations or [])} total): {chr(10).join(rec_parts) or "No active recommendations."} Provide: 1. A 2-3 paragraph risk assessment narrative. 2. 3-5 key highlights (mitigated risks or positive findings). 3. 3-5 key concerns (active or emerging risks). 4. 3-5 actionable recommendations for risk mitigation. Format as JSON: {{"narrative": "...", "highlights": ["..."], "concerns": ["..."], "recommendations": ["..."]}}""" response = self._call_ai(prompt) result = _json.loads(self._clean_ai_response(response)) return { "narrative": result.get("narrative", ""), "highlights": result.get("highlights", []), "concerns": result.get("concerns", []), "recommendations": result.get("recommendations", []), "generation_time_ms": int((_time.time() - t0) * 1000), "status": "completed", } except Exception as e: logger.error(f"Error generating insights analysis: {e}", exc_info=True) return { "narrative": f"Analysis unavailable: {e}", "highlights": [], "concerns": [], "recommendations": [], "generation_time_ms": int((_time.time() - t0) * 1000), "status": "failed", } def _collect_period_metrics( self, start_date: date, end_date: date, hospital=None, ) -> dict[str, Any]: """Collect metrics for a given period with variances.""" now = timezone.now() period_length = (end_date - start_date).days current_start = timezone.make_aware(timezone.datetime.combine(start_date, timezone.datetime.min.time())) current_end = timezone.make_aware(timezone.datetime.combine(end_date, timezone.datetime.max.time())) previous_start = current_start - timedelta(days=period_length) previous_end = current_start # Current period current_metrics = self.summary_service._aggregate_period_metrics(current_start, current_end, hospital) # Previous period average previous_metrics = self.summary_service._aggregate_period_metrics(previous_start, previous_end, hospital) metrics = dict(current_metrics) # Add variance for key metrics for key in [ "complaints_total", "surveys_satisfaction", "surveys_nps", "actions_total", "observations_total", ]: current_val = current_metrics.get(key, 0) previous_val = previous_metrics.get(key, 0) variance_data = self.summary_service.calculate_variance(current_val, previous_val) metrics[f"{key}_variance"] = variance_data["percentage"] return metrics def _call_ai(self, prompt: str) -> str: """Call the AI model with the given prompt.""" from litellm import completion response = completion( model=self.MODEL_NAME, messages=[{"role": "user", "content": prompt}], temperature=0.4, max_tokens=2000, ) return response.choices[0].message.content @staticmethod def _clean_ai_response(text: str) -> str: """Strip markdown code fences from AI response before JSON parsing.""" text = text.strip() if text.startswith("```"): lines = text.split("\n") if lines[0].startswith("```"): lines = lines[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] text = "\n".join(lines) return text.strip() # ============================================================================= # PredictiveAnalyticsService # ============================================================================= class PredictiveAnalyticsService: """ Service for predictive analytics, anomaly detection, and risk scoring. Uses statistical methods to detect anomalies, predict SLA breaches, identify trend changes, and calculate risk scores. """ def __init__(self) -> None: self.summary_service = ExecutiveSummaryService() def detect_anomalies( self, metric_type: str, days: int = 90, hospital=None, ) -> dict[str, Any]: """ Detect statistical anomalies in a metric over the specified period. Uses z-score method: values beyond 2 standard deviations from the mean are flagged as anomalies. Args: metric_type: The metric to analyze days: Number of days to analyze (default 90) hospital: Optional Hospital to filter by Returns: Dictionary with anomaly data including flagged dates and severity. """ trend_data = self.summary_service.get_trend_data(metric_type, days=days, hospital=hospital) values = [point["value"] for point in trend_data] if len(values) < 7: return { "metric_type": metric_type, "days_analyzed": days, "anomalies": [], "message": "Insufficient data for anomaly detection (need at least 7 days)", } mean_val = statistics.mean(values) stdev_val = statistics.stdev(values) if len(values) > 1 else 0 anomalies = [] if stdev_val > 0: for point in trend_data: z_score = abs((point["value"] - mean_val) / stdev_val) if z_score > 2: severity = "critical" if z_score > 3 else "high" if z_score > 2.5 else "medium" anomalies.append( { "date": point["date"], "value": point["value"], "z_score": round(z_score, 2), "severity": severity, "deviation_from_mean": round(point["value"] - mean_val, 2), } ) return { "metric_type": metric_type, "days_analyzed": days, "mean": round(mean_val, 2), "stdev": round(stdev_val, 2), "anomaly_count": len(anomalies), "anomalies": anomalies, } def predict_sla_breach( self, hospital=None, days_ahead: int = 7, ) -> list[dict[str, Any]]: """ Predict potential SLA breaches in the coming days. Analyzes currently open/in-progress items approaching their SLA deadline and estimates breach probability based on historical resolution patterns. Args: hospital: Optional Hospital to filter by days_ahead: Number of days to predict ahead (default 7) Returns: List of predicted breach risk items with probability and details. """ now = timezone.now() prediction_horizon = now + timedelta(days=days_ahead) predictions = [] # Analyze complaints complaints_qs = self.summary_service.Complaint.objects.filter( status__in=["open", "in_progress"], due_at__lte=prediction_horizon, due_at__gte=now, ) if hospital: complaints_qs = complaints_qs.filter(hospital=hospital) for complaint in complaints_qs.select_related("hospital", "department"): hours_remaining = (complaint.due_at - now).total_seconds() / 3600 age_hours = (now - complaint.created_at).total_seconds() / 3600 # Simple risk model: higher risk as deadline approaches and age increases if hours_remaining > 0: breach_probability = min( 100, round( (1 - (hours_remaining / max(age_hours, 1))) * 100 + 20, 1, ), ) else: breach_probability = 100.0 severity = "critical" if breach_probability > 80 else "high" if breach_probability > 60 else "medium" predictions.append( { "entity_type": "complaint", "entity_id": str(complaint.id), "title": complaint.title, "hospital": complaint.hospital.name if complaint.hospital else None, "department": complaint.department.name if complaint.department else None, "due_at": complaint.due_at.isoformat(), "hours_remaining": round(hours_remaining, 1), "age_hours": round(age_hours, 1), "breach_probability": breach_probability, "severity": severity, } ) # Analyze PX Actions actions_qs = self.summary_service.PXAction.objects.filter( status__in=["open", "in_progress"], due_at__lte=prediction_horizon, due_at__gte=now, ) if hospital: actions_qs = actions_qs.filter(hospital=hospital) for action in actions_qs.select_related("hospital", "department"): hours_remaining = (action.due_at - now).total_seconds() / 3600 age_hours = (now - action.created_at).total_seconds() / 3600 if hours_remaining > 0: breach_probability = min( 100, round( (1 - (hours_remaining / max(age_hours, 1))) * 100 + 20, 1, ), ) else: breach_probability = 100.0 severity = "critical" if breach_probability > 80 else "high" if breach_probability > 60 else "medium" predictions.append( { "entity_type": "px_action", "entity_id": str(action.id), "title": action.title, "hospital": action.hospital.name if action.hospital else None, "department": action.department.name if action.department else None, "due_at": action.due_at.isoformat(), "hours_remaining": round(hours_remaining, 1), "age_hours": round(age_hours, 1), "breach_probability": breach_probability, "severity": severity, } ) # Analyze Observations observations_qs = self.summary_service.Observation.objects.filter( status__in=["new", "triaged", "assigned", "in_progress"], due_at__lte=prediction_horizon, due_at__gte=now, ) if hospital: observations_qs = observations_qs.filter(hospital=hospital) for obs in observations_qs.select_related("hospital", "assigned_department"): hours_remaining = (obs.due_at - now).total_seconds() / 3600 age_hours = (now - obs.created_at).total_seconds() / 3600 if hours_remaining > 0: breach_probability = min( 100, round( (1 - (hours_remaining / max(age_hours, 1))) * 100 + 20, 1, ), ) else: breach_probability = 100.0 severity = "critical" if breach_probability > 80 else "high" if breach_probability > 60 else "medium" predictions.append( { "entity_type": "observation", "entity_id": str(obs.id), "title": obs.title or obs.description[:100], "hospital": obs.hospital.name if obs.hospital else None, "department": obs.assigned_department.name if obs.assigned_department else None, "due_at": obs.due_at.isoformat(), "hours_remaining": round(hours_remaining, 1), "age_hours": round(age_hours, 1), "breach_probability": breach_probability, "severity": severity, } ) # Sort by breach probability descending predictions.sort(key=lambda x: x["breach_probability"], reverse=True) return predictions def identify_trend_changes( self, metric_type: str, days: int = 60, hospital=None, ) -> dict[str, Any]: """ Identify significant trend changes in a metric over the specified period. Compares the most recent half of the period against the earlier half to identify significant increases or decreases. Args: metric_type: The metric to analyze days: Number of days to analyze (default 60) hospital: Optional Hospital to filter by Returns: Dictionary with trend change analysis including direction and magnitude. """ trend_data = self.summary_service.get_trend_data(metric_type, days=days, hospital=hospital) if len(trend_data) < 14: return { "metric_type": metric_type, "days_analyzed": days, "change_detected": False, "message": "Insufficient data for trend analysis (need at least 14 days)", } mid_point = len(trend_data) // 2 first_half = [d["value"] for d in trend_data[:mid_point]] second_half = [d["value"] for d in trend_data[mid_point:]] first_avg = statistics.mean(first_half) if first_half else 0 second_avg = statistics.mean(second_half) if second_half else 0 if first_avg > 0: change_percentage = ((second_avg - first_avg) / first_avg) * 100 else: change_percentage = 0.0 if second_avg == 0 else 100.0 direction = "increasing" if change_percentage > 5 else "decreasing" if change_percentage < -5 else "stable" significance = ( "significant" if abs(change_percentage) > 20 else "moderate" if abs(change_percentage) > 10 else "minor" ) # Detect slope changes (acceleration/deceleration) recent_trend = "accelerating" if len(second_half) >= 7: recent_week = second_half[-7:] previous_week = second_half[:7] if len(second_half) > 7 else first_half[-7:] recent_slope = (recent_week[-1] - recent_week[0]) / max(len(recent_week), 1) previous_slope = (previous_week[-1] - previous_week[0]) / max(len(previous_week), 1) if abs(recent_slope) > abs(previous_slope) * 1.5: recent_trend = "accelerating" elif abs(recent_slope) < abs(previous_slope) * 0.5: recent_trend = "decelerating" else: recent_trend = "steady" return { "metric_type": metric_type, "days_analyzed": days, "first_half_avg": round(first_avg, 2), "second_half_avg": round(second_avg, 2), "change_percentage": round(change_percentage, 1), "direction": direction, "significance": significance, "recent_trend": recent_trend, "change_detected": abs(change_percentage) > 5, } def calculate_risk_scores( self, hospital=None, ) -> list[dict[str, Any]]: """ Calculate risk scores for hospitals or departments based on multiple factors. Risk score is a composite of: - SLA breach rate (weight: 30%) - Overdue rate (weight: 25%) - Satisfaction decline (weight: 20%) - Critical volume (weight: 15%) - Resolution time trend (weight: 10%) Args: hospital: Optional Hospital to calculate for (if None, calculates for all) Returns: List of risk score entries sorted by risk score descending. """ from apps.organizations.models import Hospital now = timezone.now() last_30d = now - timedelta(days=30) previous_30d = now - timedelta(days=60) hospitals_to_analyze = ( Hospital.objects.filter(status="active", id=hospital.id) if hospital else Hospital.objects.filter(status="active") ) risk_scores = [] for h in hospitals_to_analyze: # SLA breach rate total_items = ( self.summary_service.Complaint.objects.filter(hospital=h, created_at__gte=last_30d).count() + self.summary_service.PXAction.objects.filter(hospital=h, created_at__gte=last_30d).count() + self.summary_service.Observation.objects.filter(hospital=h, created_at__gte=last_30d).count() ) breached_items = ( self.summary_service.Complaint.objects.filter(hospital=h, breached_at__gte=last_30d).count() + self.summary_service.PXAction.objects.filter( hospital=h, is_overdue=True, status__in=["open", "in_progress"] ).count() + self.summary_service.Observation.objects.filter(hospital=h, breached_at__gte=last_30d).count() ) sla_breach_rate = (breached_items / total_items * 100) if total_items > 0 else 0 # Overdue rate overdue_items = ( self.summary_service.Complaint.objects.filter( hospital=h, is_overdue=True, status__in=["open", "in_progress"] ).count() + self.summary_service.PXAction.objects.filter( hospital=h, is_overdue=True, status__in=["open", "in_progress"] ).count() + self.summary_service.Observation.objects.filter( hospital=h, is_overdue=True, status__in=["new", "triaged", "assigned", "in_progress"] ).count() ) active_items = ( self.summary_service.Complaint.objects.filter(hospital=h, status__in=["open", "in_progress"]).count() + self.summary_service.PXAction.objects.filter(hospital=h, status__in=["open", "in_progress"]).count() + self.summary_service.Observation.objects.filter( hospital=h, status__in=["new", "triaged", "assigned", "in_progress"] ).count() ) overdue_rate = (overdue_items / active_items * 100) if active_items > 0 else 0 # Satisfaction decline current_satisfaction = ( self.summary_service.SurveyInstance.objects.filter( hospital=h, completed_at__gte=last_30d, total_score__isnull=False ).aggregate(avg=Avg("total_score"))["avg"] or 0 ) previous_satisfaction = ( self.summary_service.SurveyInstance.objects.filter( hospital=h, completed_at__gte=previous_30d, completed_at__lt=last_30d, total_score__isnull=False ).aggregate(avg=Avg("total_score"))["avg"] or 0 ) satisfaction_decline = float(previous_satisfaction) - float(current_satisfaction) # Critical volume critical_items = ( self.summary_service.Complaint.objects.filter( hospital=h, severity="critical", created_at__gte=last_30d ).count() + self.summary_service.Observation.objects.filter( hospital=h, severity="critical", created_at__gte=last_30d ).count() ) critical_rate = (critical_items / total_items * 100) if total_items > 0 else 0 # Resolution time trend current_resolution = self._get_avg_resolution_time(h, last_30d) previous_resolution = self._get_avg_resolution_time(h, previous_30d) if previous_resolution > 0: resolution_time_change = ((current_resolution - previous_resolution) / previous_resolution) * 100 else: resolution_time_change = 0 # Composite risk score (0-100, higher = more risk) risk_score = ( min(sla_breach_rate, 100) * 0.30 + min(overdue_rate, 100) * 0.25 + min(max(satisfaction_decline * 20, 0), 100) * 0.20 + min(critical_rate * 5, 100) * 0.15 + min(max(resolution_time_change, 0), 100) * 0.10 ) risk_level = ( "critical" if risk_score > 70 else "high" if risk_score > 50 else "medium" if risk_score > 30 else "low" ) risk_scores.append( { "hospital": h, "hospital_name": h.name, "risk_score": round(risk_score, 1), "risk_level": risk_level, "sla_breach_rate": round(sla_breach_rate, 1), "overdue_rate": round(overdue_rate, 1), "satisfaction_decline": round(satisfaction_decline, 2), "critical_rate": round(critical_rate, 1), "resolution_time_change_pct": round(resolution_time_change, 1), } ) risk_scores.sort(key=lambda x: x["risk_score"], reverse=True) return risk_scores def generate_predictive_insights(self) -> list: """ Create PredictiveInsight objects from anomaly detection, trend analysis, and SLA breach predictions. Returns: List of created PredictiveInsight instances. """ from apps.executive_summary.models import PredictiveInsight created_insights = [] try: # 1. Detect anomalies across key metrics for metric_type in [ "complaints_total", "complaints_critical", "surveys_satisfaction", "actions_overdue", ]: anomaly_result = self.detect_anomalies(metric_type, days=90) for anomaly in anomaly_result.get("anomalies", []): direction = "increased" if anomaly["deviation_from_mean"] > 0 else "decreased" insight, created = PredictiveInsight.objects.get_or_create( insight_type="anomaly", metric_type=metric_type, predicted_date=anomaly["date"], defaults={ "current_value": Decimal(str(anomaly["value"])), "severity": anomaly["severity"], "title_en": f"Anomaly detected in {metric_type.replace('_', ' ').title()}", "title_ar": f"تم اكتشاف شذوذ في {metric_type}", "description_en": f"Statistical anomaly detected: value of {anomaly['value']} on {anomaly['date']} with z-score of {anomaly['z_score']}. Value {direction} by {abs(anomaly['deviation_from_mean']):.2f} from the mean of {anomaly_result.get('mean', 0):.2f}.", "description_ar": f"تم اكتشاف شذوذ إحصائي: قيمة {anomaly['value']} في {anomaly['date']} بمعيار z-score {anomaly['z_score']}.", "confidence_score": Decimal(str(min(100, anomaly["z_score"] * 30))), "ai_model": "statistical_zscore", "detection_metadata": { "z_score": anomaly["z_score"], "mean": anomaly_result.get("mean"), "stdev": anomaly_result.get("stdev"), }, }, ) if created: created_insights.append(insight) # 2. Predict SLA breaches breach_predictions = self.predict_sla_breach(days_ahead=7) for prediction in breach_predictions[:20]: # Limit to top 20 if prediction["breach_probability"] > 50: severity_map = {"critical": "critical", "high": "high", "medium": "medium"} insight, created = PredictiveInsight.objects.get_or_create( insight_type="sla_breach_risk", metric_type="sla_breach_prediction", entity_type=prediction["entity_type"], entity_id=prediction["entity_id"], defaults={ "severity": severity_map.get(prediction["severity"], "medium"), "title_en": f"SLA Breach Risk: {prediction['title'][:100]}", "title_ar": f"خطر خرق اتفاقية مستوى الخدمة", "description_en": f"Predicted SLA breach with {prediction['breach_probability']}% probability. {prediction['hours_remaining']} hours remaining until deadline for {prediction['entity_type']}.", "description_ar": f"توقع خرق اتفاقية مستوى الخدمة بنسبة {prediction['breach_probability']}%. متبقي {prediction['hours_remaining']} ساعة حتى الموعد النهائي.", "predicted_value": Decimal(str(prediction["breach_probability"])), "confidence_score": Decimal(str(prediction["breach_probability"])), "predicted_date": timezone.now().date() + timedelta(days=7), "ai_model": "sla_prediction_model", "detection_metadata": prediction, }, ) if created: created_insights.append(insight) # 3. Identify trend changes for metric_type in [ "complaints_total", "surveys_satisfaction", "actions_total", ]: trend_change = self.identify_trend_changes(metric_type, days=60) if trend_change.get("change_detected") and trend_change.get("significance") in [ "significant", "moderate", ]: insight_type = ( "positive_trend" if trend_change["direction"] == "decreasing" and "complaint" in metric_type else "trend_change" ) if "satisfaction" in metric_type and trend_change["direction"] == "increasing": insight_type = "positive_trend" severity = "high" if trend_change["significance"] == "significant" else "medium" insight, created = PredictiveInsight.objects.get_or_create( insight_type=insight_type, metric_type=metric_type, defaults={ "severity": severity, "title_en": f"Trend Change: {metric_type.replace('_', ' ').title()} is {trend_change['direction']}", "title_ar": f"تغيير الاتجاه: {metric_type} في اتجاه {trend_change['direction']}", "description_en": f"{trend_change['significance'].title()} {trend_change['direction']} trend detected in {metric_type}. Average changed from {trend_change['first_half_avg']:.2f} to {trend_change['second_half_avg']:.2f} ({trend_change['change_percentage']:+.1f}% change).", "description_ar": f"تم اكتشاف اتجاه {trend_change['direction']} {trend_change['significance']} في {metric_type}.", "current_value": Decimal(str(trend_change["second_half_avg"])), "predicted_value": Decimal(str(trend_change["first_half_avg"])), "confidence_score": Decimal(str(abs(trend_change["change_percentage"]))), "ai_model": "trend_analysis", "detection_metadata": trend_change, }, ) if created: created_insights.append(insight) except Exception as e: logger.error(f"Error generating predictive insights: {e}", exc_info=True) return created_insights # ------------------------------------------------------------------------- # Internal helpers # ------------------------------------------------------------------------- def _get_avg_resolution_time(self, hospital, start_date) -> float: """Get average resolution time in hours for a hospital in a period.""" resolved = self.summary_service.Complaint.objects.filter( hospital=hospital, status="closed", closed_at__isnull=False, created_at__gte=start_date, ) if resolved.exists(): total_hours = sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved) return total_hours / resolved.count() return 0.0 # ============================================================================= # RecommendationService # ============================================================================= class RecommendationService: """ Service for generating AI recommendations based on predictive insights and data analysis patterns. Creates AIRecommendation objects with actionable guidance for process improvements, resource allocation, and training needs. """ MODEL_NAME = "openrouter/google/gemma-3-27b-it:free" def __init__(self) -> None: self.predictive_service = PredictiveAnalyticsService() self.summary_service = ExecutiveSummaryService() def generate_recommendations_from_insights(self) -> list: """ Create AIRecommendation objects based on existing PredictiveInsights. Analyzes unhandled predictive insights and generates actionable recommendations for each significant insight. Returns: List of created AIRecommendation instances. """ from apps.executive_summary.models import AIRecommendation, PredictiveInsight created_recommendations = [] try: # Get active insights without existing recommendations insights = ( PredictiveInsight.objects.filter( status__in=["new", "acknowledged"], ) .exclude( recommendations__isnull=False, ) .order_by("-severity", "-created_at")[:50] ) for insight in insights: recommendation_data = self._generate_recommendation_for_insight(insight) if recommendation_data: recommendation = AIRecommendation.objects.create( category=recommendation_data.get("category", "process_improvement"), priority=recommendation_data.get("priority", "medium"), status="new", title_en=recommendation_data.get("title_en", ""), title_ar=recommendation_data.get("title_ar", ""), description_en=recommendation_data.get("description_en", ""), description_ar=recommendation_data.get("description_ar", ""), expected_impact_en=recommendation_data.get("expected_impact_en", ""), expected_impact_ar=recommendation_data.get("expected_impact_ar", ""), hospital=insight.hospital, department=insight.department, related_insight=insight, confidence_score=insight.confidence_score, ai_model=self.MODEL_NAME, generation_metadata={ "insight_type": insight.insight_type, "insight_severity": insight.severity, }, ) created_recommendations.append(recommendation) except Exception as e: logger.error(f"Error generating recommendations from insights: {e}", exc_info=True) return created_recommendations def analyze_best_practices(self) -> dict[str, Any]: """ Identify successful patterns from high-performing hospitals. Analyzes hospitals in the top quartile of the leaderboard to identify common practices and strategies that correlate with success. Returns: Dictionary with best practices analysis including common patterns and specific recommendations for lower-performing hospitals. """ leaderboard = self.summary_service.get_hospital_leaderboard() if len(leaderboard) < 4: return { "message": "Insufficient data for best practices analysis (need at least 4 hospitals)", "best_practices": [], } # Top quartile (top 25%) top_quartile_size = max(1, len(leaderboard) // 4) top_hospitals = leaderboard[:top_quartile_size] bottom_hospitals = leaderboard[top_quartile_size:] # Analyze common patterns in top performers top_resolution_rates = [h["resolution_rate"] for h in top_hospitals] top_satisfaction_scores = [h["satisfaction_score"] for h in top_hospitals] top_overdue_rates = [h["overdue_rate"] for h in top_hospitals] top_action_closure_rates = [h["action_closure_rate"] for h in top_hospitals] avg_top_resolution = statistics.mean(top_resolution_rates) if top_resolution_rates else 0 avg_top_satisfaction = statistics.mean(top_satisfaction_scores) if top_satisfaction_scores else 0 avg_top_overdue = statistics.mean(top_overdue_rates) if top_overdue_rates else 0 avg_top_closure = statistics.mean(top_action_closure_rates) if top_action_closure_rates else 0 best_practices = [] if avg_top_resolution > 70: best_practices.append( { "practice": "High complaint resolution rate", "metric": "resolution_rate", "top_performer_avg": round(avg_top_resolution, 1), "description": f"Top-performing hospitals achieve an average resolution rate of {avg_top_resolution:.1f}%, compared to lower performers.", "recommendation": "Implement streamlined resolution workflows and set daily resolution targets.", } ) if avg_top_satisfaction > 3.5: best_practices.append( { "practice": "High patient satisfaction", "metric": "satisfaction_score", "top_performer_avg": round(avg_top_satisfaction, 1), "description": f"Top hospitals maintain satisfaction scores above {avg_top_satisfaction:.1f}/5.0.", "recommendation": "Focus on proactive communication and rapid response to patient concerns.", } ) if avg_top_overdue < 15: best_practices.append( { "practice": "Low overdue rate", "metric": "overdue_rate", "top_performer_avg": round(avg_top_overdue, 1), "description": f"Best-in-class hospitals keep overdue rates below {avg_top_overdue:.1f}%.", "recommendation": "Implement SLA monitoring dashboards and proactive escalation processes.", } ) # Gap analysis for lower performers gap_analysis = [] for hospital in bottom_hospitals[:5]: gaps = [] if hospital["resolution_rate"] < avg_top_resolution - 10: gaps.append( { "area": "resolution_rate", "current": hospital["resolution_rate"], "target": round(avg_top_resolution, 1), "gap": round(avg_top_resolution - hospital["resolution_rate"], 1), } ) if hospital["satisfaction_score"] < avg_top_satisfaction - 0.5: gaps.append( { "area": "satisfaction", "current": hospital["satisfaction_score"], "target": round(avg_top_satisfaction, 1), "gap": round(avg_top_satisfaction - hospital["satisfaction_score"], 1), } ) if gaps: gap_analysis.append( { "hospital_name": hospital["hospital_name"], "rank": hospital["rank"], "gaps": gaps, } ) return { "top_quartile_size": top_quartile_size, "top_performer_averages": { "resolution_rate": round(avg_top_resolution, 1), "satisfaction_score": round(avg_top_satisfaction, 1), "overdue_rate": round(avg_top_overdue, 1), "action_closure_rate": round(avg_top_closure, 1), }, "best_practices": best_practices, "gap_analysis": gap_analysis, } def suggest_resource_allocation(self) -> dict[str, Any]: """ Suggest optimal resource allocation based on data patterns. Analyzes workload distribution, bottleneck areas, and performance gaps to recommend where resources should be allocated or reallocated. Returns: Dictionary with resource allocation recommendations by area. """ from apps.organizations.models import Hospital now = timezone.now() last_30d = now - timedelta(days=30) suggestions: dict[str, Any] = { "complaints_handling": [], "survey_management": [], "action_resolution": [], "observation_triage": [], "call_center": [], } hospitals = Hospital.objects.filter(status="active") for hospital in hospitals: # --- Complaints workload analysis --- open_complaints = self.summary_service.Complaint.objects.filter( hospital=hospital, status__in=["open", "in_progress"] ).count() overdue_complaints = self.summary_service.Complaint.objects.filter( hospital=hospital, is_overdue=True, status__in=["open", "in_progress"] ).count() critical_complaints = self.summary_service.Complaint.objects.filter( hospital=hospital, severity="critical", status__in=["open", "in_progress"] ).count() if overdue_complaints > 5 or (open_complaints > 0 and overdue_complaints / open_complaints > 0.2): suggestions["complaints_handling"].append( { "hospital": hospital.name, "issue": "High overdue complaint rate", "current_open": open_complaints, "current_overdue": overdue_complaints, "recommendation": f"Allocate additional complaint handling resources. {overdue_complaints} complaints are currently overdue.", "priority": "high" if overdue_complaints > 10 else "medium", } ) if critical_complaints > 3: suggestions["complaints_handling"].append( { "hospital": hospital.name, "issue": "High critical complaint volume", "critical_count": critical_complaints, "recommendation": f"Prioritize critical complaint resolution. {critical_complaints} critical complaints require immediate attention.", "priority": "urgent", } ) # --- Survey follow-up --- negative_surveys = self.summary_service.SurveyInstance.objects.filter( hospital=hospital, is_negative=True, completed_at__gte=last_30d ).count() contacted = self.summary_service.SurveyInstance.objects.filter( hospital=hospital, is_negative=True, completed_at__gte=last_30d, patient_contacted=True ).count() follow_up_rate = (contacted / negative_surveys * 100) if negative_surveys > 0 else 100 if follow_up_rate < 70 and negative_surveys > 10: suggestions["survey_management"].append( { "hospital": hospital.name, "issue": "Low negative survey follow-up rate", "negative_surveys": negative_surveys, "contacted": contacted, "follow_up_rate": round(follow_up_rate, 1), "recommendation": f"Improve negative survey follow-up process. Only {follow_up_rate:.0f}% of {negative_surveys} negative surveys have been followed up.", "priority": "high", } ) # --- Action resolution --- open_actions = self.summary_service.PXAction.objects.filter( hospital=hospital, status__in=["open", "in_progress"] ).count() overdue_actions = self.summary_service.PXAction.objects.filter( hospital=hospital, is_overdue=True, status__in=["open", "in_progress"] ).count() if overdue_actions > 5: suggestions["action_resolution"].append( { "hospital": hospital.name, "issue": "High overdue action count", "open_actions": open_actions, "overdue_actions": overdue_actions, "recommendation": f"Accelerate action resolution. {overdue_actions} actions are overdue.", "priority": "high" if overdue_actions > 10 else "medium", } ) # --- Observation triage --- new_observations = self.summary_service.Observation.objects.filter(hospital=hospital, status="new").count() critical_observations = self.summary_service.Observation.objects.filter( hospital=hospital, severity="critical", status__in=["new", "triaged"] ).count() if new_observations > 10: suggestions["observation_triage"].append( { "hospital": hospital.name, "issue": "Observation triage backlog", "new_observations": new_observations, "critical_observations": critical_observations, "recommendation": f"Clear observation triage backlog. {new_observations} observations awaiting triage, {critical_observations} are critical.", "priority": "high" if critical_observations > 0 else "medium", } ) # --- Call Center Analysis --- try: from apps.callcenter.models import CallCenterInteraction calls_7d = CallCenterInteraction.objects.filter(call_started_at__gte=now - timedelta(days=7)) total_calls = calls_7d.count() if total_calls > 0: low_rating_rate = calls_7d.filter(is_low_rating=True).count() / total_calls * 100 if low_rating_rate > 20: suggestions["call_center"].append( { "issue": "High low-rating call rate", "total_calls_7d": total_calls, "low_rating_rate": round(low_rating_rate, 1), "recommendation": f"Review call center training. {low_rating_rate:.1f}% of calls in the last 7 days received low ratings.", "priority": "high", } ) except Exception: pass # Count total recommendations by priority all_suggestions = [] for category_items in suggestions.values(): all_suggestions.extend(category_items) urgent_count = sum(1 for s in all_suggestions if s.get("priority") == "urgent") high_count = sum(1 for s in all_suggestions if s.get("priority") == "high") medium_count = sum(1 for s in all_suggestions if s.get("priority") == "medium") suggestions["summary"] = { "total_recommendations": len(all_suggestions), "urgent": urgent_count, "high": high_count, "medium": medium_count, } return suggestions # ------------------------------------------------------------------------- # Internal helpers # ------------------------------------------------------------------------- def _generate_recommendation_for_insight(self, insight) -> Optional[dict[str, str]]: """Generate a recommendation for a specific insight using AI.""" from apps.executive_summary.models import PredictiveInsight try: category_mapping = { "trend_change": "process_improvement", "anomaly": "quality_assurance", "risk_warning": "preventive_action", "sla_breach_risk": "process_improvement", "performance_drop": "training", "volume_spike": "resource_allocation", "satisfaction_decline": "communication", "positive_trend": "process_improvement", } category = category_mapping.get(insight.insight_type, "process_improvement") priority = "high" if insight.severity in ["high", "critical"] else "medium" prompt = f"""You are a healthcare patient experience (PX) consultant. Based on the following predictive insight, generate a specific, actionable recommendation. ## Insight Details: - Type: {insight.insight_type} - Severity: {insight.severity} - Title: {insight.title_en} - Description: {insight.description_en} - Metric: {insight.metric_type} - Current Value: {insight.current_value} - Predicted Value: {insight.predicted_value} Generate a recommendation in both English and Arabic. Include: 1. A concise title (max 100 characters) 2. A detailed description of the recommended action 3. The expected impact if implemented Format as JSON: {{ "title_en": "...", "title_ar": "...", "description_en": "...", "description_ar": "...", "expected_impact_en": "...", "expected_impact_ar": "..." }} """ from litellm import completion response = completion( model=self.MODEL_NAME, messages=[{"role": "user", "content": prompt}], temperature=0.4, max_tokens=1000, ) import json try: cleaned = AINarrativeService._clean_ai_response(response.choices[0].message.content) result = json.loads(cleaned) except json.JSONDecodeError: # Fallback to basic recommendation result = { "title_en": f"Address {insight.insight_type}: {insight.title_en[:80]}", "title_ar": insight.title_ar or f"معالجة {insight.insight_type}", "description_en": insight.description_en, "description_ar": insight.description_ar or "", "expected_impact_en": "Addressing this insight should improve overall patient experience metrics.", "expected_impact_ar": "معالجة هذا البصيرة يجب أن تحسن مقاييس تجربة المريض بشكل عام.", } return { "category": category, "priority": priority, **result, } except Exception as e: logger.error(f"Error generating recommendation for insight {insight.id}: {e}") return None