253 lines
8.5 KiB
C#
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()
|
|
};
|
|
}
|