""" AI-Powered Analytics Service Provides 5 advanced AI features: 1. AI-Generated Executive Summaries 2. Early Warning System (at-risk departments) 3. Predictive Complaint Volume (time-series forecasting) 4. SLA Breach Prediction 5. Automated Action Recommendations """ import json import logging import math from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from django.conf import settings from django.core.cache import cache from django.db.models import Avg, Count, F, Q from django.utils import timezone from apps.complaints.models import Complaint from apps.feedback.models import Feedback from apps.organizations.models import Department, Hospital from apps.px_action_center.models import PXAction from apps.surveys.models import SurveyInstance logger = logging.getLogger(__name__) # ============================================================================= # Shared AI Service Wrapper (uses apps.core.ai_service — reads model from .env) # ============================================================================= def _ai_chat(system_prompt: str, user_prompt: str, max_tokens: int = 4096) -> Optional[str]: """Chat with the shared AIService. Model config is read from .env via settings.""" from apps.core.ai_service import AIService, AIServiceError try: result = AIService.chat_completion( prompt=user_prompt, system_prompt=system_prompt, max_tokens=max_tokens, response_format="json_object", ) result = result.strip() if result.startswith("```json"): result = result[7:] elif result.startswith("```"): result = result[3:] if result.endswith("```"): result = result[:-3] return result.strip() except (AIServiceError, Exception) as e: logger.error(f"AI chat error: {e}") return None class _OpenRouterClient: """Compatibility wrapper — delegates to the shared AIService.""" def is_configured(self) -> bool: return True # AIService manages its own config def chat( self, system_prompt: str, user_prompt: str, temperature: float = 0.2, max_tokens: int = 4096 ) -> Optional[str]: return _ai_chat(system_prompt, user_prompt, max_tokens) _client = _OpenRouterClient() # ============================================================================= # 1. AI-Generated Executive Summaries # ============================================================================= class ExecutiveSummaryGenerator: """ Generates bilingual (EN/AR) executive summaries by feeding aggregated KPI data to the LLM. """ CACHE_TIMEOUT = 3600 # 1 hour @staticmethod def _gather_data(user, hospital_id=None, department_id=None, period="30d") -> Dict[str, Any]: """Collect all data needed for the summary.""" now = timezone.now() if period == "7d": start = now - timedelta(days=7) elif period == "90d": start = now - timedelta(days=90) else: start = now - timedelta(days=30) # Base filters base_complaint = Complaint.objects.filter(created_at__gte=start) base_action = PXAction.objects.filter(created_at__gte=start) base_survey = SurveyInstance.objects.filter(completed_at__gte=start, status="completed") base_feedback = Feedback.objects.filter(created_at__gte=start) if hospital_id: h = Hospital.objects.filter(id=hospital_id).first() if h: base_complaint = base_complaint.filter(hospital=h) base_action = base_action.filter(hospital=h) base_survey = base_survey.filter(survey_template__hospital=h) base_feedback = base_feedback.filter(hospital=h) elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)(): base_complaint = base_complaint.filter(hospital=user.hospital) base_action = base_action.filter(hospital=user.hospital) base_survey = base_survey.filter(survey_template__hospital=user.hospital) base_feedback = base_feedback.filter(hospital=user.hospital) if department_id: d = Department.objects.filter(id=department_id).first() if d: base_complaint = base_complaint.filter(department=d) base_action = base_action.filter(department=d) # Previous period for comparison duration = now - start prev_start = start - duration prev_end = start prev_complaints = Complaint.objects.filter(created_at__gte=prev_start, created_at__lt=prev_end) if hospital_id: prev_complaints = prev_complaints.filter(hospital_id=hospital_id) if department_id: prev_complaints = prev_complaints.filter(department_id=department_id) # Complaints stats total_complaints = base_complaint.count() prev_complaint_count = prev_complaints.count() resolved = base_complaint.filter(status__in=["resolved", "closed"]).count() overdue = base_complaint.filter(is_overdue=True).count() critical = base_complaint.filter(severity__in=["high", "critical"]).count() # Top categories top_cats = list( base_complaint.filter(category__isnull=False) .values("category__name_en") .annotate(c=Count("id")) .order_by("-c")[:5] ) # Top departments by complaint volume top_depts = list( base_complaint.filter(department__isnull=False) .values("department__name_en") .annotate(c=Count("id")) .order_by("-c")[:5] ) # Actions stats total_actions = base_action.count() open_actions = base_action.filter(status__in=["open", "in_progress"]).count() closed_actions = base_action.filter(status="closed").count() overdue_actions = base_action.filter(is_overdue=True).count() # Survey stats total_surveys = base_survey.count() avg_score = base_survey.aggregate(avg=Avg("total_score"))["avg"] or 0 negative = base_survey.filter(is_negative=True).count() nps_surveys = base_survey.filter(survey_template__survey_type="nps", total_score__isnull=False) if nps_surveys.exists(): promoters = nps_surveys.filter(total_score__gte=9).count() detractors = nps_surveys.filter(total_score__lte=6).count() nps = ((promoters - detractors) / nps_surveys.count() * 100) if nps_surveys.count() > 0 else 0 else: nps = 0 # SLA compliance total_with_sla = base_complaint.filter(due_at__isnull=False).count() resolved_within = base_complaint.filter(status__in=["resolved", "closed"], resolved_at__lte=F("due_at")).count() sla_rate = (resolved_within / total_with_sla * 100) if total_with_sla > 0 else 0 # Feedback total_fb = base_feedback.count() compliments = base_feedback.filter(feedback_type="compliment").count() # Avg resolution hours resolved_with_time = base_complaint.filter( status__in=["resolved", "closed"], resolved_at__isnull=False, created_at__isnull=False ) if resolved_with_time.exists(): avg_res_hrs = resolved_with_time.annotate(rt=F("resolved_at") - F("created_at")).aggregate(avg=Avg("rt"))[ "avg" ] avg_res_hrs = (avg_res_hrs.total_seconds() / 3600) if avg_res_hrs else 0 else: avg_res_hrs = 0 complaint_change_pct = 0 if prev_complaint_count > 0: complaint_change_pct = ((total_complaints - prev_complaint_count) / prev_complaint_count) * 100 return { "period": period, "total_complaints": total_complaints, "complaint_change_pct": round(complaint_change_pct, 1), "resolved_complaints": resolved, "overdue_complaints": overdue, "critical_complaints": critical, "top_categories": [c["category__name_en"] for c in top_cats], "top_departments": [d["department__name_en"] for d in top_depts], "total_actions": total_actions, "open_actions": open_actions, "closed_actions": closed_actions, "overdue_actions": overdue_actions, "total_surveys": total_surveys, "avg_survey_score": round(avg_score, 2), "negative_surveys": negative, "nps_score": round(nps, 1), "sla_compliance": round(sla_rate, 1), "total_feedback": total_fb, "compliments": compliments, "avg_resolution_hours": round(avg_res_hrs, 1), } @classmethod def generate(cls, user, hospital_id=None, department_id=None, period="30d", force_refresh=False) -> Dict[str, Any]: """Generate or return cached executive summary.""" cache_key = f"exec_summary_{user.id}_{hospital_id}_{department_id}_{period}" if not force_refresh: cached = cache.get(cache_key) if cached: return cached data = cls._gather_data(user, hospital_id, department_id, period) system_prompt = ( "You are a senior healthcare quality analyst writing an executive summary " "for hospital leadership. Your summaries must be concise, data-driven, " "and actionable. Always provide BOTH English and Arabic (فصحى معاصرة) versions. " "Respond with ONLY valid JSON, no markdown." ) top_cats_str = ", ".join(data["top_categories"]) if data["top_categories"] else "N/A" top_depts_str = ", ".join(data["top_departments"]) if data["top_departments"] else "N/A" direction = "up" if data["complaint_change_pct"] > 0 else "down" user_prompt = f"""Write an executive summary for the past {data["period"]}. Use these exact data points: COMPLAINTS: {data["total_complaints"]} total ({direction} {abs(data["complaint_change_pct"])}% vs prior period). {data["resolved_complaints"]} resolved, {data["overdue_complaints"]} overdue, {data["critical_complaints"]} critical/high severity. Avg resolution: {data["avg_resolution_hours"]}h. SLA compliance: {data["sla_compliance"]}%. Top complaint categories: {top_cats_str} Top departments by volume: {top_depts_str} ACTIONS: {data["total_actions"]} total, {data["open_actions"]} open/in-progress, {data["closed_actions"]} closed, {data["overdue_actions"]} overdue. SURVEYS: {data["total_surveys"]} responses. Avg score: {data["avg_survey_score"]}/5. {data["negative_surveys"]} negative. NPS: {data["nps_score"]}. FEEDBACK: {data["total_feedback"]} items, {data["compliments"]} compliments. Write a 4-5 sentence English summary, then a 4-5 sentence Arabic summary, then 3 specific recommended actions. Return ONLY this JSON structure: {{ "summary_en": "...", "summary_ar": "...", "key_findings_en": ["finding 1", "finding 2", "finding 3"], "key_findings_ar": ["اكتشاف 1", "اكتشاف 2", "اكتشاف 3"], "recommendations_en": ["action 1", "action 2", "action 3"], "recommendations_ar": ["إجراء 1", "إجراء 2", "إجراء 3"], "risk_level": "low|medium|high" }}""" raw = _client.chat(system_prompt, user_prompt, temperature=0.2, max_tokens=2048) if not raw: result = { "summary_en": "AI summary unavailable — check OpenRouter configuration.", "summary_ar": "ملخص الذكاء الاصطناعي غير متاح — تحقق من إعدادات OpenRouter.", "key_findings_en": [ f"{data['total_complaints']} complaints recorded", f"SLA compliance at {data['sla_compliance']}%", f"NPS: {data['nps_score']}", ], "key_findings_ar": [ f"تم تسجيل {data['total_complaints']} شكوى", f"الالتزام باتفاقية مستوى الخدمة {data['sla_compliance']}%", f"صافي نقاط الترويج: {data['nps_score']}", ], "recommendations_en": [ "Review overdue complaints", "Address top complaint categories", "Improve SLA compliance", ], "recommendations_ar": [ "مراجعة الشكاوى المتأخرة", "معالجة فئات الشكاوى الرئيسية", "تحسين الالتزام باتفاقية مستوى الخدمة", ], "risk_level": "medium", } else: try: result = json.loads(raw) except json.JSONDecodeError: result = { "summary_en": raw[:500], "summary_ar": "", "key_findings_en": [], "key_findings_ar": [], "recommendations_en": [], "recommendations_ar": [], "risk_level": "medium", } result["_data"] = data # include raw numbers for UI display cache.set(cache_key, result, cls.CACHE_TIMEOUT) return result # ============================================================================= # 2. Early Warning System — At-Risk Department Detection # ============================================================================= class EarlyWarningSystem: """ Detects departments showing risk signals across multiple channels: - Rising complaint volume - Declining survey scores - Increasing SLA breaches - Negative social/call center feedback Returns ranked list of at-risk departments. """ CACHE_TIMEOUT = 1800 # 30 minutes RISK_WEIGHTS = { "complaint_volume_spike": 25, "survey_score_decline": 25, "sla_breach_increase": 20, "negative_feedback_rise": 15, "overdue_actions_rise": 15, } @classmethod def detect(cls, user, hospital_id=None, limit=10) -> List[Dict[str, Any]]: """Scan all active departments and return those with risk scores > threshold.""" cache_key = f"early_warning_{user.id}_{hospital_id}_{limit}" cached = cache.get(cache_key) if cached: return cached now = timezone.now() current_start = now - timedelta(days=30) prev_start = now - timedelta(days=60) # Get departments depts = Department.objects.filter(status="active") if hospital_id: depts = depts.filter(hospital_id=hospital_id) elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)(): depts = depts.filter(hospital=user.hospital) results = [] for dept in depts: signals = cls._evaluate_department(dept, current_start, prev_start, now) risk_score = signals["risk_score"] if risk_score > 20: # threshold — show anything above 20% signals["department_id"] = str(dept.id) signals["department_name"] = dept.name_en if hasattr(dept, "name_en") else str(dept) results.append(signals) results.sort(key=lambda x: x["risk_score"], reverse=True) results = results[:limit] cache.set(cache_key, results, cls.CACHE_TIMEOUT) return results @classmethod def _evaluate_department(cls, dept, current_start, prev_start, now) -> Dict[str, Any]: signals = {} risk_score = 0 # 1. Complaint volume spike current_complaints = Complaint.objects.filter(department=dept, created_at__gte=current_start).count() prev_complaints = Complaint.objects.filter( department=dept, created_at__gte=prev_start, created_at__lt=current_start ).count() if prev_complaints > 0: change_pct = ((current_complaints - prev_complaints) / prev_complaints) * 100 elif current_complaints > 0: change_pct = 100 else: change_pct = 0 volume_score = min(change_pct / 50 * 100, 100) # 50% spike = 100 risk_score += volume_score * cls.RISK_WEIGHTS["complaint_volume_spike"] / 100 signals["complaint_volume_spike"] = { "current": current_complaints, "previous": prev_complaints, "change_pct": round(change_pct, 1), "score": round(volume_score, 1), } # 2. Survey score decline current_surveys = SurveyInstance.objects.filter( journey_instance__department=dept, completed_at__gte=current_start, status="completed", total_score__isnull=False, ) prev_surveys = SurveyInstance.objects.filter( journey_instance__department=dept, completed_at__gte=prev_start, completed_at__lt=current_start, status="completed", total_score__isnull=False, ) curr_avg = current_surveys.aggregate(a=Avg("total_score"))["a"] or 0 prev_avg = prev_surveys.aggregate(a=Avg("total_score"))["a"] or 0 if prev_avg > 0: survey_change = ((curr_avg - prev_avg) / prev_avg) * 100 else: survey_change = 0 decline_score = max(0, min(-survey_change / 20 * 100, 100)) # 20% drop = 100 risk_score += decline_score * cls.RISK_WEIGHTS["survey_score_decline"] / 100 signals["survey_score_decline"] = { "current_avg": round(curr_avg, 2), "previous_avg": round(prev_avg, 2), "change_pct": round(survey_change, 1), "score": round(decline_score, 1), } # 3. SLA breach increase curr_breached = Complaint.objects.filter( department=dept, created_at__gte=current_start, is_overdue=True ).count() curr_total_c = Complaint.objects.filter(department=dept, created_at__gte=current_start).count() prev_breached = Complaint.objects.filter( department=dept, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True ).count() prev_total_c = Complaint.objects.filter( department=dept, created_at__gte=prev_start, created_at__lt=current_start ).count() curr_breach_rate = (curr_breached / curr_total_c * 100) if curr_total_c > 0 else 0 prev_breach_rate = (prev_breached / prev_total_c * 100) if prev_total_c > 0 else 0 if prev_breach_rate > 0: breach_change = curr_breach_rate - prev_breach_rate else: breach_change = curr_breach_rate sla_score = min(max(breach_change / 20 * 100, 0), 100) risk_score += sla_score * cls.RISK_WEIGHTS["sla_breach_increase"] / 100 signals["sla_breach_increase"] = { "current_rate": round(curr_breach_rate, 1), "previous_rate": round(prev_breach_rate, 1), "change_pp": round(breach_change, 1), "score": round(sla_score, 1), } # 4. Negative feedback rise curr_neg_fb = Feedback.objects.filter( department=dept, created_at__gte=current_start, sentiment="negative" ).count() prev_neg_fb = Feedback.objects.filter( department=dept, created_at__gte=prev_start, created_at__lt=current_start, sentiment="negative" ).count() if prev_neg_fb > 0: fb_change = ((curr_neg_fb - prev_neg_fb) / prev_neg_fb) * 100 elif curr_neg_fb > 0: fb_change = 100 else: fb_change = 0 fb_score = min(max(fb_change / 50 * 100, 0), 100) risk_score += fb_score * cls.RISK_WEIGHTS["negative_feedback_rise"] / 100 signals["negative_feedback_rise"] = { "current": curr_neg_fb, "previous": prev_neg_fb, "change_pct": round(fb_change, 1), "score": round(fb_score, 1), } # 5. Overdue actions rise curr_overdue = PXAction.objects.filter(department=dept, created_at__gte=current_start, is_overdue=True).count() prev_overdue = PXAction.objects.filter( department=dept, created_at__gte=prev_start, created_at__lt=current_start, is_overdue=True ).count() if prev_overdue > 0: overdue_change = ((curr_overdue - prev_overdue) / prev_overdue) * 100 elif curr_overdue > 0: overdue_change = 100 else: overdue_change = 0 overdue_score = min(max(overdue_change / 50 * 100, 0), 100) risk_score += overdue_score * cls.RISK_WEIGHTS["overdue_actions_rise"] / 100 signals["overdue_actions_rise"] = { "current": curr_overdue, "previous": prev_overdue, "change_pct": round(overdue_change, 1), "score": round(overdue_score, 1), } signals["risk_score"] = round(risk_score, 1) signals["risk_level"] = ( "critical" if risk_score >= 70 else "high" if risk_score >= 50 else "medium" if risk_score >= 30 else "low" ) signals["active_signals"] = sum( 1 for k in [ "complaint_volume_spike", "survey_score_decline", "sla_breach_increase", "negative_feedback_rise", "overdue_actions_rise", ] if signals.get(k, {}).get("score", 0) > 30 ) return signals # ============================================================================= # 3. Predictive Complaint Volume — Time-Series Forecasting # ============================================================================= class ComplaintVolumeForecaster: """ Uses weighted moving average + seasonality detection to forecast complaint volumes for the next 30/60/90 days with confidence bands. """ CACHE_TIMEOUT = 3600 # 1 hour @classmethod def forecast(cls, user, hospital_id=None, forecast_days=30) -> Dict[str, Any]: cache_key = f"complaint_forecast_{user.id}_{hospital_id}_{forecast_days}" cached = cache.get(cache_key) if cached: return cached now = timezone.now() # Need at least 90 days of history history_start = now - timedelta(days=120) qs = Complaint.objects.filter(created_at__gte=history_start) if hospital_id: qs = qs.filter(hospital_id=hospital_id) elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)(): qs = qs.filter(hospital=user.hospital) # Aggregate daily counts daily = list( qs.extra(select={"date": "DATE(created_at)"}).values("date").annotate(count=Count("id")).order_by("date") ) if len(daily) < 14: return cls._insufficient_data_response(forecast_days) # Build time series dates = [] counts = [] current = history_start.date() end = now.date() date_map = {str(d["date"]): d["count"] for d in daily} while current <= end: dates.append(current) counts.append(date_map.get(str(current), 0)) current += timedelta(days=1) # 7-day weighted moving average forecast weights = [0.05, 0.08, 0.12, 0.15, 0.20, 0.25, 0.15] # recent days weighted more predictions = [] history = list(counts) for day_offset in range(1, forecast_days + 1): recent_7 = history[-7:] # Pad if needed while len(recent_7) < 7: recent_7.insert(0, recent_7[0] if recent_7 else 0) wma = sum(w * v for w, v in zip(weights, recent_7)) predicted = max(0, round(wma, 1)) predictions.append(predicted) history.append(predicted) # feed back for next prediction # Confidence bands widen with forecast horizon recent_std = cls._std_dev(counts[-14:]) if len(counts) >= 14 else cls._std_dev(counts) forecast_dates = [now.date() + timedelta(days=i) for i in range(1, forecast_days + 1)] result_labels = [d.strftime("%Y-%m-%d") for d in forecast_dates] upper_band = [] lower_band = [] for i, pred in enumerate(predictions): # Confidence widens: 1 std at day 1, 3 std at day 30 std_multiplier = 1 + (i / forecast_days) * 2 margin = recent_std * std_multiplier upper_band.append(round(pred + margin, 1)) lower_band.append(round(max(0, pred - margin), 1)) # Detect seasonality signal (day-of-week patterns) dow_pattern = cls._detect_day_of_week_pattern(counts, dates) total_predicted = round(sum(predictions), 0) recent_30 = sum(counts[-30:]) if len(counts) >= 30 else sum(counts) change_pct = ((total_predicted - recent_30) / recent_30 * 100) if recent_30 > 0 else 0 result = { "labels": result_labels, "predicted": predictions, "upper_band": upper_band, "lower_band": lower_band, "total_predicted_30d": int(total_predicted), "recent_30d_actual": recent_30, "change_pct": round(change_pct, 1), "confidence_level": "high" if len(counts) > 60 else "medium" if len(counts) > 30 else "low", "day_of_week_pattern": dow_pattern, } cache.set(cache_key, result, cls.CACHE_TIMEOUT) return result @staticmethod def _std_dev(values): if not values: return 0 mean = sum(values) / len(values) variance = sum((x - mean) ** 2 for x in values) / len(values) return math.sqrt(variance) @staticmethod def _detect_day_of_week_pattern(counts, dates): """Detect if certain days of week have higher complaint volumes.""" if len(counts) < 14: return {} dow_totals = {i: [] for i in range(7)} for date, count in zip(dates, counts): dow_totals[date.weekday()].append(count) dow_avgs = { ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][k]: round(sum(v) / len(v), 1) if v else 0 for k, v in dow_totals.items() } overall_avg = sum(dow_avgs.values()) / 7 pattern = { day: "above_average" if avg > overall_avg * 1.1 else "below_average" if avg < overall_avg * 0.9 else "average" for day, avg in dow_avgs.items() } return pattern @staticmethod def _insufficient_data_response(forecast_days): return { "labels": [], "predicted": [], "upper_band": [], "lower_band": [], "total_predicted_30d": 0, "recent_30d_actual": 0, "change_pct": 0, "confidence_level": "insufficient_data", "day_of_week_pattern": {}, "message": "At least 14 days of historical data required for forecasting.", } # ============================================================================= # 4. SLA Breach Prediction # ============================================================================= class SLABreachPredictor: """ Predicts which open/in-progress complaints are at risk of breaching SLA. Uses factors: age, severity, assigned staff workload, historical resolution time. """ CACHE_TIMEOUT = 900 # 15 minutes @classmethod def predict(cls, user, hospital_id=None, limit=20) -> List[Dict[str, Any]]: """Return complaints ranked by breach probability.""" cache_key = f"sla_breach_pred_{user.id}_{hospital_id}_{limit}" cached = cache.get(cache_key) if cached: return cached now = timezone.now() qs = Complaint.objects.filter( status__in=["open", "in_progress"], due_at__isnull=False, ) if hospital_id: qs = qs.filter(hospital_id=hospital_id) elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)(): qs = qs.filter(hospital=user.hospital) qs = qs.select_related("hospital", "department", "source", "assigned_to").order_by("due_at") results = [] for complaint in qs[: limit * 2]: # fetch extra to filter/sort prediction = cls._predict_complaint_breach(complaint, now) if prediction["breach_probability"] > 30: # only show > 30% risk prediction["complaint_id"] = str(complaint.id) prediction["title"] = complaint.title prediction["severity"] = complaint.severity prediction["status"] = complaint.status prediction["due_at"] = complaint.due_at.isoformat() if complaint.due_at else None prediction["hours_remaining"] = ( round((complaint.due_at - now).total_seconds() / 3600, 1) if complaint.due_at else None ) if complaint.department: prediction["department"] = ( complaint.department.name_en if hasattr(complaint.department, "name_en") else str(complaint.department) ) if complaint.assigned_to: prediction["assigned_to"] = f"{complaint.assigned_to.first_name} {complaint.assigned_to.last_name}" results.append(prediction) results.sort(key=lambda x: x["breach_probability"], reverse=True) results = results[:limit] cache.set(cache_key, results, cls.CACHE_TIMEOUT) return results @classmethod def _predict_complaint_breach(cls, complaint, now) -> Dict[str, Any]: """Calculate breach probability for a single complaint.""" probability = 0 factors = [] # Factor 1: Time remaining (0-35 points) if complaint.due_at: hours_remaining = (complaint.due_at - now).total_seconds() / 3600 total_sla_hours = ( (complaint.due_at - complaint.created_at).total_seconds() / 3600 if complaint.created_at else 24 ) if hours_remaining <= 0: time_score = 35 factors.append("SLA already expired") elif hours_remaining < 4: time_score = 30 factors.append(f"Only {hours_remaining:.0f}h remaining") elif hours_remaining < 12: time_score = 20 factors.append(f"{hours_remaining:.0f}h remaining — urgent window") elif hours_remaining < 24: time_score = 10 factors.append(f"{hours_remaining:.0f}h remaining") else: time_pct = 1 - (hours_remaining / total_sla_hours) if total_sla_hours > 0 else 0 time_score = min(time_pct * 35, 35) if time_pct > 0.7: factors.append(f"{time_pct * 100:.0f}% of SLA time consumed") else: time_score = 15 factors.append("No SLA deadline set") probability += time_score # Factor 2: Severity (0-20 points) severity_scores = {"critical": 20, "high": 15, "medium": 8, "low": 3} sev_score = severity_scores.get(complaint.severity, 10) probability += sev_score if sev_score >= 15: factors.append(f"{complaint.severity.capitalize()} severity — typically slower to resolve") # Factor 3: Assignment status (0-15 points) if not complaint.assigned_to_id: probability += 15 factors.append("Unassigned — no owner yet") else: # Check assignee workload workload = Complaint.objects.filter( assigned_to=complaint.assigned_to, status__in=["open", "in_progress"], ).count() if workload >= 10: probability += 12 factors.append(f"Assignee has {workload} active cases — overloaded") elif workload >= 5: probability += 6 factors.append(f"Assignee has {workload} active cases — moderate load") # Factor 4: Historical resolution time for similar complaints (0-20 points) similar = Complaint.objects.filter( status__in=["resolved", "closed"], severity=complaint.severity, ) if complaint.department: similar = similar.filter(department=complaint.department) if similar.exists(): avg_resolve_hours = ( similar.filter(resolved_at__isnull=False, created_at__isnull=False) .annotate(rt=F("resolved_at") - F("created_at")) .aggregate(avg=Avg("rt"))["avg"] ) if avg_resolve_hours: avg_hrs = avg_resolve_hours.total_seconds() / 3600 total_sla_hrs = ( (complaint.due_at - complaint.created_at).total_seconds() / 3600 if complaint.due_at and complaint.created_at else 24 ) if avg_hrs > total_sla_hrs * 0.9: probability += 18 factors.append(f"Similar complaints avg {avg_hrs:.0f}h to resolve — exceeds SLA") elif avg_hrs > total_sla_hrs * 0.7: probability += 10 factors.append(f"Similar complaints avg {avg_hrs:.0f}h — close to SLA limit") # Factor 5: Age without progress (0-10 points) if complaint.created_at: age_hours = (now - complaint.created_at).total_seconds() / 3600 if complaint.status == "open" and age_hours > 24: probability += 10 factors.append(f"Open for {age_hours:.0f}h without status change") elif complaint.status == "open" and age_hours > 12: probability += 5 factors.append(f"Open for {age_hours:.0f}h") probability = min(round(probability, 1), 100) return { "breach_probability": probability, "risk_factors": factors, "recommendation": cls._get_recommendation(probability, factors), } @staticmethod def _get_recommendation(probability, factors): if probability >= 80: return "Immediate escalation required" elif probability >= 60: return "Escalate or reassign to available staff" elif probability >= 40: return "Add priority flag and monitor closely" else: return "On track — continue standard monitoring" # ============================================================================= # 5. Automated Action Recommendations # ============================================================================= class ActionRecommendationEngine: """ Analyzes clusters of similar complaints and recommends specific PX Actions based on historical resolution patterns. """ CACHE_TIMEOUT = 3600 # 1 hour @classmethod def generate_recommendations(cls, user, hospital_id=None, department_id=None, limit=5) -> List[Dict[str, Any]]: """Generate AI-powered action recommendations from complaint analysis.""" cache_key = f"action_recommendations_{user.id}_{hospital_id}_{department_id}_{limit}" cached = cache.get(cache_key) if cached: return cached # Gather recent resolved complaints with resolution data now = timezone.now() start = now - timedelta(days=90) qs = Complaint.objects.filter( status__in=["resolved", "closed"], created_at__gte=start, resolution__isnull=False, ) if hospital_id: qs = qs.filter(hospital_id=hospital_id) elif hasattr(user, "hospital") and user.hospital and not getattr(user, "is_px_admin", lambda: False)(): qs = qs.filter(hospital=user.hospital) if department_id: qs = qs.filter(department_id=department_id) qs = qs.select_related("department").order_by("-resolved_at") # Group by category/domain category_data = {} for c in qs[:200]: cat = c.category if hasattr(c, "category") and c.category else "uncategorized" if cat not in category_data: category_data[cat] = { "count": 0, "resolutions": [], "severities": [], "departments": set(), } category_data[cat]["count"] += 1 if hasattr(c, "resolution") and c.resolution: category_data[cat]["resolutions"].append(c.resolution[:200]) if hasattr(c, "severity"): category_data[cat]["severities"].append(c.severity) if c.department: dept_name = c.department.name_en if hasattr(c.department, "name_en") else str(c.department) category_data[cat]["departments"].add(dept_name) # Filter to categories with enough volume (at least 3 complaints) significant_categories = {k: v for k, v in category_data.items() if v["count"] >= 3} if not significant_categories: return cls._no_data_response() # Use AI to generate recommendations for top categories recommendations = [] top_categories = sorted(significant_categories.items(), key=lambda x: x[1]["count"], reverse=True)[:limit] for category, data in top_categories: rec = cls._generate_ai_recommendation(category, data) if rec: recommendations.append(rec) # If AI is unavailable, fall back to rule-based recommendations if not recommendations: recommendations = cls._generate_rule_based_recommendations(top_categories) cache.set(cache_key, recommendations, cls.CACHE_TIMEOUT) return recommendations @classmethod def _generate_ai_recommendation(cls, category, data) -> Optional[Dict[str, Any]]: """Use OpenRouter to generate a recommendation for a complaint cluster.""" if not _client.is_configured(): return None system_prompt = ( "You are a healthcare quality improvement expert. Given a cluster of similar complaints " "and their resolutions, recommend specific, actionable improvement steps. " "Respond with ONLY valid JSON, no markdown." ) resolutions_sample = ( "\n".join(data["resolutions"][:5]) if data["resolutions"] else "No resolution data available." ) most_common_sev = max(set(data["severities"]), key=data["severities"].count) if data["severities"] else "medium" depts_str = ", ".join(list(data["departments"])[:3]) if data["departments"] else "Multiple departments" user_prompt = f"""Category: {category} Complaint count: {data["count"]} Most common severity: {most_common_sev} Affected departments: {depts_str} Sample resolutions: {resolutions_sample} Based on this pattern, recommend 2-3 specific, actionable improvement actions. Return ONLY this JSON: {{ "category": "{category}", "problem_summary_en": "Brief description of the systemic issue", "problem_summary_ar": "وصف موجز للمشكلة المنهجية", "complaint_count": {data["count"]}, "affected_departments": ["dept1", "dept2"], "recommended_actions_en": ["Action 1", "Action 2", "Action 3"], "recommended_actions_ar": ["إجراء 1", "إجراء 2", "إجراء 3"], "expected_impact_en": "Expected improvement if actions are implemented", "priority": "low|medium|high|critical", "action_category": "clinical_quality|patient_safety|service_quality|staff_behavior|facility|process_improvement|other" }}""" raw = _client.chat(system_prompt, user_prompt, temperature=0.2, max_tokens=1024) if not raw: return None try: result = json.loads(raw) result["source"] = "ai_generated" return result except json.JSONDecodeError: return None @classmethod def _generate_rule_based_recommendations(cls, top_categories) -> List[Dict[str, Any]]: """Fallback: generate recommendations without AI.""" recommendations = [] category_templates = { "wait_time": { "problem_en": "Recurring complaints about excessive wait times", "problem_ar": "شكاوى متكررة حول أوقات الانتظار الطويلة", "actions_en": [ "Review scheduling system", "Add real-time wait tracking", "Implement patient communication updates", ], "actions_ar": ["مراجعة نظام الجدولة", "إضافة تتبع وقت الانتظار", "تطبيق تحديثات التواصل مع المرضى"], "impact_en": "Reduce average wait time by 30% and improve patient satisfaction", "category": "process_improvement", "priority": "high", }, "staff_behavior": { "problem_en": "Pattern of complaints about staff communication and behavior", "problem_ar": "نمط من الشكاوى حول سلوك وتواصل الموظفين", "actions_en": [ "Conduct patient communication training", "Implement feedback loop for staff performance", "Establish patient relations team", ], "actions_ar": [ "إجراء تدريب على التواصل مع المرضى", "تطبيق حلقة التغذية الراجعة لأداء الموظفين", "إنشاء فريق علاقات المرضى", ], "impact_en": "Improve staff-patient relationships and reduce behavior-related complaints", "category": "staff_behavior", "priority": "high", }, "clinical": { "problem_en": "Clinical quality and care delivery concerns", "problem_ar": "مخاوف حول الجودة السريرية وتقديم الرعاية", "actions_en": ["Conduct clinical audit", "Review care protocols", "Implement peer review process"], "actions_ar": ["إجراء مراجعة سريرية", "مراجعة بروتوكولات الرعاية", "تطبيق عملية مراجعة الأقران"], "impact_en": "Improve clinical outcomes and patient safety metrics", "category": "clinical_quality", "priority": "critical", }, "facility": { "problem_en": "Facility condition and environmental complaints", "problem_ar": "شكاوى حول حالة المنشأة والبيئة", "actions_en": ["Conduct facility inspection", "Create maintenance schedule", "Upgrade affected areas"], "actions_ar": ["إجراء فحص للمنشأة", "إنشاء جدول صيانة", "ترقية المناطق المتأثرة"], "impact_en": "Improve patient environment and facility standards compliance", "category": "facility", "priority": "medium", }, } for category, data in top_categories: template = None for key, tmpl in category_templates.items(): if key in category.lower(): template = tmpl break if not template: template = { "problem_en": f"Recurring complaints in {category}", "problem_ar": f"شكاوى متكررة في {category}", "actions_en": [ "Investigate complaint pattern", "Develop corrective action plan", "Monitor improvement metrics", ], "actions_ar": ["التحقيق في نمط الشكاوى", "وضع خطة عمل تصحيحية", "مراقبة مقاييس التحسين"], "impact_en": f"Reduce {category}-related complaints", "category": "other", "priority": "medium", } recommendations.append( { "category": category, "problem_summary_en": template["problem_en"], "problem_summary_ar": template["problem_ar"], "complaint_count": data["count"], "affected_departments": list(data["departments"])[:5], "recommended_actions_en": template["actions_en"], "recommended_actions_ar": template["actions_ar"], "expected_impact_en": template["impact_en"], "priority": template["priority"], "action_category": template["category"], "source": "rule_based", } ) return recommendations @staticmethod def _no_data_response() -> List[Dict[str, Any]]: return [ { "category": "general", "problem_summary_en": "Insufficient resolved complaint data to identify patterns", "problem_summary_ar": "بيانات غير كافية لتحديد الأنماط", "complaint_count": 0, "affected_departments": [], "recommended_actions_en": [ "Ensure all resolved complaints have resolution notes", "Review complaint categorization", "Build historical resolution patterns", ], "recommended_actions_ar": [ "ضمان وجود ملاحظات حل لجميع الشكاوى المحلولة", "مراجعة تصنيف الشكاوى", "بناء أنماط الحل التاريخية", ], "expected_impact_en": "Enable data-driven action recommendations", "priority": "medium", "action_category": "process_improvement", "source": "system", } ]