feature: Live Capture (Track B)

This commit is contained in:
voltsrage
2026-06-27 04:23:51 +08:00
parent 01000a2489
commit 88e70b3dbe
30 changed files with 4547 additions and 23 deletions
@@ -0,0 +1,12 @@
public interface IAttestationService
{
/// <summary>
/// Validates that the user is a credentialed clinician and that the
/// password re-confirm matches their stored hash. Throws on failure.
/// </summary>
/// <param name="userId">The authenticated user's ID from JWT claims.</param>
/// <param name="clinicianAttestation">Must be true; false throws ValidationException.</param>
/// <param name="passwordConfirm">Raw password for re-confirmation.</param>
/// <returns>The validated User entity.</returns>
Task<User> ValidateAttestationAsync(Guid userId, bool clinicianAttestation, string passwordConfirm);
}
@@ -0,0 +1,20 @@
public interface ILiveCaptureService
{
/// <summary>
/// Records observations against an existing VigilCareClinical encounter.
/// Validates clinician attestation, creates a live_capture batch, promotes
/// synchronously, and returns live observation IDs with any critical alerts.
/// </summary>
Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId,
RecordObservationsRequest request,
Guid clinicianUserId);
/// <summary>
/// Opens a new encounter in VigilCareClinical and records initial vitals
/// in a single atomic operation. Used for outpatient workflows.
/// </summary>
Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request,
Guid clinicianUserId);
}
@@ -0,0 +1,481 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class LiveCaptureService : ILiveCaptureService
{
private readonly AppDbContext _db;
private readonly IAttestationService _attestation;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<LiveCaptureService> _logger;
// Redis key prefix for cached alert thresholds (same as VigilCareClinical)
private const string ThresholdCachePrefix = "threshold:";
public LiveCaptureService(
AppDbContext db,
IAttestationService attestation,
IConnectionMultiplexer redis,
ILogger<LiveCaptureService> logger)
{
_db = db;
_attestation = attestation;
_redis = redis;
_logger = logger;
}
public async Task<LiveCaptureResponse> RecordObservationsAsync(
Guid encounterId, RecordObservationsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation (role + password re-confirm)
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate encounter exists and is active in VigilCareClinical
var encounter = await _db.Encounters.FindAsync(encounterId);
if (encounter is null)
throw new NotFoundException(
"Encounter not found in VigilCareClinical.", "ENCOUNTER_NOT_FOUND");
if (encounter.Status != "active")
throw new ConflictException(
"Observations can only be recorded against active encounters.",
"ENCOUNTER_NOT_ACTIVE");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Execute synchronous promotion within a single transaction
return await PromoteSynchronouslyAsync(
encounter.Id, encounter.PatientId, clinician, request.Observations);
}
public async Task<LiveCaptureResponse> OpenEncounterWithVitalsAsync(
OpenEncounterWithVitalsRequest request, Guid clinicianUserId)
{
// 1. Validate attestation
var clinician = await _attestation.ValidateAttestationAsync(
clinicianUserId, request.ClinicianAttestation, request.PasswordConfirm);
// 2. Validate patient exists
var patient = await _db.Patients.FindAsync(request.PatientId);
if (patient is null)
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
// 3. Validate observations
if (request.Observations is null || request.Observations.Count == 0)
throw new ValidationException(
"At least one observation is required.", "EMPTY_OBSERVATIONS");
if (request.Observations.Count > 10)
throw new ValidationException(
"Maximum 10 observations per live capture request.",
"TOO_MANY_OBSERVATIONS");
foreach (var obs in request.Observations)
{
if (!PlausibilityValidator.IsPlausible(obs.ObservationCode, obs.Value, out var reason))
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
}
// 4. Check no duplicate active encounter for this patient
var existingActive = await _db.Encounters.AnyAsync(e =>
e.PatientId == request.PatientId &&
e.Status == "active");
if (existingActive)
throw new ConflictException(
"Patient already has an active encounter. Record observations against the existing encounter.",
"ACTIVE_ENCOUNTER_EXISTS");
if (!DepartmentExtensions.TryFromDbString(request.Department, out var department))
throw new ValidationException(
$"Invalid department '{request.Department}'. Must be a recognized hospital department.",
"INVALID_DEPARTMENT");
// 5. Create the encounter in VigilCareClinical
var encounter = new Encounter
{
Id = Guid.NewGuid(),
PatientId = request.PatientId,
Department = department,
RoomBed = request.RoomBed,
AdmissionReason = request.AdmissionReason,
Status = "active",
AdmissionDate = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
_db.Encounters.Add(encounter);
// 6. Promote observations synchronously
return await PromoteSynchronouslyAsync(
encounter.Id, request.PatientId, clinician, request.Observations);
}
/// <summary>
/// Core promotion logic shared by both endpoints. Creates the batch, draft
/// observations, live observations, evaluates critical thresholds, and writes
/// all audit and outbox events within a single database transaction.
/// </summary>
private async Task<LiveCaptureResponse> PromoteSynchronouslyAsync(
Guid encounterId, Guid patientId, User clinician,
List<LiveCaptureObservationRequest> observations)
{
var now = DateTimeOffset.UtcNow;
var batchId = Guid.NewGuid();
var observationResponses = new List<LiveCaptureObservationResponse>();
var criticalAlertCount = 0;
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
// --- Create the live_capture batch (already in terminal Promoted state) ---
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Promoted,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.LiveCapture,
PatientId = patientId,
DocumentRef = "live-capture", // No scanned document for Track B
DocumentSha256 = ComputeLiveCaptureHash(clinician.Id, encounterId, now),
EnableRetroactiveAlerts = false, // Not applicable — live capture always alerts
EnteredByUserId = clinician.Id,
VerifiedByUserId = clinician.Id, // Clinician attestation replaces verifier
ApprovedByUserId = clinician.Id,
ClinicianAttestation = true,
PromotedAt = now,
PromotionEncounterId = encounterId,
CreatedAt = now,
UpdatedAt = now
};
_db.DigitizationBatches.Add(batch);
// --- Write attestation and promotion events ---
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.LiveCaptureAttested,
ActorUserId = clinician.Id,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
clinicianId = clinician.Id,
clinicianName = clinician.FullName,
encounterId,
observationCount = observations.Count,
track = "LIVE_CAPTURE"
})
});
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Promoted,
ActorUserId = clinician.Id,
OccurredAt = now.AddMilliseconds(1),
MetadataJson = JsonSerializer.Serialize(new
{
promotionType = "synchronous_live_capture",
encounterId
})
});
// --- Process each observation ---
var cache = _redis.GetDatabase();
foreach (var obs in observations)
{
var draftObsId = Guid.NewGuid();
var liveObsId = Guid.NewGuid();
// Create draft observation (audit trail)
var draftObservation = new DraftObservation
{
Id = draftObsId,
BatchId = batchId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
CreatedAt = now
};
_db.DraftObservations.Add(draftObservation);
// Create live observation in VigilCareClinical tables
var liveObservation = new Observation
{
Id = liveObsId,
EncounterId = encounterId,
PatientId = patientId,
ObservationCode = obs.ObservationCode,
Value = obs.Value,
Unit = obs.Unit,
RecordedAt = obs.RecordedAt,
Note = obs.Note,
Source = "live_capture",
SourceDraftObservationId = draftObsId,
SourceBatchId = batchId,
CreatedAt = now
};
_db.Observations.Add(liveObservation);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "observation.recorded",
AggregateType = "Observation",
AggregateId = liveObsId,
PayloadJson = JsonSerializer.Serialize(new
{
observationId = liveObsId,
encounterId,
patientId,
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
recordedAt = obs.RecordedAt,
source = "live_capture",
batchId
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
// --- Synchronous critical value detection ---
var alert = await EvaluateCriticalThresholdAsync(
cache, liveObsId, encounterId, patientId,
obs.ObservationCode, obs.Value, obs.Unit, now);
if (alert is not null)
{
criticalAlertCount++;
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
new LiveCaptureCriticalAlert(
alert.Id,
alert.Severity,
alert.Message,
alert.ThresholdValue,
alert.ThresholdBound)));
}
else
{
observationResponses.Add(new LiveCaptureObservationResponse(
draftObsId, liveObsId,
obs.ObservationCode, obs.Value, obs.Unit, obs.RecordedAt,
null));
}
}
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Live capture batch {BatchId} promoted synchronously: " +
"{ObsCount} observations, {AlertCount} critical alerts, " +
"encounter {EncounterId}, clinician {ClinicianId}",
batchId, observations.Count, criticalAlertCount,
encounterId, clinician.Id);
return new LiveCaptureResponse(
batchId, encounterId, observationResponses,
criticalAlertCount, now);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// Evaluates a single observation against Redis-cached alert thresholds.
/// If the value breaches a CRITICAL bound, creates a ClinicalAlert row and
/// an outbox event within the current transaction (before SaveChanges).
///
/// Returns null if no critical threshold is breached.
/// Warning thresholds are handled asynchronously by the Kafka consumer —
/// the same split as VigilCareClinical Phase 2.
/// </summary>
private async Task<CriticalAlertResult?> EvaluateCriticalThresholdAsync(
StackExchange.Redis.IDatabase cache, Guid observationId, Guid encounterId, Guid patientId,
string observationCode, decimal value, string unit, DateTimeOffset now)
{
var threshold = await LoadThresholdAsync(cache, observationCode);
if (threshold is null)
return null;
var breach = GetCriticalBreach(value, threshold);
if (breach is null)
return null;
var (thresholdValue, thresholdBound) = breach.Value;
var details = BuildCriticalDetails(observationCode, value, unit, threshold, thresholdBound);
AlertType alertType;
try
{
alertType = AlertTypeExtensions.CriticalFor(observationCode);
}
catch (ArgumentOutOfRangeException)
{
_logger.LogWarning(
"No critical alert type configured for observation code {ObservationCode}",
observationCode);
return null;
}
var alertId = Guid.NewGuid();
var clinicalAlert = new ClinicalAlert
{
Id = alertId,
EncounterId = encounterId,
PatientId = patientId,
ObservationId = observationId,
ObservationCode = observationCode,
AlertType = alertType,
Severity = AlertSeverity.Critical,
Details = details,
Status = AlertStatus.Open,
TriggeredAt = now
};
_db.ClinicalAlerts.Add(clinicalAlert);
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "alert.generated",
AggregateType = "ClinicalAlert",
AggregateId = alertId,
PayloadJson = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
observationId,
observationCode,
alertType = alertType.ToDbString(),
severity = AlertSeverity.Critical.ToDbString(),
details,
triggeredValue = value,
thresholdValue,
thresholdBound,
source = "live_capture",
triggeredAt = now
}),
CreatedAt = now,
ProcessedAt = null,
RetryCount = 0
});
_logger.LogWarning(
"CRITICAL alert {AlertId} generated via live capture: " +
"{ObservationCode} = {Value} {Unit} ({ThresholdBound} = {ThresholdValue}), " +
"encounter {EncounterId}, patient {PatientId}",
alertId, observationCode, value, unit,
thresholdBound, thresholdValue, encounterId, patientId);
return new CriticalAlertResult(
alertId,
AlertSeverity.Critical.ToDbString(),
details,
thresholdValue,
thresholdBound);
}
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(
StackExchange.Redis.IDatabase cache, string observationCode)
{
var cacheKey = $"{ThresholdCachePrefix}{observationCode}";
var cached = await cache.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
var threshold = await _db.AlertThresholds
.AsNoTracking()
.FirstOrDefaultAsync(t => t.ObservationCode == observationCode);
if (threshold is null)
return null;
var entry = new ThresholdCacheEntry(
threshold.ObservationCode,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh);
await cache.StringSetAsync(
cacheKey,
JsonSerializer.Serialize(entry),
TimeSpan.FromMinutes(30));
return entry;
}
private static (decimal ThresholdValue, string ThresholdBound)? GetCriticalBreach(
decimal value, ThresholdCacheEntry threshold)
{
if (threshold.CriticalLow.HasValue && value < threshold.CriticalLow.Value)
return (threshold.CriticalLow.Value, "CRITICAL_LOW");
if (threshold.CriticalHigh.HasValue && value > threshold.CriticalHigh.Value)
return (threshold.CriticalHigh.Value, "CRITICAL_HIGH");
return null;
}
private static string BuildCriticalDetails(
string observationCode, decimal value, string unit,
ThresholdCacheEntry threshold, string thresholdBound)
{
if (thresholdBound == "CRITICAL_LOW")
{
return $"{observationCode} value {value} {unit} is below critical low " +
$"threshold of {threshold.CriticalLow} {unit}";
}
return $"{observationCode} value {value} {unit} is above critical high " +
$"threshold of {threshold.CriticalHigh} {unit}";
}
/// <summary>
/// Computes a deterministic hash for live capture batches (no scanned document).
/// Uses clinician ID, encounter ID, and timestamp to generate uniqueness.
/// </summary>
private static string ComputeLiveCaptureHash(Guid clinicianId, Guid encounterId, DateTimeOffset timestamp)
{
var input = $"{clinicianId}:{encounterId}:{timestamp:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}