2199 lines
93 KiB
Python
2199 lines
93 KiB
Python
"""
|
|
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
|