feature: Explainable Alerts
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class ExplainableAlertsTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
|
||||
public ExplainableAlertsTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync() =>
|
||||
await ExplainableAlertsTestHelper.ResetAsync(_fixture);
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task News2Alert_IncludesScoreContributors()
|
||||
{
|
||||
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
|
||||
_fixture, respRate: 28, spo2: 92, systolicBp: 95, heartRate: 110,
|
||||
tempC: 38.5m, supplementalO2: 1m);
|
||||
|
||||
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
|
||||
_fixture, encounterId, AlertType.News2Emergency);
|
||||
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
|
||||
|
||||
body.Explanation.Should().NotBeNull();
|
||||
body.Explanation!.ScoreContributors.Should().HaveCount(7);
|
||||
body.Explanation.ScoreContributors.Should().Contain(c =>
|
||||
c.Parameter == "Respiratory Rate" && c.Points > 0);
|
||||
body.Explanation.NarrativeSummary.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SofaAlert_IncludesOrganContributors()
|
||||
{
|
||||
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedSofaBaselineAsync(_fixture);
|
||||
await ExplainableAlertsTestHelper.AdvanceSofaDeltaAsync(_fixture, encounterId, patientId, delta: 2);
|
||||
|
||||
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
|
||||
_fixture, encounterId, AlertType.SofaSepsis);
|
||||
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
|
||||
|
||||
body.Explanation.Should().NotBeNull();
|
||||
body.Explanation!.ScoreContributors.Should().HaveCount(6);
|
||||
body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Respiratory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GcsAlert_IncludesEyeVerbalMotor()
|
||||
{
|
||||
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
|
||||
await ExplainableAlertsTestHelper.RecordGcsAsync(
|
||||
_fixture, encounterId, patientId, eye: 2, verbal: 2, motor: 3);
|
||||
|
||||
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
|
||||
_fixture, encounterId, AlertType.GcsCritical);
|
||||
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
|
||||
|
||||
body.Explanation.Should().NotBeNull();
|
||||
body.Explanation!.ScoreContributors.Should().HaveCount(3);
|
||||
body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Eye");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrendAlert_IncludesTrendContext()
|
||||
{
|
||||
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
|
||||
await ExplainableAlertsTestHelper.SimulateRapidRespRateRiseAsync(
|
||||
_fixture, encounterId, patientId);
|
||||
|
||||
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
|
||||
_fixture, encounterId, AlertType.RapidDeterioration);
|
||||
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
|
||||
|
||||
body.Explanation.Should().NotBeNull();
|
||||
body.Explanation!.Trend.Should().NotBeNull();
|
||||
body.Explanation.Trend!.PercentChange.Should().BeGreaterThan(0);
|
||||
body.Explanation.Trend.Duration.Should().NotBe(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationAlert_IncludesDrugContext()
|
||||
{
|
||||
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
|
||||
await ExplainableAlertsTestHelper.AdministerMedicationAsync(
|
||||
_fixture, encounterId, "acetaminophen", dose: 1000, unit: "mg");
|
||||
await ExplainableAlertsTestHelper.FeedNews2VitalsAsync(
|
||||
_fixture, encounterId, patientId,
|
||||
respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
|
||||
tempC: 39.2m, supplementalO2: 0m);
|
||||
|
||||
var alert = await ExplainableAlertsTestHelper.WaitForFirstAlertWithExplanationAsync(
|
||||
_fixture, encounterId);
|
||||
|
||||
alert.Explanation.Should().NotBeNull();
|
||||
alert.Explanation!.MedicationContext.Should().NotBeNull();
|
||||
alert.Explanation.MedicationContext!.DrugName.Should().BeEquivalentTo("acetaminophen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NarrativeSummary_IsHumanReadable()
|
||||
{
|
||||
var alert = await ExplainableAlertsTestHelper.GetAnyAlertWithExplanationAsync(_fixture);
|
||||
|
||||
alert.Explanation.Should().NotBeNull();
|
||||
alert.Explanation!.NarrativeSummary.Should().MatchRegex("(NEWS2|SOFA|GCS|rapid deterioration)", "i");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyAlert_NullExplanation_Serializes()
|
||||
{
|
||||
var legacyId = await ExplainableAlertsTestHelper.SeedLegacyAlertWithoutExplanationAsync(_fixture);
|
||||
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, legacyId);
|
||||
|
||||
body.Explanation.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByEncounter_ReturnsExplanation()
|
||||
{
|
||||
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
|
||||
_fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
|
||||
tempC: 37.0m, supplementalO2: 0m);
|
||||
|
||||
using var client = _fixture.CreateClient();
|
||||
var response = await client.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<ApiResponse<PagedResult<AlertResponse>>>(
|
||||
ExplainableAlertsTestHelper.JsonOptions);
|
||||
|
||||
body!.Data!.Items.Should().NotBeEmpty();
|
||||
body.Data.Items.Should().AllSatisfy(a =>
|
||||
{
|
||||
a.Explanation.Should().NotBeNull();
|
||||
a.Explanation!.NarrativeSummary.Should().NotBeNullOrWhiteSpace();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScoringOutputs_Unchanged()
|
||||
{
|
||||
var before = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture);
|
||||
var after = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture);
|
||||
|
||||
after.TotalScore.Should().Be(before.TotalScore);
|
||||
after.AlertType.Should().Be(before.AlertType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainableAlert_OutboxPayload_IncludesExplanation()
|
||||
{
|
||||
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
|
||||
_fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
|
||||
tempC: 37.0m, supplementalO2: 0m);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var outbox = await db.OutboxEvents
|
||||
.Where(e => e.Topic == "alert.generated" && e.PartitionKey == encounterId.ToString())
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstAsync();
|
||||
|
||||
outbox.Payload.Should().Contain("explanation");
|
||||
outbox.Payload.Should().Contain("narrativeSummary");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user