diff --git a/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs b/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs new file mode 100644 index 0000000..0019b56 --- /dev/null +++ b/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +/// +/// Polls for uploaded batches without OCR results and pre-fills draft fields. +/// OCR is opt-in and non-blocking: failures leave the batch in UPLOADED status +/// for manual entry. +/// +public class OcrProcessingService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly OcrOptions _options; + private readonly ILogger _logger; + + public OcrProcessingService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "OcrProcessingService started. Provider: {Provider}, poll interval: {PollInterval}s, confidence threshold: {Threshold}", + _options.Provider, + _options.PollIntervalSeconds, + _options.ConfidenceThreshold); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessPendingBatchesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "OCR processing cycle failed"); + } + + await Task.Delay( + TimeSpan.FromSeconds(_options.PollIntervalSeconds), stoppingToken); + } + + _logger.LogInformation("OcrProcessingService stopped"); + } + + private async Task ProcessPendingBatchesAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ocr = scope.ServiceProvider.GetRequiredService(); + var storage = scope.ServiceProvider.GetRequiredService(); + var preFiller = scope.ServiceProvider.GetRequiredService(); + + var pendingBatches = await db.DigitizationBatches + .AsNoTracking() + .Where(b => b.Status == BatchStatus.Uploaded) + .Where(b => !db.OcrResults.Any(o => o.BatchId == b.Id)) + .Where(b => !db.DigitizationEvents.Any(e => + e.BatchId == b.Id && + (e.EventType == DigitizationEventType.OcrCompleted || + e.EventType == DigitizationEventType.OcrFailed))) + .OrderBy(b => b.CreatedAt) + .Take(5) + .ToListAsync(ct); + + foreach (var batch in pendingBatches) + { + await ProcessBatchAsync(db, ocr, storage, preFiller, batch, ct); + } + } + + private async Task ProcessBatchAsync( + AppDbContext db, + IOcrService ocr, + IDocumentStorageService storage, + OcrDraftPreFiller preFiller, + DigitizationBatch batch, + CancellationToken ct) + { + var currentStatus = await db.DigitizationBatches + .AsNoTracking() + .Where(b => b.Id == batch.Id) + .Select(b => b.Status) + .FirstOrDefaultAsync(ct); + + if (currentStatus != BatchStatus.Uploaded) + { + _logger.LogDebug( + "Skipping OCR for batch {BatchId}: status is {Status}", + batch.Id, currentStatus.ToDbString()); + return; + } + + var actorUserId = await db.DigitizationEvents + .AsNoTracking() + .Where(e => e.BatchId == batch.Id && + (e.EventType == DigitizationEventType.Uploaded || + e.EventType == DigitizationEventType.CorrectionUploaded)) + .OrderBy(e => e.OccurredAt) + .Select(e => e.ActorUserId) + .FirstOrDefaultAsync(ct); + + if (actorUserId == Guid.Empty) + { + _logger.LogWarning( + "Skipping OCR for batch {BatchId}: no upload event found", + batch.Id); + return; + } + + var document = await db.ScannedDocuments + .AsNoTracking() + .FirstOrDefaultAsync(d => d.BatchId == batch.Id, ct); + + if (document is null) + { + _logger.LogWarning( + "Skipping OCR for batch {BatchId}: scanned document metadata not found", + batch.Id); + return; + } + + var startedAt = DateTimeOffset.UtcNow; + db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + EventType = DigitizationEventType.OcrStarted, + ActorUserId = actorUserId, + OccurredAt = startedAt, + MetadataJson = JsonSerializer.Serialize(new + { + provider = _options.Provider + }) + }); + await db.SaveChangesAsync(ct); + + try + { + await using var documentStream = await storage.DownloadAsync(document.ObjectKey); + var extraction = await ocr.ExtractAsync(documentStream, document.ContentType); + + await preFiller.PreFillAsync(batch.Id, batch.BatchType, extraction); + + var fieldConfidences = extraction.Fields + .GroupBy(f => f.FieldName, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Confidence, StringComparer.Ordinal); + + var processedAt = DateTimeOffset.UtcNow; + db.OcrResults.Add(new OcrResult + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + Provider = _options.Provider, + FieldConfidencesJson = JsonSerializer.Serialize(fieldConfidences), + RawText = extraction.RawText, + DurationMs = extraction.DurationMs, + ProcessedAt = processedAt + }); + + db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + EventType = DigitizationEventType.OcrCompleted, + ActorUserId = actorUserId, + OccurredAt = processedAt, + MetadataJson = JsonSerializer.Serialize(new + { + provider = _options.Provider, + durationMs = extraction.DurationMs, + fieldCount = extraction.Fields.Count, + confidentFieldCount = fieldConfidences.Count(kv => kv.Value >= _options.ConfidenceThreshold) + }) + }); + + await db.SaveChangesAsync(ct); + + _logger.LogInformation( + "OCR completed for batch {BatchId}: provider={Provider}, fields={FieldCount}, duration={DurationMs}ms", + batch.Id, _options.Provider, extraction.Fields.Count, extraction.DurationMs); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = batch.Id, + EventType = DigitizationEventType.OcrFailed, + ActorUserId = actorUserId, + OccurredAt = DateTimeOffset.UtcNow, + MetadataJson = JsonSerializer.Serialize(new + { + provider = _options.Provider, + error = ex.Message + }) + }); + await db.SaveChangesAsync(ct); + + _logger.LogWarning( + ex, + "OCR failed for batch {BatchId}: {ErrorMessage}", + batch.Id, ex.Message); + } + } +} diff --git a/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs b/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs new file mode 100644 index 0000000..6752f8a --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/AzureOcrOptions.cs @@ -0,0 +1,5 @@ +public class AzureOcrOptions +{ + public string Endpoint { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} diff --git a/VigilCareRecordsAPI/Configurations/OcrOptions.cs b/VigilCareRecordsAPI/Configurations/OcrOptions.cs new file mode 100644 index 0000000..716ff8b --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/OcrOptions.cs @@ -0,0 +1,12 @@ +public class OcrOptions +{ + public const string Section = "Ocr"; + + public bool Enabled { get; set; } = false; + public string Provider { get; set; } = "azure"; // "azure" or "tesseract" + public double ConfidenceThreshold { get; set; } = 0.7; + public int PollIntervalSeconds { get; set; } = 15; + + public AzureOcrOptions Azure { get; set; } = new(); + public TesseractOcrOptions Tesseract { get; set; } = new(); +} diff --git a/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs b/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs new file mode 100644 index 0000000..cc6affd --- /dev/null +++ b/VigilCareRecordsAPI/Configurations/TesseractOcrOptions.cs @@ -0,0 +1,5 @@ +public class TesseractOcrOptions +{ + public string DataPath { get; set; } = "/usr/share/tessdata"; + public string Language { get; set; } = "eng"; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs index 4d2503c..b69a45c 100644 --- a/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirEncounterController.cs @@ -25,7 +25,7 @@ public class FhirEncounterController : ControllerBase } /// - /// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z + /// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z /// Supports search by patient reference, status, and date range. /// [HttpGet] diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs index 4f89989..5eaf343 100644 --- a/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirObservationController.cs @@ -25,7 +25,7 @@ public class FhirObservationController : ControllerBase } /// - /// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W + /// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W /// Supports search by patient reference, LOINC code, date range, and category. /// [HttpGet] diff --git a/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs index 7e0367f..6636d0e 100644 --- a/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs +++ b/VigilCareRecordsAPI/Controllers/Fhir/FhirPatientController.cs @@ -29,7 +29,7 @@ public class FhirPatientController : ControllerBase } /// - /// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z + /// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z /// Supports search by name (contains), birthdate (exact), and MRN identifier. /// Returns a FHIR Bundle of type searchset. /// diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs index a638ba3..c23db1e 100644 --- a/VigilCareRecordsAPI/Data/AppDbContext.cs +++ b/VigilCareRecordsAPI/Data/AppDbContext.cs @@ -24,6 +24,7 @@ public class AppDbContext : DbContext public DbSet LiveObservations => Set(); public DbSet PromotionAttempts => Set(); public DbSet CoverSheets => Set(); + public DbSet OcrResults => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs index 8588a9d..a297c5d 100644 --- a/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs +++ b/VigilCareRecordsAPI/Data/Configurations/DigitizationEventConfiguration.cs @@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration { t.HasCheckConstraint("chk_digitization_events_event_type", - "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')"); }); builder.HasKey(e => e.Id); builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); diff --git a/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs new file mode 100644 index 0000000..485ca05 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Configurations/OcrResultConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class OcrResultConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ocr_results"); + builder.HasKey(o => o.Id); + builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + builder.Property(o => o.BatchId).HasColumnName("batch_id").IsRequired(); + builder.Property(o => o.Provider).HasColumnName("provider").HasMaxLength(20).IsRequired(); + builder.Property(o => o.FieldConfidencesJson).HasColumnName("field_confidences_json").HasColumnType("jsonb").IsRequired(); + builder.Property(o => o.RawText).HasColumnName("raw_text"); + builder.Property(o => o.DurationMs).HasColumnName("duration_ms").IsRequired(); + builder.Property(o => o.ProcessedAt).HasColumnName("processed_at").HasDefaultValueSql("NOW()"); + + builder.HasIndex(o => o.BatchId).IsUnique(); + builder.HasOne(o => o.Batch) + .WithOne() + .HasForeignKey(o => o.BatchId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs new file mode 100644 index 0000000..36d4fd4 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.Designer.cs @@ -0,0 +1,1628 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260627170141_AddOcrResult")] + partial class AddOcrResult + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", "clinical"); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "OccurredAt"); + + b.ToTable("auth_audit_events", null, t => + { + t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')"); + }); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("ClientAlertId") + .HasColumnType("uuid") + .HasColumnName("client_alert_id"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("FeedbackReceived") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("feedback_received"); + + b.Property("ObservationCode") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("SyncedFromGateway") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("synced_from_gateway"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ClientAlertId") + .IsUnique() + .HasFilter("client_alert_id IS NOT NULL"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.HasIndex("EncounterId", "AlertType", "ObservationCode") + .HasFilter("status IN ('OPEN', 'ESCALATED')"); + + b.ToTable("clinical_alerts", "clinical", t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("CoverSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignToUserId") + .HasColumnType("uuid") + .HasColumnName("assign_to_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("GeneratedByUserId") + .HasColumnType("uuid") + .HasColumnName("generated_by_user_id"); + + b.Property("IsUsed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_used"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("Track") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.HasKey("Id"); + + b.HasIndex("AssignToUserId"); + + b.HasIndex("BatchId"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_cover_sheets_code"); + + b.HasIndex("GeneratedByUserId"); + + b.HasIndex("PatientId"); + + b.HasIndex("IsUsed", "CreatedAt") + .HasDatabaseName("ix_cover_sheets_unused") + .HasFilter("is_used = false"); + + b.ToTable("cover_sheets", (string)null); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BatchType") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("batch_type"); + + b.Property("ClinicianAttestation") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("clinician_attestation"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DocumentRef") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("document_ref"); + + b.Property("DocumentSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("document_sha256"); + + b.Property("EnableRetroactiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("enable_retroactive_alerts"); + + b.Property("EncounterDraftId") + .HasColumnType("uuid") + .HasColumnName("encounter_draft_id"); + + b.Property("EnteredByUserId") + .HasColumnType("uuid") + .HasColumnName("entered_by_user_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("PromotedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("promoted_at"); + + b.Property("PromotionEncounterId") + .HasColumnType("uuid") + .HasColumnName("promotion_encounter_id"); + + b.Property("RejectionReason") + .HasColumnType("text") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status") + .HasDefaultValueSql("'UPLOADED'"); + + b.Property("SupersedesBatchId") + .HasColumnType("uuid") + .HasColumnName("supersedes_batch_id"); + + b.Property("Track") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("track") + .HasDefaultValueSql("'BACKFILL'"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("VerifiedByUserId") + .HasColumnType("uuid") + .HasColumnName("verified_by_user_id"); + + b.HasKey("Id"); + + b.HasIndex("ApprovedByUserId"); + + b.HasIndex("EnteredByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SupersedesBatchId") + .HasFilter("supersedes_batch_id IS NOT NULL"); + + b.HasIndex("VerifiedByUserId"); + + b.HasIndex("DocumentSha256", "PatientId", "CreatedAt"); + + b.ToTable("digitization_batches", null, t => + { + t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')"); + + t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED', 'CANCELLED')"); + + t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')"); + }); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("event_type"); + + b.Property("MetadataJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_json"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BatchId", "OccurredAt"); + + b.ToTable("digitization_events", null, t => + { + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')"); + }); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_encounters", null, t => + { + t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("BatchId", "ObservationCode"); + + b.ToTable("draft_observations", (string)null); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("MedicationsJson") + .HasColumnType("jsonb") + .HasColumnName("medications_json"); + + b.Property("NoActiveMedications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_active_medications"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("draft_patients", null, t => + { + t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status") + .HasDatabaseName("ix_encounters_patient_status"); + + b.ToTable("encounters", "clinical", t => + { + t.HasCheckConstraint("chk_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("http_status_code"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("OperationName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("operation_name"); + + b.Property("ResourceId") + .HasColumnType("uuid") + .HasColumnName("resource_id"); + + b.Property("ResponseBodyJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("response_body_json"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_idempotency_records_expires_at"); + + b.HasIndex("IdempotencyKey", "OperationName") + .IsUnique() + .HasDatabaseName("ix_idempotency_records_key_operation"); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("admission_date"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "Status"); + + b.ToTable("live_encounters", null, t => + { + t.HasCheckConstraint("chk_live_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')"); + }); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsSuperseded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_superseded"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SupersededAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("superseded_at"); + + b.Property("SupersededByBatchId") + .HasColumnType("uuid") + .HasColumnName("superseded_by_batch_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IsSuperseded") + .HasFilter("is_superseded = true"); + + b.HasIndex("SourceBatchId"); + + b.HasIndex("EncounterId", "ObservationCode"); + + b.ToTable("live_observations", (string)null); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("note"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("source"); + + b.Property("SourceBatchId") + .HasColumnType("uuid") + .HasColumnName("source_batch_id"); + + b.Property("SourceDraftObservationId") + .HasColumnType("uuid") + .HasColumnName("source_draft_observation_id"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("PatientId"); + + b.HasIndex("SourceBatchId") + .HasDatabaseName("ix_observations_source_batch") + .HasFilter("source_batch_id IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode") + .HasDatabaseName("ix_observations_encounter_code"); + + b.ToTable("observations", "clinical"); + }); + + modelBuilder.Entity("OcrResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("FieldConfidencesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_confidences_json"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("provider"); + + b.Property("RawText") + .HasColumnType("text") + .HasColumnName("raw_text"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("ocr_results", (string)null); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AggregateId") + .HasColumnType("uuid") + .HasColumnName("aggregate_id"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("aggregate_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload_json"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("retry_count"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt") + .HasDatabaseName("ix_outbox_events_unprocessed") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", "clinical"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AllergiesJson") + .HasColumnType("jsonb") + .HasColumnName("allergies_json"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContact") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("emergency_contact"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("NoKnownAllergies") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("no_known_allergies"); + + b.Property("Sex") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("sex"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique() + .HasDatabaseName("ix_patients_mrn"); + + b.ToTable("patients", "clinical", t => + { + t.HasCheckConstraint("chk_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')"); + }); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttemptNumber") + .HasColumnType("integer") + .HasColumnName("attempt_number"); + + b.Property("AttemptedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("attempted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at"); + + b.Property("Succeeded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("succeeded"); + + b.HasKey("Id"); + + b.HasIndex("NextRetryAt") + .HasDatabaseName("ix_promotion_attempts_pending_retry") + .HasFilter("succeeded = false AND next_retry_at IS NOT NULL"); + + b.HasIndex("BatchId", "AttemptNumber") + .IsUnique() + .HasDatabaseName("ix_promotion_attempts_batch_attempt"); + + b.ToTable("promotion_attempts", (string)null); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_token_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("object_key"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Sha256"); + + b.ToTable("scanned_documents", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("full_name"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')"); + }); + }); + + modelBuilder.Entity("AuthAuditEvent", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CoverSheet", b => + { + b.HasOne("User", "AssignToUser") + .WithMany() + .HasForeignKey("AssignToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "GeneratedByUser") + .WithMany() + .HasForeignKey("GeneratedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AssignToUser"); + + b.Navigation("Batch"); + + b.Navigation("GeneratedByUser"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.HasOne("User", "ApprovedByUser") + .WithMany() + .HasForeignKey("ApprovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "EnteredByUser") + .WithMany() + .HasForeignKey("EnteredByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("User", "VerifiedByUser") + .WithMany() + .HasForeignKey("VerifiedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovedByUser"); + + b.Navigation("EnteredByUser"); + + b.Navigation("VerifiedByUser"); + }); + + modelBuilder.Entity("DigitizationEvent", b => + { + b.HasOne("User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DigitizationBatch", "Batch") + .WithMany("Events") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftEncounter", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftEncounter") + .HasForeignKey("DraftEncounter", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftObservation", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany("DraftObservations") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DraftPatient", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("DraftPatient") + .HasForeignKey("DraftPatient", "BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.HasOne("Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LiveObservation", b => + { + b.HasOne("LiveEncounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("OcrResult", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne() + .HasForeignKey("OcrResult", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("PromotionAttempt", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("RefreshToken", b => + { + b.HasOne("RefreshToken", "ReplacedByToken") + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplacedByToken"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ScannedDocument", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne("Document") + .HasForeignKey("ScannedDocument", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("DigitizationBatch", b => + { + b.Navigation("Document"); + + b.Navigation("DraftEncounter"); + + b.Navigation("DraftObservations"); + + b.Navigation("DraftPatient"); + + b.Navigation("Events"); + }); + + modelBuilder.Entity("LiveEncounter", b => + { + b.Navigation("Observations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs new file mode 100644 index 0000000..f2adaf2 --- /dev/null +++ b/VigilCareRecordsAPI/Data/Migrations/20260627170141_AddOcrResult.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareRecordsAPI.Data.Migrations +{ + /// + public partial class AddOcrResult : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events"); + + migrationBuilder.CreateTable( + name: "ocr_results", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + batch_id = table.Column(type: "uuid", nullable: false), + provider = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + field_confidences_json = table.Column(type: "jsonb", nullable: false), + raw_text = table.Column(type: "text", nullable: true), + duration_ms = table.Column(type: "integer", nullable: false), + processed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_ocr_results", x => x.id); + table.ForeignKey( + name: "FK_ocr_results_digitization_batches_batch_id", + column: x => x.batch_id, + principalTable: "digitization_batches", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.AddCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events", + sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')"); + + migrationBuilder.CreateIndex( + name: "IX_ocr_results_batch_id", + table: "ocr_results", + column: "batch_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ocr_results"); + + migrationBuilder.DropCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events"); + + migrationBuilder.AddCheckConstraint( + name: "chk_digitization_events_event_type", + table: "digitization_events", + sql: "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + } + } +} diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs index 0292423..99c8b76 100644 --- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs @@ -476,7 +476,7 @@ namespace VigilCareRecordsAPI.Data.Migrations b.ToTable("digitization_events", null, t => { - t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')"); + t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')"); }); }); @@ -1007,6 +1007,51 @@ namespace VigilCareRecordsAPI.Data.Migrations b.ToTable("observations", "clinical"); }); + modelBuilder.Entity("OcrResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("DurationMs") + .HasColumnType("integer") + .HasColumnName("duration_ms"); + + b.Property("FieldConfidencesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("field_confidences_json"); + + b.Property("ProcessedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("provider"); + + b.Property("RawText") + .HasColumnType("text") + .HasColumnName("raw_text"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.ToTable("ocr_results", (string)null); + }); + modelBuilder.Entity("OutboxEvent", b => { b.Property("Id") @@ -1506,6 +1551,17 @@ namespace VigilCareRecordsAPI.Data.Migrations b.Navigation("Patient"); }); + modelBuilder.Entity("OcrResult", b => + { + b.HasOne("DigitizationBatch", "Batch") + .WithOne() + .HasForeignKey("OcrResult", "BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + modelBuilder.Entity("PromotionAttempt", b => { b.HasOne("DigitizationBatch", "Batch") diff --git a/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs b/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs new file mode 100644 index 0000000..3c28609 --- /dev/null +++ b/VigilCareRecordsAPI/Domain/Entities/OcrResult.cs @@ -0,0 +1,12 @@ +public class OcrResult +{ + public Guid Id { get; set; } + public Guid BatchId { get; set; } + public string Provider { get; set; } = null!; // "azure" or "tesseract" + public string FieldConfidencesJson { get; set; } = null!; // serialized Dictionary + public string? RawText { get; set; } + public int DurationMs { get; set; } + public DateTimeOffset ProcessedAt { get; set; } + + public DigitizationBatch Batch { get; set; } = null!; +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs index efb1070..3c21f37 100644 --- a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs +++ b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs @@ -22,7 +22,10 @@ public enum DigitizationEventType DocumentAccessed, Cancelled, DraftFieldUpdated, - DraftObservationDeleted + DraftObservationDeleted, + OcrStarted, + OcrCompleted, + OcrFailed } public static class DigitizationEventTypeExtensions @@ -52,6 +55,9 @@ public static class DigitizationEventTypeExtensions DigitizationEventType.Cancelled => "cancelled", DigitizationEventType.DraftFieldUpdated => "draft_field_updated", DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted", + DigitizationEventType.OcrStarted => "ocr_started", + DigitizationEventType.OcrCompleted => "ocr_completed", + DigitizationEventType.OcrFailed => "ocr_failed", _ => throw new ArgumentOutOfRangeException(nameof(t)) }; @@ -80,6 +86,9 @@ public static class DigitizationEventTypeExtensions "cancelled" => DigitizationEventType.Cancelled, "draft_field_updated" => DigitizationEventType.DraftFieldUpdated, "draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted, + "ocr_started" => DigitizationEventType.OcrStarted, + "ocr_completed" => DigitizationEventType.OcrCompleted, + "ocr_failed" => DigitizationEventType.OcrFailed, _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'") }; } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs index 919415d..fa19f74 100644 --- a/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs +++ b/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs @@ -2,7 +2,8 @@ public record DraftPayloadResponse( Guid BatchId, string Status, string BatchType, - BatchTypeFieldRequirements FieldRequirements, + BatchTypeFieldRequirements FieldRequirements, + OcrConfidenceMap? OcrConfidence, DraftPatientDto? Patient, DraftEncounterDto? Encounter, List Observations diff --git a/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs b/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs new file mode 100644 index 0000000..f9aeec3 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Batch/OcrConfidenceMap.cs @@ -0,0 +1,6 @@ +public record OcrConfidenceMap( + string Provider, + DateTimeOffset ProcessedAt, + int DurationMs, + Dictionary FieldConfidences +); diff --git a/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs new file mode 100644 index 0000000..afaa556 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractedField.cs @@ -0,0 +1,5 @@ +public record OcrExtractedField( + string FieldName, // e.g. "patient.fullName", "observation.HEART_RATE.value" + string RawValue, // raw text as extracted + double Confidence // 0.0 – 1.0 +); \ No newline at end of file diff --git a/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs new file mode 100644 index 0000000..9cbd694 --- /dev/null +++ b/VigilCareRecordsAPI/Models/Records/Ocr/OcrExtractionResult.cs @@ -0,0 +1,5 @@ +public record OcrExtractionResult( + List Fields, + string RawText, + int DurationMs +); diff --git a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs index b9006ff..146e921 100644 --- a/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs +++ b/VigilCareRecordsAPI/Models/Records/WorkQueue/WorkQueueOverviewResponse.cs @@ -2,29 +2,30 @@ /// Aggregate work queue health metrics for the supervisor dashboard. /// Returned by GET /api/v1/work-queue/overview. /// -public record WorkQueueOverviewResponse( +public record WorkQueueOverviewResponse +{ /// /// Count of batches per status. Key is the DB status string /// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION"). /// All 8 statuses are always present, even if count is 0. /// - Dictionary StatusCounts, + public required Dictionary StatusCounts { get; init; } /// /// Average time in minutes that batches currently in PendingVerification /// have been waiting. Zero if no batches are pending. /// - double AverageTimeInQueueMinutes, + public required double AverageTimeInQueueMinutes { get; init; } /// /// Rejection rate as a decimal (0.0 to 1.0). Calculated as /// rejections / (rejections + verifications) over the last 24 hours. /// - double RejectRate, + public required double RejectRate { get; init; } /// /// Age in minutes of the oldest batch in PendingVerification status. /// Zero if no batches are pending. /// - double OldestPendingVerificationMinutes -); \ No newline at end of file + public required double OldestPendingVerificationMinutes { get; init; } +} diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 688a2f1..be6d931 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -53,6 +53,23 @@ try builder.Services.Configure(builder.Configuration.GetSection(FhirOptions.Section)); + builder.Services.Configure(builder.Configuration.GetSection(OcrOptions.Section)); + + var ocrOptions = builder.Configuration.GetSection(OcrOptions.Section).Get(); + + if (ocrOptions?.Enabled == true) + { + builder.Services.AddSingleton(); + + if (ocrOptions.Provider == "azure") + builder.Services.AddScoped(); + else + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + } + // JWT Authentication var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; diff --git a/VigilCareRecordsAPI/Services/DocumentStorageService.cs b/VigilCareRecordsAPI/Services/DocumentStorageService.cs index 48e3727..53a5023 100644 --- a/VigilCareRecordsAPI/Services/DocumentStorageService.cs +++ b/VigilCareRecordsAPI/Services/DocumentStorageService.cs @@ -58,6 +58,18 @@ public class DocumentStorageService : IDocumentStorageService .WithExpiry(_options.PresignedUrlExpiryMinutes * 60)); } + public async Task DownloadAsync(string objectKey) + { + var memStream = new MemoryStream(); + await _minio.GetObjectAsync(new GetObjectArgs() + .WithBucket(_options.BucketName) + .WithObject(objectKey) + .WithCallbackStream(stream => stream.CopyTo(memStream))); + + memStream.Position = 0; + return memStream; + } + private async Task EnsureBucketAsync() { var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName)); diff --git a/VigilCareRecordsAPI/Services/DraftService.cs b/VigilCareRecordsAPI/Services/DraftService.cs index 61fafd5..551707b 100644 --- a/VigilCareRecordsAPI/Services/DraftService.cs +++ b/VigilCareRecordsAPI/Services/DraftService.cs @@ -31,11 +31,29 @@ public class DraftService : IDraftService if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); + var ocrResult = await _db.OcrResults + .AsNoTracking() + .FirstOrDefaultAsync(o => o.BatchId == batchId); + + OcrConfidenceMap? ocrConfidence = null; + if (ocrResult is not null) + { + var fieldConfidences = JsonSerializer.Deserialize>( + ocrResult.FieldConfidencesJson) ?? new Dictionary(); + + ocrConfidence = new OcrConfidenceMap( + ocrResult.Provider, + ocrResult.ProcessedAt, + ocrResult.DurationMs, + fieldConfidences); + } + return new DraftPayloadResponse( batch.Id, batch.Status.ToDbString(), batch.BatchType.ToDbString(), BatchTypeFieldRequirements.ForBatchType(batch.BatchType), + ocrConfidence, batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null, batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null, batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList() diff --git a/VigilCareRecordsAPI/Services/IdempotencyService.cs b/VigilCareRecordsAPI/Services/IdempotencyService.cs index 1eb7c46..4971e02 100644 --- a/VigilCareRecordsAPI/Services/IdempotencyService.cs +++ b/VigilCareRecordsAPI/Services/IdempotencyService.cs @@ -22,7 +22,7 @@ public class IdempotencyService : IIdempotencyService return record; } - public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId, + public Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId, int httpStatusCode, object responseBody, TimeSpan? ttl = null) { var effectiveTtl = ttl ?? DefaultTtl; @@ -46,5 +46,6 @@ public class IdempotencyService : IIdempotencyService _db.IdempotencyRecords.Add(record); // SaveChanges is called by the caller (within the same transaction) + return Task.CompletedTask; } } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs b/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs index fda9525..f25500d 100644 --- a/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs +++ b/VigilCareRecordsAPI/Services/Interfaces/IDocumentStorageService.cs @@ -2,4 +2,5 @@ public interface IDocumentStorageService { Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId); Task GetPresignedUrlAsync(string objectKey); + Task DownloadAsync(string objectKey); } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Interfaces/IOcrService.cs b/VigilCareRecordsAPI/Services/Interfaces/IOcrService.cs new file mode 100644 index 0000000..c094017 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Interfaces/IOcrService.cs @@ -0,0 +1,4 @@ +public interface IOcrService +{ + Task ExtractAsync(Stream documentStream, string contentType); +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Ocr/AzureDocumentOcrService.cs b/VigilCareRecordsAPI/Services/Ocr/AzureDocumentOcrService.cs new file mode 100644 index 0000000..4d1c135 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Ocr/AzureDocumentOcrService.cs @@ -0,0 +1,264 @@ +using Azure; +using Azure.AI.DocumentIntelligence; +using Microsoft.Extensions.Options; + +public class AzureDocumentOcrService : IOcrService +{ + private const double DefaultTableConfidence = 0.75; + + private static readonly Dictionary ClinicalLabelMap = + new(StringComparer.OrdinalIgnoreCase) + { + ["patient name"] = "patient.fullName", + ["name"] = "patient.fullName", + ["full name"] = "patient.fullName", + ["date of birth"] = "patient.dateOfBirth", + ["dob"] = "patient.dateOfBirth", + ["birth date"] = "patient.dateOfBirth", + ["sex"] = "patient.sex", + ["gender"] = "patient.sex", + ["admission date"] = "encounter.admissionDate", + ["admitted"] = "encounter.admissionDate", + ["department"] = "encounter.department", + ["ward"] = "encounter.department", + ["unit"] = "encounter.department", + ["room"] = "encounter.roomBed", + ["bed"] = "encounter.roomBed", + ["room/bed"] = "encounter.roomBed", + ["hr"] = "observation.HEART_RATE.value", + ["heart rate"] = "observation.HEART_RATE.value", + ["pulse"] = "observation.HEART_RATE.value", + ["temp"] = "observation.TEMP_C.value", + ["temperature"] = "observation.TEMP_C.value", + ["bp sys"] = "observation.BP_SYSTOLIC.value", + ["systolic"] = "observation.BP_SYSTOLIC.value", + ["bp dia"] = "observation.BP_DIASTOLIC.value", + ["diastolic"] = "observation.BP_DIASTOLIC.value", + ["rr"] = "observation.RESP_RATE.value", + ["resp rate"] = "observation.RESP_RATE.value", + ["respiratory rate"] = "observation.RESP_RATE.value", + ["spo2"] = "observation.SPO2.value", + ["o2 sat"] = "observation.SPO2.value", + ["oxygen saturation"] = "observation.SPO2.value", + }; + + private static readonly HashSet TimestampHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "time", "date", "datetime", "date/time", "recorded", "recorded at", "timestamp" + }; + + private static readonly HashSet GenericTableHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "label", "name", "parameter", "field", "item", "value", "result", "reading" + }; + + private readonly DocumentIntelligenceClient _client; + private readonly ILogger _logger; + + public AzureDocumentOcrService( + IOptions options, + ILogger logger) + { + var opts = options.Value.Azure; + _client = new DocumentIntelligenceClient( + new Uri(opts.Endpoint), + new AzureKeyCredential(opts.ApiKey)); + _logger = logger; + } + + public async Task ExtractAsync( + Stream documentStream, string contentType) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + var content = BinaryData.FromStream(documentStream); + var operation = await _client.AnalyzeDocumentAsync( + WaitUntil.Completed, + "prebuilt-document", + content); + + var result = operation.Value; + var fields = new List(); + + foreach (var kv in result.KeyValuePairs ?? []) + { + if (kv.Key?.Content is null || kv.Value?.Content is null) continue; + + var fieldName = MapAzureKeyToFieldName(kv.Key.Content); + if (fieldName is null) continue; + + fields.Add(new OcrExtractedField( + fieldName, + kv.Value.Content, + kv.Confidence)); + } + + foreach (var table in result.Tables ?? []) + { + fields.AddRange(ExtractTableObservations(table)); + } + + var rawText = string.Join("\n", (result.Pages ?? []) + .SelectMany(p => p.Lines?.Select(l => l.Content) ?? [])); + + sw.Stop(); + return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds); + } + + private static string? MapAzureKeyToFieldName(string key) + { + var normalized = key.Trim(); + if (normalized.Length == 0) + return null; + + return ClinicalLabelMap.GetValueOrDefault(normalized); + } + + private static List ExtractTableObservations(DocumentTable table) + { + var fields = new List(); + if (table.Cells is null || table.Cells.Count == 0) + return fields; + + if (table.ColumnCount == 2) + { + var labelValueFields = ExtractLabelValueTable(table); + if (labelValueFields.Count > 0) + return labelValueFields; + } + + return ExtractGridTable(table); + } + + private static List ExtractLabelValueTable(DocumentTable table) + { + var fields = new List(); + var dataStartRow = HasGenericHeaderRow(table.Cells) ? 1 : 0; + + for (var row = dataStartRow; row < table.RowCount; row++) + { + var key = GetCellContent(table.Cells, row, 0); + var value = GetCellContent(table.Cells, row, 1); + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) + continue; + + var fieldName = MapAzureKeyToFieldName(key); + if (fieldName is null) + continue; + + fields.Add(new OcrExtractedField(fieldName, value.Trim(), DefaultTableConfidence)); + } + + return fields; + } + + private static List ExtractGridTable(DocumentTable table) + { + var fields = new List(); + var headerCells = table.Cells + .Where(c => c.RowIndex == 0) + .OrderBy(c => c.ColumnIndex) + .ToList(); + + if (headerCells.Count == 0) + return fields; + + var columnMappings = new Dictionary(); + foreach (var headerCell in headerCells) + { + columnMappings[headerCell.ColumnIndex] = MapTableHeader(headerCell.Content); + } + + var observationCodes = columnMappings.Values + .Where(v => v is not null and not "recordedAt") + .Cast() + .Distinct() + .ToList(); + + if (observationCodes.Count == 0) + return fields; + + for (var row = 1; row < table.RowCount; row++) + { + string? recordedAt = null; + + foreach (var cell in table.Cells.Where(c => c.RowIndex == row)) + { + if (!columnMappings.TryGetValue(cell.ColumnIndex, out var mapping) + || mapping is null + || string.IsNullOrWhiteSpace(cell.Content)) + { + continue; + } + + var content = cell.Content.Trim(); + + if (mapping == "recordedAt") + { + recordedAt = content; + continue; + } + + fields.Add(new OcrExtractedField( + $"observation.{mapping}.value", + content, + DefaultTableConfidence)); + } + + if (recordedAt is null) + continue; + + foreach (var code in observationCodes) + { + fields.Add(new OcrExtractedField( + $"observation.{code}.recordedAt", + recordedAt, + DefaultTableConfidence)); + } + } + + return fields; + } + + private static string? MapTableHeader(string? header) + { + if (string.IsNullOrWhiteSpace(header)) + return null; + + var normalized = header.Trim(); + if (TimestampHeaders.Contains(normalized)) + return "recordedAt"; + + var fieldName = MapAzureKeyToFieldName(normalized); + if (fieldName is null) + return null; + + const string observationPrefix = "observation."; + const string valueSuffix = ".value"; + if (fieldName.StartsWith(observationPrefix, StringComparison.Ordinal) + && fieldName.EndsWith(valueSuffix, StringComparison.Ordinal)) + { + return fieldName[observationPrefix.Length..^valueSuffix.Length]; + } + + return null; + } + + private static bool HasGenericHeaderRow(IReadOnlyList cells) + { + var headerCells = cells.Where(c => c.RowIndex == 0).ToList(); + if (headerCells.Count != 2) + return false; + + return headerCells.All(c => + GenericTableHeaders.Contains(c.Content?.Trim() ?? string.Empty)); + } + + private static string? GetCellContent( + IReadOnlyList cells, int row, int column) + { + return cells + .FirstOrDefault(c => c.RowIndex == row && c.ColumnIndex == column) + ?.Content; + } +} \ No newline at end of file diff --git a/VigilCareRecordsAPI/Services/Ocr/ImagePreprocessor.cs b/VigilCareRecordsAPI/Services/Ocr/ImagePreprocessor.cs new file mode 100644 index 0000000..5bda250 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Ocr/ImagePreprocessor.cs @@ -0,0 +1,66 @@ +using Docnet.Core; +using Docnet.Core.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +public class ImagePreprocessor +{ + // US Letter at 300 DPI — sufficient for Tesseract on clinical scans. + private const int PdfRenderWidth = 2550; + private const int PdfRenderHeight = 3300; + + public Stream Preprocess(Stream input, string contentType) + { + Stream? pdfRenderStream = null; + try + { + if (contentType == "application/pdf") + { + pdfRenderStream = RenderPdfFirstPage(input); + input = pdfRenderStream; + } + + using var image = Image.Load(input); + + image.Mutate(ctx => ctx + .Grayscale() + .GaussianSharpen(1.5f) + .BinaryThreshold(0.5f)); + + var output = new MemoryStream(); + image.SaveAsPng(output); + output.Position = 0; + return output; + } + finally + { + pdfRenderStream?.Dispose(); + } + } + + private static Stream RenderPdfFirstPage(Stream pdfStream) + { + using var buffer = new MemoryStream(); + pdfStream.CopyTo(buffer); + var pdfBytes = buffer.ToArray(); + + using var docReader = DocLib.Instance.GetDocReader( + pdfBytes, + new PageDimensions(PdfRenderWidth, PdfRenderHeight)); + + if (docReader.GetPageCount() == 0) + throw new InvalidOperationException("PDF contains no pages."); + + using var pageReader = docReader.GetPageReader(0); + var rawBytes = pageReader.GetImage(); + var width = pageReader.GetPageWidth(); + var height = pageReader.GetPageHeight(); + + using var image = Image.LoadPixelData(rawBytes, width, height); + var output = new MemoryStream(); + image.SaveAsPng(output); + output.Position = 0; + return output; + } +} diff --git a/VigilCareRecordsAPI/Services/Ocr/OcrDraftPreFiller.cs b/VigilCareRecordsAPI/Services/Ocr/OcrDraftPreFiller.cs new file mode 100644 index 0000000..43173ad --- /dev/null +++ b/VigilCareRecordsAPI/Services/Ocr/OcrDraftPreFiller.cs @@ -0,0 +1,252 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +public class OcrDraftPreFiller +{ + private static readonly Dictionary DefaultUnits = new(StringComparer.Ordinal) + { + ["HEART_RATE"] = "bpm", + ["TEMP_C"] = "C", + ["BP_SYSTOLIC"] = "mmHg", + ["BP_DIASTOLIC"] = "mmHg", + ["RESP_RATE"] = "breaths/min", + ["SPO2"] = "%", + ["POTASSIUM_MEQ_L"] = "mEq/L", + ["WBC_K_UL"] = "K/uL", + ["GLUCOSE_MG_DL"] = "mg/dL", + ["LACTATE_MMOL_L"] = "mmol/L", + }; + + private static readonly Regex ObservationValueField = + new(@"^observation\.([^.]+)\.value$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly AppDbContext _db; + private readonly OcrOptions _options; + private readonly ILogger _logger; + + public OcrDraftPreFiller( + AppDbContext db, + IOptions options, + ILogger logger) + { + _db = db; + _options = options.Value; + _logger = logger; + } + + public async Task PreFillAsync( + Guid batchId, BatchType batchType, OcrExtractionResult extraction) + { + var requirements = BatchTypeFieldRequirements.ForBatchType(batchType); + var confidentFields = extraction.Fields + .Where(f => f.Confidence >= _options.ConfidenceThreshold) + .GroupBy(f => f.FieldName, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + if (requirements.ShowPatientDemographics) + await PreFillPatientAsync(batchId, confidentFields); + + if (requirements.ShowEncounterContext) + await PreFillEncounterAsync(batchId, confidentFields); + + if (requirements.ShowObservations) + await PreFillObservationsAsync(batchId, confidentFields); + + await _db.SaveChangesAsync(); + } + + private async Task PreFillPatientAsync( + Guid batchId, Dictionary fields) + { + if (!fields.Keys.Any(k => k.StartsWith("patient.", StringComparison.Ordinal))) + return; + + var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId); + var now = DateTimeOffset.UtcNow; + + if (patient is null) + { + patient = new DraftPatient + { + Id = Guid.NewGuid(), + BatchId = batchId, + CreatedAt = now, + UpdatedAt = now + }; + _db.DraftPatients.Add(patient); + } + + if (fields.TryGetValue("patient.fullName", out var name)) + patient.FullName = name.RawValue.Trim(); + + if (fields.TryGetValue("patient.dateOfBirth", out var dob) + && TryParseDate(dob.RawValue, out var parsedDob)) + patient.DateOfBirth = parsedDob; + + if (fields.TryGetValue("patient.sex", out var sex)) + patient.Sex = NormalizeSex(sex.RawValue); + + patient.UpdatedAt = now; + } + + private async Task PreFillEncounterAsync( + Guid batchId, Dictionary fields) + { + if (!fields.Keys.Any(k => k.StartsWith("encounter.", StringComparison.Ordinal))) + return; + + var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId); + var now = DateTimeOffset.UtcNow; + + if (encounter is null) + { + encounter = new DraftEncounter + { + Id = Guid.NewGuid(), + BatchId = batchId, + CreatedAt = now, + UpdatedAt = now + }; + _db.DraftEncounters.Add(encounter); + } + + if (fields.TryGetValue("encounter.admissionDate", out var admissionDate) + && TryParseDateTime(admissionDate.RawValue, out var parsedAdmission)) + encounter.AdmissionDate = parsedAdmission; + + if (fields.TryGetValue("encounter.department", out var department) + && TryParseDepartment(department.RawValue, out var parsedDepartment)) + encounter.Department = parsedDepartment; + + if (fields.TryGetValue("encounter.roomBed", out var roomBed)) + encounter.RoomBed = roomBed.RawValue.Trim(); + + if (fields.TryGetValue("encounter.admissionReason", out var admissionReason)) + encounter.AdmissionReason = admissionReason.RawValue.Trim(); + + encounter.UpdatedAt = now; + } + + private async Task PreFillObservationsAsync( + Guid batchId, Dictionary fields) + { + var observationCodes = fields.Keys + .Select(k => ObservationValueField.Match(k)) + .Where(m => m.Success) + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (observationCodes.Count == 0) + return; + + var existingCodes = await _db.DraftObservations + .Where(o => o.BatchId == batchId) + .Select(o => o.ObservationCode) + .ToListAsync(); + + var existing = existingCodes.ToHashSet(StringComparer.Ordinal); + var now = DateTimeOffset.UtcNow; + + foreach (var code in observationCodes) + { + if (existing.Contains(code)) + continue; + + if (!fields.TryGetValue($"observation.{code}.value", out var valueField)) + continue; + + if (!decimal.TryParse( + valueField.RawValue, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out var value) + && !decimal.TryParse(valueField.RawValue, out value)) + { + _logger.LogDebug( + "Skipping OCR observation {Code} for batch {BatchId}: unparsable value '{Value}'", + code, batchId, valueField.RawValue); + continue; + } + + if (!PlausibilityValidator.IsPlausible(code, value, out var reason)) + { + _logger.LogDebug( + "Skipping OCR observation {Code} for batch {BatchId}: {Reason}", + code, batchId, reason); + continue; + } + + var unit = fields.TryGetValue($"observation.{code}.unit", out var unitField) + ? unitField.RawValue.Trim() + : DefaultUnits.GetValueOrDefault(code, string.Empty); + + var recordedAt = now; + if (fields.TryGetValue($"observation.{code}.recordedAt", out var recordedAtField) + && TryParseDateTime(recordedAtField.RawValue, out var parsedRecordedAt)) + recordedAt = parsedRecordedAt; + + _db.DraftObservations.Add(new DraftObservation + { + Id = Guid.NewGuid(), + BatchId = batchId, + ObservationCode = code, + Value = value, + Unit = unit, + RecordedAt = recordedAt, + CreatedAt = now + }); + } + } + + private static bool TryParseDate(string raw, out DateOnly result) + { + if (DateOnly.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out result)) + return true; + + return DateOnly.TryParse(raw.Trim(), out result); + } + + private static bool TryParseDateTime(string raw, out DateTimeOffset result) + { + if (DateTimeOffset.TryParse(raw.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result)) + return true; + + if (TryParseDate(raw, out var dateOnly)) + { + result = new DateTimeOffset(dateOnly.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + return true; + } + + return DateTimeOffset.TryParse(raw.Trim(), out result); + } + + private static bool TryParseDepartment(string raw, out Department result) + { + var trimmed = raw.Trim(); + if (DepartmentExtensions.TryFromDbString(trimmed, out result)) + return true; + + foreach (Department department in Enum.GetValues()) + { + if (department.ToDbString().Equals(trimmed, StringComparison.OrdinalIgnoreCase)) + { + result = department; + return true; + } + } + + result = default; + return false; + } + + private static string NormalizeSex(string raw) => + raw.Trim().ToLowerInvariant() switch + { + "m" or "male" => "Male", + "f" or "female" => "Female", + _ => raw.Trim() + }; +} diff --git a/VigilCareRecordsAPI/Services/Ocr/TesseractOcrService.cs b/VigilCareRecordsAPI/Services/Ocr/TesseractOcrService.cs new file mode 100644 index 0000000..fac6819 --- /dev/null +++ b/VigilCareRecordsAPI/Services/Ocr/TesseractOcrService.cs @@ -0,0 +1,90 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using Tesseract; + +public class TesseractOcrService : IOcrService +{ + private static readonly (Regex Pattern, string FieldName)[] ClinicalPatterns = + [ + (new Regex(@"(?:Patient\s+)?Name[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "patient.fullName"), + (new Regex(@"DOB[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"), + (new Regex(@"(?:Date of Birth|Birth Date)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "patient.dateOfBirth"), + (new Regex(@"(?:Sex|Gender)[:\s]+(\S+)", RegexOptions.IgnoreCase), "patient.sex"), + (new Regex(@"(?:Admission Date|Admitted)[:\s]+(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", RegexOptions.IgnoreCase), "encounter.admissionDate"), + (new Regex(@"(?:Department|Ward|Unit)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.department"), + (new Regex(@"(?:Room(?:/Bed)?|Bed)[:\s]+(.+)", RegexOptions.IgnoreCase | RegexOptions.Multiline), "encounter.roomBed"), + (new Regex(@"HR[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"), + (new Regex(@"(?:Heart Rate|Pulse)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.HEART_RATE.value"), + (new Regex(@"(?:Temp|Temperature)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.TEMP_C.value"), + (new Regex(@"(?:BP Sys|Systolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_SYSTOLIC.value"), + (new Regex(@"(?:BP Dia|Diastolic)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.BP_DIASTOLIC.value"), + (new Regex(@"(?:RR|Resp(?:iratory)?\s*Rate)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.RESP_RATE.value"), + (new Regex(@"(?:SpO2|O2 Sat)[:\s]+(\d+(?:\.\d+)?)", RegexOptions.IgnoreCase), "observation.SPO2.value"), + ]; + + private readonly TesseractOcrOptions _options; + private readonly ImagePreprocessor _preprocessor; + private readonly ILogger _logger; + + public TesseractOcrService( + IOptions options, + ImagePreprocessor preprocessor, + ILogger logger) + { + _options = options.Value.Tesseract; + _preprocessor = preprocessor; + _logger = logger; + } + + public async Task ExtractAsync( + Stream documentStream, string contentType) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + + using var preprocessed = _preprocessor.Preprocess(documentStream, contentType); + using var memStream = new MemoryStream(); + await preprocessed.CopyToAsync(memStream); + var imageBytes = memStream.ToArray(); + + using var engine = new TesseractEngine( + _options.DataPath, _options.Language, EngineMode.Default); + using var pix = Pix.LoadFromMemory(imageBytes); + using var page = engine.Process(pix); + + var rawText = page.GetText(); + var meanConfidence = page.GetMeanConfidence(); + var fields = ParseClinicalText(rawText, meanConfidence); + + sw.Stop(); + return new OcrExtractionResult(fields, rawText, (int)sw.ElapsedMilliseconds); + } + + private static List ParseClinicalText(string text, float meanConfidence) + { + var fields = new List(); + if (string.IsNullOrWhiteSpace(text)) + return fields; + + var baseConfidence = Math.Clamp(meanConfidence / 100f, 0.0, 1.0); + var matchedFields = new HashSet(StringComparer.Ordinal); + + foreach (var (pattern, fieldName) in ClinicalPatterns) + { + if (matchedFields.Contains(fieldName)) + continue; + + var match = pattern.Match(text); + if (!match.Success) + continue; + + var value = match.Groups[1].Value.Trim(); + if (value.Length == 0) + continue; + + fields.Add(new OcrExtractedField(fieldName, value, baseConfidence)); + matchedFields.Add(fieldName); + } + + return fields; + } +} diff --git a/VigilCareRecordsAPI/Services/PromotionService.cs b/VigilCareRecordsAPI/Services/PromotionService.cs index 586266a..3c5fdcd 100644 --- a/VigilCareRecordsAPI/Services/PromotionService.cs +++ b/VigilCareRecordsAPI/Services/PromotionService.cs @@ -526,7 +526,7 @@ public class PromotionService : IPromotionService return encounter; } - private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync( + private Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync( DigitizationBatch batch, Guid patientId, Guid encounterId, bool enableRetroactiveAlerts, DateTimeOffset now) { @@ -600,7 +600,7 @@ public class PromotionService : IPromotionService "{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})", observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString()); - return (observationIds.ToArray(), outboxCount); + return Task.FromResult((observationIds.ToArray(), outboxCount)); } public async Task PromoteAsync(Guid batchId, Guid actorUserId) diff --git a/VigilCareRecordsAPI/Services/WorkQueueService.cs b/VigilCareRecordsAPI/Services/WorkQueueService.cs index 2757d18..82fe9a0 100644 --- a/VigilCareRecordsAPI/Services/WorkQueueService.cs +++ b/VigilCareRecordsAPI/Services/WorkQueueService.cs @@ -179,10 +179,12 @@ public class WorkQueueService : IWorkQueueService "Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}", pendingBatches.Count, avgTimeInQueueMinutes, rejectRate); - return new WorkQueueOverviewResponse( - StatusCounts: statusCounts, - AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1), - RejectRate: Math.Round(rejectRate, 4), - OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1)); + return new WorkQueueOverviewResponse + { + StatusCounts = statusCounts, + AverageTimeInQueueMinutes = Math.Round(avgTimeInQueueMinutes, 1), + RejectRate = Math.Round(rejectRate, 4), + OldestPendingVerificationMinutes = Math.Round(oldestPendingMinutes, 1) + }; } } \ No newline at end of file diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj index 9b1fe14..ab786aa 100644 --- a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -11,7 +11,9 @@ + + @@ -28,8 +30,10 @@ + + diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json index 61ff8f8..58b29a8 100644 --- a/VigilCareRecordsAPI/appsettings.json +++ b/VigilCareRecordsAPI/appsettings.json @@ -76,5 +76,19 @@ "PublisherName": "VigilCare Records", "PublisherUrl": "https://vigilcare.local", "ServerVersion": "1.0.0" + }, + "Ocr": { + "Enabled": false, + "Provider": "azure", + "ConfidenceThreshold": 0.7, + "PollIntervalSeconds": 15, + "Azure": { + "Endpoint": "", + "ApiKey": "" + }, + "Tesseract": { + "DataPath": "/usr/share/tessdata", + "Language": "eng" + } } } \ No newline at end of file diff --git a/scripts/run-vigilcare-records-phase-13-verification.sh b/scripts/run-vigilcare-records-phase-13-verification.sh new file mode 100755 index 0000000..97eaa3e --- /dev/null +++ b/scripts/run-vigilcare-records-phase-13-verification.sh @@ -0,0 +1,670 @@ +#!/usr/bin/env bash +# Runs Phase 13 verification checks from docs/plans/phase-13-plan.md. +# +# Covers OCR schema/config, draft ocrConfidence API field, manual-entry +# non-interference, and optional live OCR polling when the API runs with OCR enabled. +# +# Prerequisites: +# docker compose up -d (PostgreSQL, Redis, MinIO) +# dotnet ef database update --project VigilCareRecordsAPI +# dotnet run --project VigilCareRecordsAPI (Ocr:Enabled=false by default) +# Phase 1–12 seed data (intake1, entry1) +# +# Optional live OCR checks (plan §4–6): +# Restart API with OCR enabled, e.g.: +# Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI +# Ensure Tesseract data is installed (e.g. /usr/share/tessdata/eng.traineddata) +# VIGILCARE_OCR_LIVE=1 ./scripts/run-vigilcare-records-phase-13-verification.sh +# +# Environment overrides: +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_PG_HOST default: localhost +# VIGILCARE_PG_PORT default: 5437 +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions +# VIGILCARE_SKIP_BUILD_CHECKS set to 1 to skip dotnet build/test +# VIGILCARE_SKIP_API_CHECKS set to 1 to skip HTTP API checks +# VIGILCARE_OCR_LIVE set to 1 to run live OCR polling tests +# VIGILCARE_OCR_POLL_WAIT_SEC default: 20 (should exceed Ocr:PollIntervalSeconds) +# +# Usage: +# chmod +x scripts/run-vigilcare-records-phase-13-verification.sh +# ./scripts/run-vigilcare-records-phase-13-verification.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf" +APPSETTINGS="$REPO_ROOT/VigilCareRecordsAPI/appsettings.json" + +API_URL="${VIGILCARE_API_URL:-http://localhost:5217}" +COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}" +COMPOSE=(docker compose -f "$COMPOSE_FILE") +PG_HOST="${VIGILCARE_PG_HOST:-localhost}" +PG_PORT="${VIGILCARE_PG_PORT:-5437}" +PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}" +PG_USER="${VIGILCARE_PG_USER:-postgres}" +PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}" +SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}" +SKIP_BUILD_CHECKS="${VIGILCARE_SKIP_BUILD_CHECKS:-0}" +SKIP_API_CHECKS="${VIGILCARE_SKIP_API_CHECKS:-0}" +OCR_LIVE="${VIGILCARE_OCR_LIVE:-0}" +OCR_POLL_WAIT_SEC="${VIGILCARE_OCR_POLL_WAIT_SEC:-20}" + +INTAKE_TOKEN="" +ENTRY_TOKEN="" +ENTRY_CLERK1_ID="" + +PASS_COUNT=0 +FAIL_COUNT=0 +FAILED_TESTS=() + +log() { + printf '%s\n' "$*" +} + +section() { + log "" + log "== $1 ==" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAILED_TESTS+=("$1") + log " FAIL: $1" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERROR: required command not found: $cmd" + exit 1 + fi +} + +ensure_fixture_pdf() { + if [[ -f "$FIXTURE_PDF" ]]; then + return 0 + fi + mkdir -p "$(dirname "$FIXTURE_PDF")" + cat >"$FIXTURE_PDF" <<'EOF' +%PDF-1.0 +1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer<> +startxref +206 +%%EOF +EOF +} + +unique_pdf_path() { + local suffix="$1" + local path="/tmp/vigilcare-p13-${suffix}-${RANDOM}.pdf" + cp "$FIXTURE_PDF" "$path" + printf '%s' "$path" +} + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$@" +} + +json_post() { + local url="$1" + local body="$2" + local token="${3:-}" + if [[ -n "$token" ]]; then + curl -sS -X POST "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" + else + curl -sS -X POST "$url" \ + -H 'Content-Type: application/json' \ + -d "$body" + fi +} + +json_get() { + local url="$1" + local token="$2" + curl -sS "$url" -H "Authorization: Bearer $token" +} + +json_put() { + local url="$1" + local body="$2" + local token="$3" + curl -sS -X PUT "$url" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "$body" +} + +login() { + local username="$1" + local password="${2:-password}" + json_post "$API_URL/api/v1/auth/login" \ + "{\"username\":\"$username\",\"password\":\"$password\"}" +} + +extract_data_field() { + local json="$1" + local field="$2" + jq -er ".data.$field // empty" <<<"$json" +} + +assert_api_reachable() { + local code + code="$(http_code "$API_URL/swagger/index.html" || true)" + if [[ "$code" != "200" ]]; then + log "ERROR: API not reachable at $API_URL (HTTP $code)." + log "Start infrastructure with: docker compose up -d" + log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI" + log "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +compose_service_running() { + local service="$1" + "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" +} + +psql_available() { + [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 + compose_service_running postgres && return 0 + command -v psql >/dev/null 2>&1 && return 0 + return 1 +} + +psql_query() { + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + return 1 + fi + if compose_service_running postgres; then + "${COMPOSE[@]}" exec -T postgres \ + psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + elif command -v psql >/dev/null 2>&1; then + PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + else + return 1 + fi +} + +upload_batch() { + local token="$1" + local pdf="$2" + local batch_type="${3:-VITALS_SHEET}" + local track="${4:-BACKFILL}" + + curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + -F "file=@${pdf};type=application/pdf" \ + -F "batchType=$batch_type" \ + -F "track=$track" +} + +assign_batch() { + local token="$1" + local batch_id="$2" + local entry_clerk_id="$3" + + curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkId\":\"$entry_clerk_id\"}" +} + +test_build_and_unit_tests() { + section "1. Backend compiles and tests pass (plan §1)" + + if [[ "$SKIP_BUILD_CHECKS" == "1" ]]; then + log " SKIP: dotnet build/test (VIGILCARE_SKIP_BUILD_CHECKS=1)" + return + fi + + if ! command -v dotnet >/dev/null 2>&1; then + fail "dotnet SDK available for build" + return + fi + + if dotnet build "$REPO_ROOT/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj" \ + --nologo -v q >/tmp/vigilcare-p13-build.log 2>&1; then + pass "dotnet build succeeds" + else + fail "dotnet build succeeds" + log " see /tmp/vigilcare-p13-build.log" + return + fi + + if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \ + --no-build --nologo >/tmp/vigilcare-p13-tests.log 2>&1; then + pass "dotnet test passes" + else + fail "dotnet test passes" + log " see /tmp/vigilcare-p13-tests.log" + fi +} + +test_database_schema() { + section "2. Migration — ocr_results and OCR event types (plan §2, §4)" + + if ! psql_available; then + log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)" + return + fi + + local table_exists unique_batch constraint_ok migration_ok + table_exists="$(psql_query " + SELECT count(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'ocr_results'; + ")" + unique_batch="$(psql_query " + SELECT count(*) + FROM pg_indexes + WHERE tablename = 'ocr_results' AND indexdef LIKE '%UNIQUE%' AND indexdef LIKE '%batch_id%'; + ")" + constraint_ok="$(psql_query " + SELECT count(*) + FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE t.relname = 'digitization_events' + AND c.conname = 'chk_digitization_events_event_type' + AND pg_get_constraintdef(c.oid) LIKE '%ocr_started%' + AND pg_get_constraintdef(c.oid) LIKE '%ocr_completed%' + AND pg_get_constraintdef(c.oid) LIKE '%ocr_failed%'; + ")" + migration_ok="$(psql_query " + SELECT count(*) + FROM public.\"__EFMigrationsHistory\" + WHERE \"MigrationId\" LIKE '%AddOcrResult%'; + " 2>/dev/null || echo "0")" + + if [[ "$table_exists" == "1" ]]; then + pass "ocr_results table exists" + else + fail "ocr_results table exists (run: dotnet ef database update --project VigilCareRecordsAPI)" + fi + + if [[ "$unique_batch" == "1" ]]; then + pass "unique index on ocr_results.batch_id exists" + else + fail "unique index on ocr_results.batch_id exists" + fi + + if [[ "$constraint_ok" == "1" ]]; then + pass "digitization_events check constraint includes OCR event types" + else + fail "digitization_events check constraint includes OCR event types" + fi + + if [[ "$migration_ok" == "1" ]]; then + pass "AddOcrResult migration applied" + else + fail "AddOcrResult migration applied" + fi +} + +test_ocr_disabled_by_default_config() { + section "3. OCR disabled by default — appsettings (plan §3)" + + if [[ ! -f "$APPSETTINGS" ]]; then + fail "appsettings.json exists" + return + fi + + local enabled provider + enabled="$(jq -er '.Ocr.Enabled' <<<"$(cat "$APPSETTINGS")")" + provider="$(jq -er '.Ocr.Provider // empty' <<<"$(cat "$APPSETTINGS")")" + + if [[ "$enabled" == "false" ]]; then + pass "appsettings Ocr.Enabled is false by default" + else + fail "appsettings Ocr.Enabled is false by default (got: $enabled)" + fi + + if [[ -n "$provider" ]]; then + pass "appsettings defines Ocr.Provider" + else + fail "appsettings defines Ocr.Provider" + fi + + if [[ -f "$REPO_ROOT/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs" ]]; then + pass "OcrProcessingService source present" + else + fail "OcrProcessingService source present" + fi + + if grep -q 'OcrConfidenceMap' "$REPO_ROOT/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs" 2>/dev/null; then + pass "DraftPayloadResponse includes OcrConfidenceMap" + else + fail "DraftPayloadResponse includes OcrConfidenceMap" + fi +} + +test_authentication() { + section "4. Authentication" + + local intake_json entry_json users_json + intake_json="$(login intake1)" + entry_json="$(login entry1)" + + INTAKE_TOKEN="$(extract_data_field "$intake_json" token)" + ENTRY_TOKEN="$(extract_data_field "$entry_json" token)" + + if [[ -n "$INTAKE_TOKEN" ]]; then + pass "intake1 login returns JWT" + else + fail "intake1 login returns JWT" + fi + + if [[ -n "$ENTRY_TOKEN" ]]; then + pass "entry1 login returns JWT" + else + fail "entry1 login returns JWT" + fi + + users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")" + ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)" + if [[ -n "$ENTRY_CLERK1_ID" ]]; then + pass "resolved entry clerk ID for assign test" + else + fail "resolved entry clerk ID for assign test" + fi +} + +test_draft_ocr_confidence_null_when_ocr_off() { + section "5. Draft API — ocrConfidence null without OCR run (plan §3, §8)" + + if [[ -z "$INTAKE_TOKEN" ]]; then + fail "draft ocrConfidence test skipped — no intake token" + return + fi + + local pdf upload_json batch_id draft_json ocr_conf + pdf="$(unique_pdf_path draft-null)" + upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" + rm -f "$pdf" + + batch_id="$(extract_data_field "$upload_json" id)" + if [[ -z "$batch_id" ]]; then + fail "upload batch for draft ocrConfidence test" + return + fi + + draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")" + if jq -e '.success == true' <<<"$draft_json" >/dev/null 2>&1; then + pass "GET /draft returns success for UPLOADED batch" + else + fail "GET /draft returns success for UPLOADED batch" + return + fi + + if jq -e '.data | has("ocrConfidence")' <<<"$draft_json" >/dev/null 2>&1; then + pass "draft payload includes ocrConfidence field" + else + fail "draft payload includes ocrConfidence field" + fi + + ocr_conf="$(jq -r '.data.ocrConfidence // "missing"' <<<"$draft_json")" + if [[ "$ocr_conf" == "null" || "$ocr_conf" == "missing" ]]; then + pass "ocrConfidence is null when OCR has not processed batch" + else + fail "ocrConfidence is null when OCR has not processed batch (got: $ocr_conf)" + fi + + local status + status="$(extract_data_field "$upload_json" status)" + if [[ "$status" == "UPLOADED" ]]; then + pass "uploaded batch remains UPLOADED before entry" + else + fail "uploaded batch remains UPLOADED before entry (status=$status)" + fi +} + +test_manual_entry_skips_ocr() { + section "6. No interference with manual entry (plan §7)" + + if [[ -z "$INTAKE_TOKEN" || -z "$ENTRY_TOKEN" || -z "$ENTRY_CLERK1_ID" ]]; then + fail "manual-entry interference test skipped — missing auth IDs" + return + fi + + local pdf upload_json batch_id assign_json put_json status ocr_row event_count + pdf="$(unique_pdf_path manual-entry)" + upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" + rm -f "$pdf" + + batch_id="$(extract_data_field "$upload_json" id)" + if [[ -z "$batch_id" ]]; then + fail "upload batch for manual-entry test" + return + fi + + assign_json="$(assign_batch "$INTAKE_TOKEN" "$batch_id" "$ENTRY_CLERK1_ID")" + if [[ "$(jq -er '.success // false' <<<"$assign_json")" == "true" ]]; then + pass "batch assigned to entry clerk" + else + fail "batch assigned to entry clerk" + return + fi + + put_json="$(json_put \ + "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ + '{"fullName":"Manual Entry Patient","dateOfBirth":"1990-01-15","sex":"female"}' \ + "$ENTRY_TOKEN")" + if [[ "$(jq -er '.success // false' <<<"$put_json")" == "true" ]]; then + pass "entry clerk saves draft patient before OCR can run" + else + fail "entry clerk saves draft patient before OCR can run" + return + fi + + status="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id" "$ENTRY_TOKEN" \ + | jq -r '.data.status // empty')" + if [[ "$status" == "IN_ENTRY" ]]; then + pass "batch transitions to IN_ENTRY after first draft save" + else + fail "batch transitions to IN_ENTRY after first draft save (status=$status)" + fi + + if psql_available; then + ocr_row="$(psql_query " + SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id'; + ")" + event_count="$(psql_query " + SELECT count(*) + FROM digitization_events + WHERE batch_id = '$batch_id' + AND event_type IN ('ocr_started', 'ocr_completed', 'ocr_failed'); + ")" + + if [[ "$ocr_row" == "0" ]]; then + pass "no ocr_results row for IN_ENTRY batch" + else + fail "no ocr_results row for IN_ENTRY batch (count=$ocr_row)" + fi + + if [[ "$event_count" == "0" ]]; then + pass "no OCR lifecycle events for IN_ENTRY batch" + else + fail "no OCR lifecycle events for IN_ENTRY batch (count=$event_count)" + fi + else + log " SKIP: DB assertions for manual-entry test (postgres unavailable)" + fi +} + +test_live_ocr_polling() { + section "7. Live OCR polling (optional — plan §4–6)" + + if [[ "$OCR_LIVE" != "1" ]]; then + log " SKIP: live OCR tests (set VIGILCARE_OCR_LIVE=1 and restart API with Ocr__Enabled=true)" + log " Example: Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI" + return + fi + + if [[ -z "$INTAKE_TOKEN" ]]; then + fail "live OCR test skipped — no intake token" + return + fi + + if ! psql_available; then + fail "live OCR test requires PostgreSQL for event/result assertions" + return + fi + + local pdf upload_json batch_id draft_json provider ocr_conf event_completed event_failed + pdf="$(unique_pdf_path ocr-live)" + upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" + rm -f "$pdf" + + batch_id="$(extract_data_field "$upload_json" id)" + if [[ -z "$batch_id" ]]; then + fail "upload batch for live OCR test" + return + fi + + log " Waiting ${OCR_POLL_WAIT_SEC}s for OcrProcessingService poll cycle..." + sleep "$OCR_POLL_WAIT_SEC" + + event_completed="$(psql_query " + SELECT count(*) FROM digitization_events + WHERE batch_id = '$batch_id' AND event_type = 'ocr_completed'; + ")" + event_failed="$(psql_query " + SELECT count(*) FROM digitization_events + WHERE batch_id = '$batch_id' AND event_type = 'ocr_failed'; + ")" + local ocr_started + ocr_started="$(psql_query " + SELECT count(*) FROM digitization_events + WHERE batch_id = '$batch_id' AND event_type = 'ocr_started'; + ")" + + if [[ "$ocr_started" -ge 1 ]]; then + pass "OcrStarted event written for uploaded batch" + else + fail "OcrStarted event written for uploaded batch (is API running with Ocr__Enabled=true?)" + fi + + if [[ "$event_completed" == "1" ]]; then + pass "OcrCompleted event written" + elif [[ "$event_failed" == "1" ]]; then + pass "OcrFailed event written (OCR failure is non-blocking — plan §6)" + local batch_status + batch_status="$(psql_query " + SELECT status FROM digitization_batches WHERE id = '$batch_id'; + ")" + if [[ "$batch_status" == "UPLOADED" ]]; then + pass "batch remains UPLOADED after OCR failure" + else + fail "batch remains UPLOADED after OCR failure (status=$batch_status)" + fi + return + else + fail "OcrCompleted or OcrFailed event written after poll wait" + return + fi + + draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")" + ocr_conf="$(jq -c '.data.ocrConfidence // null' <<<"$draft_json")" + provider="$(jq -r '.data.ocrConfidence.provider // empty' <<<"$draft_json")" + + if [[ "$ocr_conf" != "null" && -n "$provider" ]]; then + pass "GET /draft returns ocrConfidence with provider ($provider)" + else + fail "GET /draft returns ocrConfidence with provider (got: $ocr_conf)" + fi + + if jq -e '.data.ocrConfidence.fieldConfidences | type == "object"' <<<"$draft_json" >/dev/null 2>&1; then + pass "ocrConfidence.fieldConfidences is an object" + else + fail "ocrConfidence.fieldConfidences is an object" + fi + + local ocr_row + ocr_row="$(psql_query " + SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id'; + ")" + if [[ "$ocr_row" == "1" ]]; then + pass "ocr_results row persisted for batch" + else + fail "ocr_results row persisted for batch" + fi +} + +print_manual_ui_checklist() { + section "8. Manual Vue UI checks (plan §8)" + log " Login as entry1 → open an OCR-processed batch in the entry form" + log " - Blue OCR banner shows provider name" + log " - Pre-filled fields show green/yellow/red left border by confidence" + log " - Fields below confidence threshold remain empty" + log " Edit a pre-filled field → save → confirm draft_field_updated audit event" + log " Login as verifier → open batch in verification form" + log " - OCR banner and confidence borders visible in read-only mode" + log " OCR disabled (default): confirm no OcrProcessingService started in API logs" + log " Azure provider: restart with Ocr__Provider=azure and valid credentials (plan §5)" + log " OCR failure: restart with invalid Azure endpoint → OcrFailed logged, manual entry works (plan §6)" +} + +main() { + require_cmd curl + require_cmd jq + ensure_fixture_pdf + + log "VigilCare Records — Phase 13 verification (Optional OCR-Assisted Draft Pre-Fill)" + log "API: $API_URL" + log "OCR live tests: $([[ "$OCR_LIVE" == "1" ]] && echo enabled || echo disabled)" + + test_build_and_unit_tests + test_database_schema + test_ocr_disabled_by_default_config + + if [[ "$SKIP_API_CHECKS" == "1" ]]; then + log "" + log "SKIP: HTTP API checks (VIGILCARE_SKIP_API_CHECKS=1)" + else + assert_api_reachable + test_authentication + test_draft_ocr_confidence_null_when_ocr_off + test_manual_entry_skips_ocr + test_live_ocr_polling + fi + + print_manual_ui_checklist + + log "" + log "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + if (( FAIL_COUNT > 0 )); then + log "Failed checks:" + for item in "${FAILED_TESTS[@]}"; do + log " - $item" + done + exit 1 + fi + + log "All Phase 13 automated verification checks passed." + log "Complete the manual Vue UI checklist above if not already done." + if [[ "$OCR_LIVE" != "1" ]]; then + log "For live OCR polling tests, restart the API with OCR enabled and re-run with VIGILCARE_OCR_LIVE=1." + fi +} + +main "$@" diff --git a/vigilcare-records-web/src/__tests__/helpers/fieldRequirements.ts b/vigilcare-records-web/src/__tests__/helpers/fieldRequirements.ts index c518cc0..6b71108 100644 --- a/vigilcare-records-web/src/__tests__/helpers/fieldRequirements.ts +++ b/vigilcare-records-web/src/__tests__/helpers/fieldRequirements.ts @@ -79,6 +79,7 @@ export function emptyDraft( status: 'IN_ENTRY', batchType, fieldRequirements: fieldRequirementsForBatchType(batchType), + ocrConfidence: null, patient: null, encounter: null, observations: [], diff --git a/vigilcare-records-web/src/assets/main.css b/vigilcare-records-web/src/assets/main.css index 4080509..4b49673 100644 --- a/vigilcare-records-web/src/assets/main.css +++ b/vigilcare-records-web/src/assets/main.css @@ -52,4 +52,16 @@ .status-badge { @apply px-2 py-1 rounded-full text-xs font-medium; } + .ocr-banner { + @apply bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800; + } + .ocr-high { + @apply border-l-[3px] border-l-green-500; + } + .ocr-medium { + @apply border-l-[3px] border-l-yellow-500; + } + .ocr-low { + @apply border-l-[3px] border-l-red-500; + } } diff --git a/vigilcare-records-web/src/components/EntryForm.vue b/vigilcare-records-web/src/components/EntryForm.vue index 48588c7..6326f6d 100644 --- a/vigilcare-records-web/src/components/EntryForm.vue +++ b/vigilcare-records-web/src/components/EntryForm.vue @@ -10,6 +10,11 @@ +
+ Pre-filled by OCR ({{ ocrConfidence.provider }}). + Review all values against the scan before submitting. +
+
Patient Demographics @@ -20,7 +25,7 @@ v-model="patient.fullName" @blur="savePatient" type="text" - class="form-input text-sm" + :class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]" />
@@ -29,12 +34,16 @@ v-model="patient.dateOfBirth" @blur="savePatient" type="date" - class="form-input text-sm" + :class="['form-input', 'text-sm', fieldConfidenceClass('patient.dateOfBirth')]" />
- @@ -146,12 +155,16 @@ v-model="encounter.admissionDate" @blur="saveEncounter" type="datetime-local" - class="form-input text-sm" + :class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]" />
- @@ -162,7 +175,7 @@ v-model="encounter.roomBed" @blur="saveEncounter" type="text" - class="form-input text-sm" + :class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]" />
@@ -171,7 +184,7 @@ v-model="encounter.admissionReason" @blur="saveEncounter" type="text" - class="form-input text-sm" + :class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionReason')]" />
@@ -203,6 +216,7 @@ v-for="obs in observations" :key="obs.id" :observation="obs" + :value-input-class="observationValueConfidenceClass(obs.observationCode)" @update="(field, value) => handleObsUpdate(obs.id, field, value)" @delete="handleObsDelete" /> @@ -233,6 +247,7 @@ import { ref, reactive, computed, watch } from 'vue' import { useBatchStore } from '../stores/batches' import { useToast } from '../composables/useToast' +import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence' import ObservationRow from '../components/ObservationRow.vue' import type { BatchDetailResponse, DraftObservation } from '../types' @@ -271,6 +286,14 @@ const departments = [ ] const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements) +const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null) +const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence) + +function observationValueConfidenceClass(observationCode: string): string { + if (!observationCode) return '' + return fieldConfidenceClass(`observation.${observationCode}.value`) +} + const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false) const showMedications = computed(() => fieldReqs.value?.showMedications ?? false) const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false) diff --git a/vigilcare-records-web/src/components/ObservationRow.vue b/vigilcare-records-web/src/components/ObservationRow.vue index ce27792..c195e2b 100644 --- a/vigilcare-records-web/src/components/ObservationRow.vue +++ b/vigilcare-records-web/src/components/ObservationRow.vue @@ -29,7 +29,7 @@ @change="update('value', parseFloat(($event.target as HTMLInputElement).value))" type="number" step="0.01" - class="form-input text-sm" + :class="['form-input', 'text-sm', valueInputClass]" :disabled="readonly" />
@@ -96,6 +96,7 @@ const props = defineProps<{ readonly?: boolean showVerified?: boolean verified?: boolean + valueInputClass?: string }>() const emit = defineEmits<{ diff --git a/vigilcare-records-web/src/components/VerificationForm.vue b/vigilcare-records-web/src/components/VerificationForm.vue index b9bfdd0..99185e5 100644 --- a/vigilcare-records-web/src/components/VerificationForm.vue +++ b/vigilcare-records-web/src/components/VerificationForm.vue @@ -12,6 +12,11 @@

{{ batch.rejectionReason }}

+
+ Values pre-filled by OCR ({{ ocrConfidence.provider }}). + Colored borders indicate extraction confidence — verify each value against the scan. +
+
Patient Demographics @@ -26,7 +31,10 @@ /> -

+

{{ field.value || '(empty)' }}

@@ -47,7 +55,10 @@ /> -

+

{{ field.value || '(empty)' }}

@@ -68,7 +79,10 @@ /> -

+

{{ field.value || '(empty)' }}

@@ -89,7 +103,10 @@ /> -

+

{{ field.value || '(empty)' }}

@@ -109,6 +126,7 @@ :readonly="true" :show-verified="true" :verified="fieldChecks[`observations[${index}].value`] ?? false" + :value-input-class="observationValueConfidenceClass(obs.observationCode)" @verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)" /> @@ -190,6 +208,7 @@ import { ref, computed, watch } from 'vue' import { useRouter } from 'vue-router' import { useBatchStore } from '../stores/batches' import { useToast } from '../composables/useToast' +import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence' import ObservationRow from '../components/ObservationRow.vue' import type { BatchDetailResponse, DraftObservation } from '../types' @@ -221,6 +240,14 @@ const medicationFields = ref([]) const encounterFields = ref([]) const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements) +const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null) +const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence) + +function observationValueConfidenceClass(observationCode: string): string { + if (!observationCode) return '' + return fieldConfidenceClass(`observation.${observationCode}.value`) +} + const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false) const showMedications = computed(() => fieldReqs.value?.showMedications ?? false) const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false) diff --git a/vigilcare-records-web/src/composables/useOcrFieldConfidence.ts b/vigilcare-records-web/src/composables/useOcrFieldConfidence.ts new file mode 100644 index 0000000..6d10d3d --- /dev/null +++ b/vigilcare-records-web/src/composables/useOcrFieldConfidence.ts @@ -0,0 +1,17 @@ +import { type ComputedRef } from 'vue' +import type { OcrConfidenceMap } from '../types' + +export function useOcrFieldConfidence( + ocrConfidence: ComputedRef, +) { + function fieldConfidenceClass(fieldPath: string): string { + if (!ocrConfidence.value) return '' + const confidence = ocrConfidence.value.fieldConfidences[fieldPath] + if (confidence === undefined) return '' + if (confidence >= 0.85) return 'ocr-high' + if (confidence >= 0.7) return 'ocr-medium' + return 'ocr-low' + } + + return { fieldConfidenceClass } +} diff --git a/vigilcare-records-web/src/types/index.ts b/vigilcare-records-web/src/types/index.ts index d2ad17f..34399d6 100644 --- a/vigilcare-records-web/src/types/index.ts +++ b/vigilcare-records-web/src/types/index.ts @@ -126,11 +126,19 @@ export interface DraftObservation { note: string | null } +export interface OcrConfidenceMap { + provider: string + processedAt: string + durationMs: number + fieldConfidences: Record +} + export interface BatchDraft { batchId: string status: string batchType: string fieldRequirements: BatchTypeFieldRequirements + ocrConfidence: OcrConfidenceMap | null patient: DraftPatient | null encounter: DraftEncounter | null observations: DraftObservation[]