Run initial test for Climate Resilience Verification Suite
Add first part of Alert Quality Analytics
This commit is contained in:
@@ -17,4 +17,5 @@ public static class ClinicalPermissions
|
||||
public const string FhirRead = "fhir:read";
|
||||
public const string AuditRead = "audit:read";
|
||||
public const string UsersAdmin = "users:admin";
|
||||
public const string AlertsFeedback = "alerts:feedback";
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.AnalyticsRead,
|
||||
ClinicalPermissions.OrdersWrite,
|
||||
ClinicalPermissions.MedicationsWrite,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
},
|
||||
[ClinicalRole.Physician] = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.AnalyticsRead,
|
||||
ClinicalPermissions.OrdersWrite,
|
||||
ClinicalPermissions.MedicationsWrite,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
},
|
||||
[ClinicalRole.Admin] = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public static class ClinicalRolePermissionMap
|
||||
ClinicalPermissions.FhirRead,
|
||||
ClinicalPermissions.AuditRead,
|
||||
ClinicalPermissions.UsersAdmin,
|
||||
ClinicalPermissions.AlertsFeedback,
|
||||
},
|
||||
[ClinicalRole.Integration] = new(StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class AlertQualityAggregatorService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly IOptions<AlertQualityOptions> _options;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
private readonly ILogger<AlertQualityAggregatorService> _logger;
|
||||
|
||||
public AlertQualityAggregatorService(
|
||||
IServiceScopeFactory scopes,
|
||||
IOptions<AlertQualityOptions> options,
|
||||
ClinicalMetrics metrics,
|
||||
ILogger<AlertQualityAggregatorService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_options = options;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var interval = TimeSpan.FromMinutes(Math.Max(1, _options.Value.IntervalMinutes));
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
|
||||
// Run once at startup, then on interval
|
||||
await AggregateAsync(stoppingToken);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await AggregateAsync(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task AggregateAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var windowHours = Math.Max(1, _options.Value.WindowHours);
|
||||
var windowEnd = AlignToHour(DateTimeOffset.UtcNow);
|
||||
var windowStart = windowEnd.AddHours(-windowHours);
|
||||
|
||||
foreach (AlertType alertType in Enum.GetValues<AlertType>())
|
||||
{
|
||||
var alerts = await db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.AlertType == alertType
|
||||
&& a.TriggeredAt >= windowStart
|
||||
&& a.TriggeredAt < windowEnd)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Status,
|
||||
a.TriggeredAt,
|
||||
a.AcknowledgedAt,
|
||||
a.ResolvedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (alerts.Count == 0)
|
||||
continue;
|
||||
|
||||
var alertIds = await db.ClinicalAlerts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.AlertType == alertType
|
||||
&& a.TriggeredAt >= windowStart
|
||||
&& a.TriggeredAt < windowEnd)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var feedbacks = await db.AlertFeedbacks
|
||||
.AsNoTracking()
|
||||
.Where(f => alertIds.Contains(f.AlertId))
|
||||
.Select(f => f.FeedbackType)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var total = alerts.Count;
|
||||
var acknowledged = alerts.Count(a =>
|
||||
a.Status is AlertStatus.Acknowledged or AlertStatus.Resolved);
|
||||
var resolved = alerts.Count(a => a.Status == AlertStatus.Resolved);
|
||||
var escalated = alerts.Count(a => a.Status == AlertStatus.Escalated);
|
||||
|
||||
var useful = feedbacks.Count(f => f == AlertFeedbackType.Useful);
|
||||
var falsePositive = feedbacks.Count(f => f == AlertFeedbackType.FalsePositive);
|
||||
var wouldAct = feedbacks.Count(f => f == AlertFeedbackType.WouldAct);
|
||||
var feedbackCount = feedbacks.Count;
|
||||
|
||||
var ackDurations = alerts
|
||||
.Where(a => a.AcknowledgedAt.HasValue)
|
||||
.Select(a => (a.AcknowledgedAt!.Value - a.TriggeredAt).TotalSeconds)
|
||||
.ToList();
|
||||
|
||||
var resolveDurations = alerts
|
||||
.Where(a => a.ResolvedAt.HasValue && a.AcknowledgedAt.HasValue)
|
||||
.Select(a => (a.ResolvedAt!.Value - a.AcknowledgedAt!.Value).TotalSeconds)
|
||||
.ToList();
|
||||
|
||||
var metric = new AlertQualityMetric
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AlertType = alertType,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = windowEnd,
|
||||
TotalAlerts = total,
|
||||
AcknowledgedCount = acknowledged,
|
||||
ResolvedCount = resolved,
|
||||
EscalatedCount = escalated,
|
||||
FeedbackUsefulCount = useful,
|
||||
FeedbackFalsePositiveCount = falsePositive,
|
||||
FeedbackWouldActCount = wouldAct,
|
||||
FeedbackCount = feedbackCount,
|
||||
AcknowledgementRate = SafeRate(acknowledged, total),
|
||||
FalsePositiveRate = SafeRate(falsePositive, feedbackCount),
|
||||
UsefulRate = SafeRate(useful, feedbackCount),
|
||||
WouldActRate = SafeRate(wouldAct, feedbackCount),
|
||||
AvgSecondsToAcknowledge = ackDurations.Count > 0 ? ackDurations.Average() : 0,
|
||||
AvgSecondsToResolution = resolveDurations.Count > 0 ? resolveDurations.Average() : 0,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var existing = await db.AlertQualityMetrics
|
||||
.FirstOrDefaultAsync(m =>
|
||||
m.AlertType == alertType
|
||||
&& m.WindowStart == windowStart
|
||||
&& m.WindowEnd == windowEnd, ct);
|
||||
|
||||
if (existing is null)
|
||||
db.AlertQualityMetrics.Add(metric);
|
||||
else
|
||||
{
|
||||
existing.TotalAlerts = metric.TotalAlerts;
|
||||
existing.AcknowledgedCount = metric.AcknowledgedCount;
|
||||
existing.ResolvedCount = metric.ResolvedCount;
|
||||
existing.EscalatedCount = metric.EscalatedCount;
|
||||
existing.FeedbackUsefulCount = metric.FeedbackUsefulCount;
|
||||
existing.FeedbackFalsePositiveCount = metric.FeedbackFalsePositiveCount;
|
||||
existing.FeedbackWouldActCount = metric.FeedbackWouldActCount;
|
||||
existing.FeedbackCount = metric.FeedbackCount;
|
||||
existing.AcknowledgementRate = metric.AcknowledgementRate;
|
||||
existing.FalsePositiveRate = metric.FalsePositiveRate;
|
||||
existing.UsefulRate = metric.UsefulRate;
|
||||
existing.WouldActRate = metric.WouldActRate;
|
||||
existing.AvgSecondsToAcknowledge = metric.AvgSecondsToAcknowledge;
|
||||
existing.AvgSecondsToResolution = metric.AvgSecondsToResolution;
|
||||
existing.ComputedAt = metric.ComputedAt;
|
||||
metric = existing;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var typeLabel = alertType.ToDbString();
|
||||
_metrics.AlertAcknowledgementRate.WithLabels(typeLabel).Set(metric.AcknowledgementRate);
|
||||
_metrics.AlertFalsePositiveRate.WithLabels(typeLabel).Set(metric.FalsePositiveRate);
|
||||
_metrics.AlertUsefulRate.WithLabels(typeLabel).Set(metric.UsefulRate);
|
||||
_metrics.AlertAvgAckSeconds.WithLabels(typeLabel).Set(metric.AvgSecondsToAcknowledge);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Alert quality aggregation complete for window {Start} – {End}",
|
||||
windowStart, windowEnd);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AlertQualityAggregatorService failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset AlignToHour(DateTimeOffset value) =>
|
||||
new(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Offset);
|
||||
|
||||
private static double SafeRate(int numerator, int denominator) =>
|
||||
denominator == 0 ? 0.0 : (double)numerator / denominator;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
public class AlertQualityOptions
|
||||
{
|
||||
public const string Section = "AlertQuality";
|
||||
|
||||
/// <summary>Aggregation interval in minutes. Default: 60.</summary>
|
||||
public int IntervalMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>Snapshot window size in hours. Default: 1.</summary>
|
||||
public int WindowHours { get; set; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class AlertQualityMetricsController : ControllerBase
|
||||
{
|
||||
private readonly IAlertQualityMetricsService _metrics;
|
||||
|
||||
public AlertQualityMetricsController(IAlertQualityMetricsService metrics) =>
|
||||
_metrics = metrics;
|
||||
|
||||
/// <summary>
|
||||
/// Returns alert quality metric snapshots for a time range, optionally filtered by alert type.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/alerts/quality-metrics")]
|
||||
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? alertType,
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to)
|
||||
{
|
||||
AlertType? parsedType = null;
|
||||
if (!string.IsNullOrEmpty(alertType))
|
||||
{
|
||||
try
|
||||
{
|
||||
parsedType = AlertTypeExtensions.FromDbString(alertType);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return BadRequest(ApiResponse<object>.Fail(
|
||||
400, "Invalid alert type filter.", "INVALID_ALERT_TYPE"));
|
||||
}
|
||||
}
|
||||
|
||||
var periodEnd = to ?? DateTimeOffset.UtcNow;
|
||||
var periodStart = from ?? periodEnd.AddDays(-7);
|
||||
if (periodStart >= periodEnd)
|
||||
{
|
||||
return BadRequest(ApiResponse<object>.Fail(
|
||||
400, "'from' must be before 'to'.", "INVALID_DATE_RANGE"));
|
||||
}
|
||||
|
||||
var items = await _metrics.ListAsync(parsedType, periodStart, periodEnd);
|
||||
return Ok(ApiResponse<object>.Ok(new
|
||||
{
|
||||
periodStart,
|
||||
periodEnd,
|
||||
items
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregate alert quality rates across all alert types for a time range.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/alerts/quality-metrics/summary")]
|
||||
[AuthorizePermission(ClinicalPermissions.AnalyticsRead)]
|
||||
[ProducesResponseType(typeof(ApiResponse<AlertQualitySummaryResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Summary(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to)
|
||||
{
|
||||
var periodEnd = to ?? DateTimeOffset.UtcNow;
|
||||
var periodStart = from ?? periodEnd.AddDays(-7);
|
||||
|
||||
var summary = await _metrics.GetSummaryAsync(periodStart, periodEnd);
|
||||
return Ok(ApiResponse<AlertQualitySummaryResponse>.Ok(summary));
|
||||
}
|
||||
}
|
||||
@@ -173,4 +173,30 @@ public class AlertsController : ControllerBase
|
||||
var alert = await _alerts.ResolveAsync(id);
|
||||
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits clinician feedback for an acknowledged or resolved alert.
|
||||
/// One submission per user per alert.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/alerts/{id:guid}/feedback")]
|
||||
[AuthorizePermission(ClinicalPermissions.AlertsFeedback)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> SubmitFeedback(
|
||||
Guid id, [FromBody] SubmitAlertFeedbackRequest req)
|
||||
{
|
||||
var feedback = await _alerts.SubmitFeedbackAsync(id, req.FeedbackType, req.Comment);
|
||||
return Created(
|
||||
$"/api/v1/alerts/{id}/feedback/{feedback.Id}",
|
||||
ApiResponse<object>.Ok(new
|
||||
{
|
||||
id = feedback.Id,
|
||||
alertId = feedback.AlertId,
|
||||
feedbackType = feedback.FeedbackType.ToString(),
|
||||
comment = feedback.Comment,
|
||||
createdAt = feedback.CreatedAt
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<WardGateway> WardGateways => Set<WardGateway>();
|
||||
public DbSet<ClinicalSyncBatch> ClinicalSyncBatches => Set<ClinicalSyncBatch>();
|
||||
public DbSet<ClinicalSyncConflict> ClinicalSyncConflicts => Set<ClinicalSyncConflict>();
|
||||
public DbSet<AlertFeedback> AlertFeedbacks => Set<AlertFeedback>();
|
||||
public DbSet<AlertQualityMetric> AlertQualityMetrics => Set<AlertQualityMetric>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class AlertFeedbackConfiguration : IEntityTypeConfiguration<AlertFeedback>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AlertFeedback> builder)
|
||||
{
|
||||
builder.ToTable("alert_feedbacks");
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(f => f.AlertId).HasColumnName("alert_id").IsRequired();
|
||||
builder.Property(f => f.UserId).HasColumnName("user_id").IsRequired();
|
||||
builder.Property(f => f.FeedbackType).HasColumnName("feedback_type").HasMaxLength(30).IsRequired()
|
||||
.HasConversion(v => v.ToDbString(), v => AlertFeedbackTypeExtensions.FromDbString(v));
|
||||
builder.Property(f => f.Comment).HasColumnName("comment").HasMaxLength(1000);
|
||||
builder.Property(f => f.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(f => f.Alert)
|
||||
.WithMany(a => a.Feedbacks)
|
||||
.HasForeignKey(f => f.AlertId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(f => f.AlertId);
|
||||
builder.HasIndex(f => f.FeedbackType);
|
||||
builder.HasIndex(f => new { f.AlertId, f.UserId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
public class AlertQualityMetricConfiguration : IEntityTypeConfiguration<AlertQualityMetric>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AlertQualityMetric> builder)
|
||||
{
|
||||
builder.ToTable("alert_quality_metrics");
|
||||
builder.HasKey(m => m.Id);
|
||||
builder.Property(m => m.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||
builder.Property(m => m.AlertType).HasColumnName("alert_type").HasMaxLength(50).IsRequired()
|
||||
.HasConversion(v => v.ToDbString(), v => AlertTypeExtensions.FromDbString(v));
|
||||
builder.Property(m => m.WindowStart).HasColumnName("window_start").IsRequired();
|
||||
builder.Property(m => m.WindowEnd).HasColumnName("window_end").IsRequired();
|
||||
builder.Property(m => m.TotalAlerts).HasColumnName("total_alerts").IsRequired();
|
||||
builder.Property(m => m.AcknowledgedCount).HasColumnName("acknowledged_count").IsRequired();
|
||||
builder.Property(m => m.ResolvedCount).HasColumnName("resolved_count").IsRequired();
|
||||
builder.Property(m => m.EscalatedCount).HasColumnName("escalated_count").IsRequired();
|
||||
builder.Property(m => m.FeedbackUsefulCount).HasColumnName("feedback_useful_count").IsRequired();
|
||||
builder.Property(m => m.FeedbackFalsePositiveCount).HasColumnName("feedback_false_positive_count").IsRequired();
|
||||
builder.Property(m => m.FeedbackWouldActCount).HasColumnName("feedback_would_act_count").IsRequired();
|
||||
builder.Property(m => m.FeedbackCount).HasColumnName("feedback_count").IsRequired();
|
||||
builder.Property(m => m.AcknowledgementRate).HasColumnName("acknowledgement_rate").IsRequired();
|
||||
builder.Property(m => m.FalsePositiveRate).HasColumnName("false_positive_rate").IsRequired();
|
||||
builder.Property(m => m.UsefulRate).HasColumnName("useful_rate").IsRequired();
|
||||
builder.Property(m => m.WouldActRate).HasColumnName("would_act_rate").IsRequired();
|
||||
builder.Property(m => m.AvgSecondsToAcknowledge).HasColumnName("avg_seconds_to_acknowledge").IsRequired();
|
||||
builder.Property(m => m.AvgSecondsToResolution).HasColumnName("avg_seconds_to_resolution").IsRequired();
|
||||
builder.Property(m => m.ComputedAt).HasColumnName("computed_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasIndex(m => new { m.AlertType, m.WindowStart, m.WindowEnd }).IsUnique();
|
||||
builder.HasIndex(m => m.WindowStart);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
|
||||
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id");
|
||||
builder.Property(a => a.SyncedFromGateway).HasColumnName("synced_from_gateway").HasDefaultValue(false);
|
||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
builder.HasOne(a => a.Encounter)
|
||||
.WithMany(e => e.Alerts)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
public class AlertFeedback
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AlertId { get; set; }
|
||||
public ClinicalAlert Alert { get; set; } = null!;
|
||||
public Guid UserId { get; set; }
|
||||
public AlertFeedbackType FeedbackType { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public class AlertQualityMetric
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public AlertType AlertType { get; set; }
|
||||
public DateTimeOffset WindowStart { get; set; }
|
||||
public DateTimeOffset WindowEnd { get; set; }
|
||||
public int TotalAlerts { get; set; }
|
||||
public int AcknowledgedCount { get; set; }
|
||||
public int ResolvedCount { get; set; }
|
||||
public int EscalatedCount { get; set; }
|
||||
public int FeedbackUsefulCount { get; set; }
|
||||
public int FeedbackFalsePositiveCount { get; set; }
|
||||
public int FeedbackWouldActCount { get; set; }
|
||||
public int FeedbackCount { get; set; }
|
||||
public double AcknowledgementRate { get; set; }
|
||||
public double FalsePositiveRate { get; set; }
|
||||
public double UsefulRate { get; set; }
|
||||
public double WouldActRate { get; set; }
|
||||
public double AvgSecondsToAcknowledge { get; set; }
|
||||
public double AvgSecondsToResolution { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -15,6 +15,8 @@ public class ClinicalAlert
|
||||
public DateTimeOffset TriggeredAt { get; set; }
|
||||
public Guid? ClientAlertId { get; set; }
|
||||
public bool SyncedFromGateway { get; set; }
|
||||
public bool FeedbackReceived { get; set; }
|
||||
|
||||
public Encounter Encounter { get; set; } = null!;
|
||||
public List<AlertFeedback> Feedbacks { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
public enum AlertFeedbackType
|
||||
{
|
||||
Useful,
|
||||
TooEarly,
|
||||
TooLate,
|
||||
FalsePositive,
|
||||
MissingContext,
|
||||
WouldAct
|
||||
}
|
||||
|
||||
public static class AlertFeedbackTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this AlertFeedbackType t) => t switch
|
||||
{
|
||||
AlertFeedbackType.Useful => "USEFUL",
|
||||
AlertFeedbackType.TooEarly => "TOO_EARLY",
|
||||
AlertFeedbackType.TooLate => "TOO_LATE",
|
||||
AlertFeedbackType.FalsePositive => "FALSE_POSITIVE",
|
||||
AlertFeedbackType.MissingContext => "MISSING_CONTEXT",
|
||||
AlertFeedbackType.WouldAct => "WOULD_ACT",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||
};
|
||||
|
||||
public static AlertFeedbackType FromDbString(string v) => v switch
|
||||
{
|
||||
"USEFUL" => AlertFeedbackType.Useful,
|
||||
"TOO_EARLY" => AlertFeedbackType.TooEarly,
|
||||
"TOO_LATE" => AlertFeedbackType.TooLate,
|
||||
"FALSE_POSITIVE" => AlertFeedbackType.FalsePositive,
|
||||
"MISSING_CONTEXT" => AlertFeedbackType.MissingContext,
|
||||
"WOULD_ACT" => AlertFeedbackType.WouldAct,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,8 @@ public enum AuditAction
|
||||
PatientUpdated,
|
||||
SuppressionWindowSet,
|
||||
UserLogin,
|
||||
AuthorizationDenied
|
||||
AuthorizationDenied,
|
||||
AlertFeedbackSubmitted,
|
||||
}
|
||||
|
||||
public static class AuditActionExtensions
|
||||
@@ -28,6 +29,7 @@ public static class AuditActionExtensions
|
||||
AuditAction.SuppressionWindowSet => "SUPPRESSION_WINDOW_SET",
|
||||
AuditAction.UserLogin => "USER_LOGIN",
|
||||
AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED",
|
||||
AuditAction.AlertFeedbackSubmitted => "ALERT_FEEDBACK_SUBMITTED",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(a))
|
||||
};
|
||||
|
||||
@@ -44,6 +46,7 @@ public static class AuditActionExtensions
|
||||
"SUPPRESSION_WINDOW_SET" => AuditAction.SuppressionWindowSet,
|
||||
"USER_LOGIN" => AuditAction.UserLogin,
|
||||
"AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied,
|
||||
"ALERT_FEEDBACK_SUBMITTED" => AuditAction.AlertFeedbackSubmitted,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v))
|
||||
};
|
||||
}
|
||||
+1866
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace VigilCareClinicalAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAlertQualityAnalytics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "feedback_received",
|
||||
table: "clinical_alerts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_feedbacks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
alert_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
feedback_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
comment = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_feedbacks", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_alert_feedbacks_clinical_alerts_alert_id",
|
||||
column: x => x.alert_id,
|
||||
principalTable: "clinical_alerts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "alert_quality_metrics",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
alert_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
window_start = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
window_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
total_alerts = table.Column<int>(type: "integer", nullable: false),
|
||||
acknowledged_count = table.Column<int>(type: "integer", nullable: false),
|
||||
resolved_count = table.Column<int>(type: "integer", nullable: false),
|
||||
escalated_count = table.Column<int>(type: "integer", nullable: false),
|
||||
feedback_useful_count = table.Column<int>(type: "integer", nullable: false),
|
||||
feedback_false_positive_count = table.Column<int>(type: "integer", nullable: false),
|
||||
feedback_would_act_count = table.Column<int>(type: "integer", nullable: false),
|
||||
feedback_count = table.Column<int>(type: "integer", nullable: false),
|
||||
acknowledgement_rate = table.Column<double>(type: "double precision", nullable: false),
|
||||
false_positive_rate = table.Column<double>(type: "double precision", nullable: false),
|
||||
useful_rate = table.Column<double>(type: "double precision", nullable: false),
|
||||
would_act_rate = table.Column<double>(type: "double precision", nullable: false),
|
||||
avg_seconds_to_acknowledge = table.Column<double>(type: "double precision", nullable: false),
|
||||
avg_seconds_to_resolution = table.Column<double>(type: "double precision", nullable: false),
|
||||
computed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_alert_quality_metrics", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_feedbacks_alert_id",
|
||||
table: "alert_feedbacks",
|
||||
column: "alert_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_feedbacks_alert_id_user_id",
|
||||
table: "alert_feedbacks",
|
||||
columns: new[] { "alert_id", "user_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_feedbacks_feedback_type",
|
||||
table: "alert_feedbacks",
|
||||
column: "feedback_type");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_quality_metrics_alert_type_window_start_window_end",
|
||||
table: "alert_quality_metrics",
|
||||
columns: new[] { "alert_type", "window_start", "window_end" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_alert_quality_metrics_window_start",
|
||||
table: "alert_quality_metrics",
|
||||
column: "window_start");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_feedbacks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "alert_quality_metrics");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "feedback_received",
|
||||
table: "clinical_alerts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,145 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlertFeedback", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid>("AlertId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("alert_id");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("comment");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("FeedbackType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("feedback_type");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AlertId");
|
||||
|
||||
b.HasIndex("FeedbackType");
|
||||
|
||||
b.HasIndex("AlertId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_feedbacks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlertQualityMetric", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<int>("AcknowledgedCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("acknowledged_count");
|
||||
|
||||
b.Property<double>("AcknowledgementRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("acknowledgement_rate");
|
||||
|
||||
b.Property<string>("AlertType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("alert_type");
|
||||
|
||||
b.Property<double>("AvgSecondsToAcknowledge")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("avg_seconds_to_acknowledge");
|
||||
|
||||
b.Property<double>("AvgSecondsToResolution")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("avg_seconds_to_resolution");
|
||||
|
||||
b.Property<DateTimeOffset>("ComputedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("computed_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<int>("EscalatedCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("escalated_count");
|
||||
|
||||
b.Property<double>("FalsePositiveRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("false_positive_rate");
|
||||
|
||||
b.Property<int>("FeedbackCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("feedback_count");
|
||||
|
||||
b.Property<int>("FeedbackFalsePositiveCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("feedback_false_positive_count");
|
||||
|
||||
b.Property<int>("FeedbackUsefulCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("feedback_useful_count");
|
||||
|
||||
b.Property<int>("FeedbackWouldActCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("feedback_would_act_count");
|
||||
|
||||
b.Property<int>("ResolvedCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("resolved_count");
|
||||
|
||||
b.Property<int>("TotalAlerts")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_alerts");
|
||||
|
||||
b.Property<double>("UsefulRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("useful_rate");
|
||||
|
||||
b.Property<DateTimeOffset>("WindowEnd")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("window_end");
|
||||
|
||||
b.Property<DateTimeOffset>("WindowStart")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("window_start");
|
||||
|
||||
b.Property<double>("WouldActRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("would_act_rate");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WindowStart");
|
||||
|
||||
b.HasIndex("AlertType", "WindowStart", "WindowEnd")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("alert_quality_metrics", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlertThreshold", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -117,6 +256,12 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("encounter_id");
|
||||
|
||||
b.Property<bool>("FeedbackReceived")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("feedback_received");
|
||||
|
||||
b.Property<string>("ObservationCode")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
@@ -1476,6 +1621,17 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlertFeedback", b =>
|
||||
{
|
||||
b.HasOne("ClinicalAlert", "Alert")
|
||||
.WithMany("Feedbacks")
|
||||
.HasForeignKey("AlertId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Alert");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.HasOne("Encounter", "Encounter")
|
||||
@@ -1668,6 +1824,11 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Navigation("Site");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalAlert", b =>
|
||||
{
|
||||
b.Navigation("Feedbacks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClinicalSite", b =>
|
||||
{
|
||||
b.Navigation("Gateways");
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
public record AlertQualityMetricResponse(
|
||||
Guid Id,
|
||||
string AlertType,
|
||||
DateTimeOffset WindowStart,
|
||||
DateTimeOffset WindowEnd,
|
||||
int TotalAlerts,
|
||||
int AcknowledgedCount,
|
||||
int ResolvedCount,
|
||||
int EscalatedCount,
|
||||
int FeedbackUsefulCount,
|
||||
int FeedbackFalsePositiveCount,
|
||||
int FeedbackWouldActCount,
|
||||
int FeedbackCount,
|
||||
double AcknowledgementRate,
|
||||
double FalsePositiveRate,
|
||||
double UsefulRate,
|
||||
double WouldActRate,
|
||||
double AvgSecondsToAcknowledge,
|
||||
double AvgSecondsToResolution,
|
||||
DateTimeOffset ComputedAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
public record AlertQualitySummaryResponse(
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd,
|
||||
int TotalAlerts,
|
||||
int TotalFeedback,
|
||||
double AcknowledgementRate,
|
||||
double FalsePositiveRate,
|
||||
double UsefulRate,
|
||||
double WouldActRate,
|
||||
double AvgSecondsToAcknowledge,
|
||||
double AvgSecondsToResolution);
|
||||
@@ -0,0 +1 @@
|
||||
public record SubmitAlertFeedbackRequest(AlertFeedbackType FeedbackType, string? Comment);
|
||||
@@ -132,35 +132,55 @@ public sealed class ClinicalMetrics
|
||||
Buckets = new[] { 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 }
|
||||
});
|
||||
|
||||
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||
// --- Gauges (set by background collectors, not incremented inline) ---
|
||||
|
||||
// The most clinically significant panel. A non-zero value means a patient's
|
||||
// critical alert has gone unacknowledged for more than 5 minutes.
|
||||
// In a real deployment this panel drives an on-call pager alert at the nurse station.
|
||||
public readonly Gauge AlertsUnacknowledgedGauge = Metrics.CreateGauge(
|
||||
"alerts_unacknowledged_gauge",
|
||||
"Count of open CRITICAL alerts older than 5 minutes with no acknowledgment.");
|
||||
// The most clinically significant panel. A non-zero value means a patient's
|
||||
// critical alert has gone unacknowledged for more than 5 minutes.
|
||||
// In a real deployment this panel drives an on-call pager alert at the nurse station.
|
||||
public readonly Gauge AlertsUnacknowledgedGauge = Metrics.CreateGauge(
|
||||
"alerts_unacknowledged_gauge",
|
||||
"Count of open CRITICAL alerts older than 5 minutes with no acknowledgment.");
|
||||
|
||||
// Per consumer group so the dashboard can show whether es-indexer, sepsis-engine,
|
||||
// or data-lake-writer is falling behind the observation stream.
|
||||
public readonly Gauge KafkaConsumerLag = Metrics.CreateGauge(
|
||||
"kafka_consumer_lag",
|
||||
"Approximate consumer group lag in messages, labeled by consumer group.",
|
||||
labelNames: new[] { "consumer_group" });
|
||||
// Per consumer group so the dashboard can show whether es-indexer, sepsis-engine,
|
||||
// or data-lake-writer is falling behind the observation stream.
|
||||
public readonly Gauge KafkaConsumerLag = Metrics.CreateGauge(
|
||||
"kafka_consumer_lag",
|
||||
"Approximate consumer group lag in messages, labeled by consumer group.",
|
||||
labelNames: new[] { "consumer_group" });
|
||||
|
||||
// An outbox that is growing means the relay is not keeping up or Kafka is unavailable.
|
||||
// In a patient safety system, a growing outbox delays alert delivery to all consumers.
|
||||
public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge(
|
||||
"outbox_pending_events",
|
||||
"Count of outbox events not yet relayed to Kafka.");
|
||||
// An outbox that is growing means the relay is not keeping up or Kafka is unavailable.
|
||||
// In a patient safety system, a growing outbox delays alert delivery to all consumers.
|
||||
public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge(
|
||||
"outbox_pending_events",
|
||||
"Count of outbox events not yet relayed to Kafka.");
|
||||
|
||||
public readonly Gauge WardGatewaysOffline = Metrics.CreateGauge(
|
||||
"ward_gateways_offline_gauge",
|
||||
"Ward gateways with status OFFLINE or DEGRADED",
|
||||
labelNames: new[] { "site_code" });
|
||||
public readonly Gauge WardGatewaysOffline = Metrics.CreateGauge(
|
||||
"ward_gateways_offline_gauge",
|
||||
"Ward gateways with status OFFLINE or DEGRADED",
|
||||
labelNames: new[] { "site_code" });
|
||||
|
||||
public readonly Gauge WardGatewayBufferDepth = Metrics.CreateGauge(
|
||||
"ward_gateway_buffer_depth",
|
||||
"Reported unsynced event count per gateway",
|
||||
labelNames: new[] { "gateway_code", "department" });
|
||||
public readonly Gauge WardGatewayBufferDepth = Metrics.CreateGauge(
|
||||
"ward_gateway_buffer_depth",
|
||||
"Reported unsynced event count per gateway",
|
||||
labelNames: new[] { "gateway_code", "department" });
|
||||
|
||||
public readonly Gauge AlertAcknowledgementRate = Metrics.CreateGauge(
|
||||
"vigilcare_alert_acknowledgement_rate",
|
||||
"Alert acknowledgement rate by type.",
|
||||
labelNames: new[] { "alert_type" });
|
||||
|
||||
public readonly Gauge AlertFalsePositiveRate = Metrics.CreateGauge(
|
||||
"vigilcare_alert_false_positive_rate",
|
||||
"Clinician-reported false positive rate by type.",
|
||||
labelNames: new[] { "alert_type" });
|
||||
|
||||
public readonly Gauge AlertUsefulRate = Metrics.CreateGauge(
|
||||
"vigilcare_alert_useful_rate",
|
||||
"Clinician-reported useful rate by type.",
|
||||
labelNames: new[] { "alert_type" });
|
||||
|
||||
public readonly Gauge AlertAvgAckSeconds = Metrics.CreateGauge(
|
||||
"vigilcare_alert_avg_ack_seconds",
|
||||
"Average seconds from trigger to acknowledgement by type.",
|
||||
labelNames: new[] { "alert_type" });
|
||||
}
|
||||
@@ -151,6 +151,9 @@ try
|
||||
builder.Services.Configure<GatewayMonitoringOptions>(
|
||||
builder.Configuration.GetSection(GatewayMonitoringOptions.Section));
|
||||
|
||||
builder.Services.Configure<AlertQualityOptions>(
|
||||
builder.Configuration.GetSection(AlertQualityOptions.Section));
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Dashboard", policy =>
|
||||
@@ -212,6 +215,7 @@ try
|
||||
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||
builder.Services.AddHostedService<GatewayStaleDetectorService>();
|
||||
builder.Services.AddScoped<IAlertQualityMetricsService, AlertQualityMetricsService>();
|
||||
|
||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
||||
@@ -238,6 +242,7 @@ try
|
||||
builder.Services.AddHostedService<SofaScoringService>();
|
||||
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
||||
builder.Services.AddHostedService<WardGatewayMetricsCollector>();
|
||||
builder.Services.AddHostedService<AlertQualityAggregatorService>();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class AlertQualityMetricsService : IAlertQualityMetricsService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AlertQualityMetricsService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<AlertQualityMetricResponse>> 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<AlertQualitySummaryResponse> 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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -163,6 +163,54 @@ public class AlertService : IAlertService
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async Task<AlertFeedback> SubmitFeedbackAsync(
|
||||
Guid alertId, AlertFeedbackType type, string? comment)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || _currentUser.UserId is null)
|
||||
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
|
||||
|
||||
var userId = _currentUser.UserId.Value;
|
||||
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(alertId);
|
||||
if (alert is null)
|
||||
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
|
||||
if (alert.Status == AlertStatus.Open)
|
||||
throw new ValidationException(
|
||||
"Feedback can only be submitted on acknowledged or resolved alerts.",
|
||||
"ALERT_NOT_REVIEWABLE");
|
||||
|
||||
var alreadySubmitted = await _db.AlertFeedbacks
|
||||
.AnyAsync(f => f.AlertId == alertId && f.UserId == userId);
|
||||
if (alreadySubmitted)
|
||||
throw new ConflictException(
|
||||
"You have already submitted feedback for this alert.",
|
||||
"FEEDBACK_ALREADY_SUBMITTED");
|
||||
|
||||
var feedback = new AlertFeedback
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AlertId = alertId,
|
||||
UserId = userId,
|
||||
FeedbackType = type,
|
||||
Comment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.AlertFeedbacks.Add(feedback);
|
||||
alert.FeedbackReceived = true;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.AlertFeedbackSubmitted,
|
||||
"ClinicalAlert",
|
||||
alert.Id,
|
||||
newValue: new { feedbackType = type.ToDbString(), feedback.UserId },
|
||||
reason: comment);
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
private async Task<int> ResolveSuppressionWindowMinutesAsync(
|
||||
AlertType alertType, int defaultWindowMinutes)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
public interface IAlertQualityMetricsService
|
||||
{
|
||||
Task<IReadOnlyList<AlertQualityMetricResponse>> ListAsync(
|
||||
AlertType? alertType, DateTimeOffset from, DateTimeOffset to);
|
||||
|
||||
Task<AlertQualitySummaryResponse> GetSummaryAsync(
|
||||
DateTimeOffset from, DateTimeOffset to);
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public interface IAlertService
|
||||
Task<ClinicalAlert> ResolveAsync(Guid id);
|
||||
Task ApplySyncedAcknowledgmentAsync(Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct);
|
||||
Task ApplySyncedResolutionAsync(Guid alertId, SyncedAlertResolution resolve, CancellationToken ct);
|
||||
Task<AlertFeedback> SubmitFeedbackAsync(Guid alertId, AlertFeedbackType type, string? comment);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class SubmitAlertFeedbackRequestValidator : AbstractValidator<SubmitAlertFeedbackRequest>
|
||||
{
|
||||
public SubmitAlertFeedbackRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.FeedbackType).IsInEnum();
|
||||
RuleFor(r => r.Comment).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
@@ -214,5 +214,9 @@
|
||||
"GatewayMonitoring": {
|
||||
"StaleThresholdMinutes": 10,
|
||||
"PollIntervalMinutes": 5
|
||||
},
|
||||
"AlertQuality": {
|
||||
"IntervalMinutes": 60,
|
||||
"WindowHours": 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user