Files

253 lines
8.5 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class OcrDraftPreFiller
{
private static readonly Dictionary<string, string> 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<OcrDraftPreFiller> _logger;
public OcrDraftPreFiller(
AppDbContext db,
IOptions<OcrOptions> options,
ILogger<OcrDraftPreFiller> 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<string, OcrExtractedField> 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<string, OcrExtractedField> 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<string, OcrExtractedField> 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<Department>())
{
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()
};
}