using Microsoft.EntityFrameworkCore; public class AlertQualityMetricsService : IAlertQualityMetricsService { private readonly AppDbContext _db; public AlertQualityMetricsService(AppDbContext db) => _db = db; public async Task> ListAsync( AlertType? alertType, DateTimeOffset from, DateTimeOffset to) { var query = _db.AlertQualityMetrics .AsNoTracking() .Where(m => m.WindowStart >= from && m.WindowEnd <= to); if (alertType.HasValue) query = query.Where(m => m.AlertType == alertType.Value); var rows = await query .OrderByDescending(m => m.WindowStart) .ThenBy(m => m.AlertType) .ToListAsync(); return rows.Select(Map).ToList(); } public async Task GetSummaryAsync( DateTimeOffset from, DateTimeOffset to) { var snapshots = await _db.AlertQualityMetrics .AsNoTracking() .Where(m => m.WindowStart >= from && m.WindowEnd <= to) .ToListAsync(); if (snapshots.Count == 0) { return new AlertQualitySummaryResponse( from, to, 0, 0, 0, 0, 0, 0, 0, 0); } var totalAlerts = snapshots.Sum(s => s.TotalAlerts); var totalFeedback = snapshots.Sum(s => s.FeedbackCount); var totalAcknowledged = snapshots.Sum(s => s.AcknowledgedCount); var totalUseful = snapshots.Sum(s => s.FeedbackUsefulCount); var totalFalsePositive = snapshots.Sum(s => s.FeedbackFalsePositiveCount); var totalWouldAct = snapshots.Sum(s => s.FeedbackWouldActCount); var weightedAckSeconds = snapshots.Sum(s => s.AvgSecondsToAcknowledge * s.TotalAlerts); var weightedResolveSeconds = snapshots.Sum(s => s.AvgSecondsToResolution * s.ResolvedCount); return new AlertQualitySummaryResponse( PeriodStart: from, PeriodEnd: to, TotalAlerts: totalAlerts, TotalFeedback: totalFeedback, AcknowledgementRate: totalAlerts == 0 ? 0 : (double)totalAcknowledged / totalAlerts, FalsePositiveRate: totalFeedback == 0 ? 0 : (double)totalFalsePositive / totalFeedback, UsefulRate: totalFeedback == 0 ? 0 : (double)totalUseful / totalFeedback, WouldActRate: totalFeedback == 0 ? 0 : (double)totalWouldAct / totalFeedback, AvgSecondsToAcknowledge: totalAlerts == 0 ? 0 : weightedAckSeconds / totalAlerts, AvgSecondsToResolution: snapshots.Sum(s => s.ResolvedCount) == 0 ? 0 : weightedResolveSeconds / snapshots.Sum(s => s.ResolvedCount)); } public async Task> ListFeedbackAsync( DateTimeOffset periodStart, DateTimeOffset periodEnd, string? scenarioId) { // Left-join ClinicalAlert → SimulationRun on EncounterId for attribution. var query = from f in _db.AlertFeedbacks.AsNoTracking() join a in _db.ClinicalAlerts.AsNoTracking() on f.AlertId equals a.Id join r in _db.SimulationRuns.AsNoTracking() on a.EncounterId equals r.EncounterId into runs from r in runs.DefaultIfEmpty() where f.CreatedAt >= periodStart && f.CreatedAt <= periodEnd select new { Feedback = f, Alert = a, Run = r }; if (!string.IsNullOrWhiteSpace(scenarioId)) { var needle = scenarioId.ToLowerInvariant(); query = query.Where(x => x.Run != null && x.Run.ScenarioId != null && x.Run.ScenarioId.ToLower() == needle); } var rows = await query .OrderByDescending(x => x.Feedback.CreatedAt) .ToListAsync(); // Distinct by feedback id in case multiple SimulationRun rows share an EncounterId. return rows .GroupBy(x => x.Feedback.Id) .Select(g => { var x = g.First(); return new AlertFeedbackQualityRow( x.Feedback.Id, x.Alert.Id, x.Alert.AlertType.ToDbString(), x.Feedback.FeedbackType.ToDbString(), x.Feedback.Comment, x.Feedback.CreatedAt, x.Run?.ScenarioId, x.Run?.SessionId); }) .ToList(); } private static AlertQualityMetricResponse Map(AlertQualityMetric m) => new( m.Id, m.AlertType.ToString(), m.WindowStart, m.WindowEnd, m.TotalAlerts, m.AcknowledgedCount, m.ResolvedCount, m.EscalatedCount, m.FeedbackUsefulCount, m.FeedbackFalsePositiveCount, m.FeedbackWouldActCount, m.FeedbackCount, m.AcknowledgementRate, m.FalsePositiveRate, m.UsefulRate, m.WouldActRate, m.AvgSecondsToAcknowledge, m.AvgSecondsToResolution, m.ComputedAt); }