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() }; }