Run initial test for Climate Resilience Verification Suite

Add first part of Alert Quality Analytics
This commit is contained in:
voltsrage
2026-06-24 03:01:00 +08:00
parent 032cd1d240
commit 185dc93fa1
50 changed files with 4116 additions and 55 deletions
@@ -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))
};
}
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" });
}
+5
View File
@@ -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);
}
}
+4
View File
@@ -214,5 +214,9 @@
"GatewayMonitoring": {
"StaleThresholdMinutes": 10,
"PollIntervalMinutes": 5
},
"AlertQuality": {
"IntervalMinutes": 60,
"WindowHours": 1
}
}