feature: Optional OCR-Assisted Draft Pre-Fill
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class OcrProcessingService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly OcrOptions _options;
|
||||||
|
private readonly ILogger<OcrProcessingService> _logger;
|
||||||
|
|
||||||
|
public OcrProcessingService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<OcrOptions> options,
|
||||||
|
ILogger<OcrProcessingService> 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<AppDbContext>();
|
||||||
|
var ocr = scope.ServiceProvider.GetRequiredService<IOcrService>();
|
||||||
|
var storage = scope.ServiceProvider.GetRequiredService<IDocumentStorageService>();
|
||||||
|
var preFiller = scope.ServiceProvider.GetRequiredService<OcrDraftPreFiller>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public class AzureOcrOptions
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; } = "";
|
||||||
|
public string ApiKey { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public class TesseractOcrOptions
|
||||||
|
{
|
||||||
|
public string DataPath { get; set; } = "/usr/share/tessdata";
|
||||||
|
public string Language { get; set; } = "eng";
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ public class FhirEncounterController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// Supports search by patient reference, status, and date range.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class FhirObservationController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// Supports search by patient reference, LOINC code, date range, and category.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class FhirPatientController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
|
||||||
/// Returns a FHIR Bundle of type searchset.
|
/// Returns a FHIR Bundle of type searchset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||||
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
|
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
|
||||||
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
|
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
|
||||||
|
public DbSet<OcrResult> OcrResults => Set<OcrResult>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration<Digitizat
|
|||||||
builder.ToTable("digitization_events", t =>
|
builder.ToTable("digitization_events", t =>
|
||||||
{
|
{
|
||||||
t.HasCheckConstraint("chk_digitization_events_event_type",
|
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.HasKey(e => e.Id);
|
||||||
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class OcrResultConfiguration : IEntityTypeConfiguration<OcrResult>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OcrResult> 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<OcrResult>(o => o.BatchId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1628
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareRecordsAPI.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOcrResult : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
batch_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
provider = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
field_confidences_json = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
raw_text = table.Column<string>(type: "text", nullable: true),
|
||||||
|
duration_ms = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
processed_at = table.Column<DateTimeOffset>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -476,7 +476,7 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
|
|
||||||
b.ToTable("digitization_events", null, t =>
|
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");
|
b.ToTable("observations", "clinical");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("OcrResult", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<Guid>("BatchId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("batch_id");
|
||||||
|
|
||||||
|
b.Property<int>("DurationMs")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("duration_ms");
|
||||||
|
|
||||||
|
b.Property<string>("FieldConfidencesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("field_confidences_json");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ProcessedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("processed_at")
|
||||||
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("provider");
|
||||||
|
|
||||||
|
b.Property<string>("RawText")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("raw_text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BatchId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ocr_results", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("OutboxEvent", b =>
|
modelBuilder.Entity("OutboxEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1506,6 +1551,17 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
b.Navigation("Patient");
|
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 =>
|
modelBuilder.Entity("PromotionAttempt", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DigitizationBatch", "Batch")
|
b.HasOne("DigitizationBatch", "Batch")
|
||||||
|
|||||||
@@ -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<string, double>
|
||||||
|
public string? RawText { get; set; }
|
||||||
|
public int DurationMs { get; set; }
|
||||||
|
public DateTimeOffset ProcessedAt { get; set; }
|
||||||
|
|
||||||
|
public DigitizationBatch Batch { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -22,7 +22,10 @@ public enum DigitizationEventType
|
|||||||
DocumentAccessed,
|
DocumentAccessed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
DraftFieldUpdated,
|
DraftFieldUpdated,
|
||||||
DraftObservationDeleted
|
DraftObservationDeleted,
|
||||||
|
OcrStarted,
|
||||||
|
OcrCompleted,
|
||||||
|
OcrFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class DigitizationEventTypeExtensions
|
public static class DigitizationEventTypeExtensions
|
||||||
@@ -52,6 +55,9 @@ public static class DigitizationEventTypeExtensions
|
|||||||
DigitizationEventType.Cancelled => "cancelled",
|
DigitizationEventType.Cancelled => "cancelled",
|
||||||
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
|
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
|
||||||
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
|
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
|
||||||
|
DigitizationEventType.OcrStarted => "ocr_started",
|
||||||
|
DigitizationEventType.OcrCompleted => "ocr_completed",
|
||||||
|
DigitizationEventType.OcrFailed => "ocr_failed",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
_ => throw new ArgumentOutOfRangeException(nameof(t))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +86,9 @@ public static class DigitizationEventTypeExtensions
|
|||||||
"cancelled" => DigitizationEventType.Cancelled,
|
"cancelled" => DigitizationEventType.Cancelled,
|
||||||
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
|
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
|
||||||
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
|
"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}'")
|
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ public record DraftPayloadResponse(
|
|||||||
string Status,
|
string Status,
|
||||||
string BatchType,
|
string BatchType,
|
||||||
BatchTypeFieldRequirements FieldRequirements,
|
BatchTypeFieldRequirements FieldRequirements,
|
||||||
|
OcrConfidenceMap? OcrConfidence,
|
||||||
DraftPatientDto? Patient,
|
DraftPatientDto? Patient,
|
||||||
DraftEncounterDto? Encounter,
|
DraftEncounterDto? Encounter,
|
||||||
List<DraftObservationDto> Observations
|
List<DraftObservationDto> Observations
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public record OcrConfidenceMap(
|
||||||
|
string Provider,
|
||||||
|
DateTimeOffset ProcessedAt,
|
||||||
|
int DurationMs,
|
||||||
|
Dictionary<string, double> FieldConfidences
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public record OcrExtractionResult(
|
||||||
|
List<OcrExtractedField> Fields,
|
||||||
|
string RawText,
|
||||||
|
int DurationMs
|
||||||
|
);
|
||||||
@@ -2,29 +2,30 @@
|
|||||||
/// Aggregate work queue health metrics for the supervisor dashboard.
|
/// Aggregate work queue health metrics for the supervisor dashboard.
|
||||||
/// Returned by GET /api/v1/work-queue/overview.
|
/// Returned by GET /api/v1/work-queue/overview.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record WorkQueueOverviewResponse(
|
public record WorkQueueOverviewResponse
|
||||||
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Count of batches per status. Key is the DB status string
|
/// Count of batches per status. Key is the DB status string
|
||||||
/// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION").
|
/// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION").
|
||||||
/// All 8 statuses are always present, even if count is 0.
|
/// All 8 statuses are always present, even if count is 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Dictionary<string, int> StatusCounts,
|
public required Dictionary<string, int> StatusCounts { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Average time in minutes that batches currently in PendingVerification
|
/// Average time in minutes that batches currently in PendingVerification
|
||||||
/// have been waiting. Zero if no batches are pending.
|
/// have been waiting. Zero if no batches are pending.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
double AverageTimeInQueueMinutes,
|
public required double AverageTimeInQueueMinutes { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rejection rate as a decimal (0.0 to 1.0). Calculated as
|
/// Rejection rate as a decimal (0.0 to 1.0). Calculated as
|
||||||
/// rejections / (rejections + verifications) over the last 24 hours.
|
/// rejections / (rejections + verifications) over the last 24 hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
double RejectRate,
|
public required double RejectRate { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Age in minutes of the oldest batch in PendingVerification status.
|
/// Age in minutes of the oldest batch in PendingVerification status.
|
||||||
/// Zero if no batches are pending.
|
/// Zero if no batches are pending.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
double OldestPendingVerificationMinutes
|
public required double OldestPendingVerificationMinutes { get; init; }
|
||||||
);
|
}
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ try
|
|||||||
|
|
||||||
builder.Services.Configure<FhirOptions>(builder.Configuration.GetSection(FhirOptions.Section));
|
builder.Services.Configure<FhirOptions>(builder.Configuration.GetSection(FhirOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<OcrOptions>(builder.Configuration.GetSection(OcrOptions.Section));
|
||||||
|
|
||||||
|
var ocrOptions = builder.Configuration.GetSection(OcrOptions.Section).Get<OcrOptions>();
|
||||||
|
|
||||||
|
if (ocrOptions?.Enabled == true)
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<ImagePreprocessor>();
|
||||||
|
|
||||||
|
if (ocrOptions.Provider == "azure")
|
||||||
|
builder.Services.AddScoped<IOcrService, AzureDocumentOcrService>();
|
||||||
|
else
|
||||||
|
builder.Services.AddScoped<IOcrService, TesseractOcrService>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<OcrDraftPreFiller>();
|
||||||
|
builder.Services.AddHostedService<OcrProcessingService>();
|
||||||
|
}
|
||||||
|
|
||||||
// JWT Authentication
|
// JWT Authentication
|
||||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
|
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ public class DocumentStorageService : IDocumentStorageService
|
|||||||
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
|
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Stream> 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()
|
private async Task EnsureBucketAsync()
|
||||||
{
|
{
|
||||||
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
|
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
|
||||||
|
|||||||
@@ -31,11 +31,29 @@ public class DraftService : IDraftService
|
|||||||
if (batch is null)
|
if (batch is null)
|
||||||
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
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<Dictionary<string, double>>(
|
||||||
|
ocrResult.FieldConfidencesJson) ?? new Dictionary<string, double>();
|
||||||
|
|
||||||
|
ocrConfidence = new OcrConfidenceMap(
|
||||||
|
ocrResult.Provider,
|
||||||
|
ocrResult.ProcessedAt,
|
||||||
|
ocrResult.DurationMs,
|
||||||
|
fieldConfidences);
|
||||||
|
}
|
||||||
|
|
||||||
return new DraftPayloadResponse(
|
return new DraftPayloadResponse(
|
||||||
batch.Id,
|
batch.Id,
|
||||||
batch.Status.ToDbString(),
|
batch.Status.ToDbString(),
|
||||||
batch.BatchType.ToDbString(),
|
batch.BatchType.ToDbString(),
|
||||||
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
|
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
|
||||||
|
ocrConfidence,
|
||||||
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
|
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
|
||||||
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
|
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
|
||||||
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
|
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class IdempotencyService : IIdempotencyService
|
|||||||
return record;
|
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)
|
int httpStatusCode, object responseBody, TimeSpan? ttl = null)
|
||||||
{
|
{
|
||||||
var effectiveTtl = ttl ?? DefaultTtl;
|
var effectiveTtl = ttl ?? DefaultTtl;
|
||||||
@@ -46,5 +46,6 @@ public class IdempotencyService : IIdempotencyService
|
|||||||
|
|
||||||
_db.IdempotencyRecords.Add(record);
|
_db.IdempotencyRecords.Add(record);
|
||||||
// SaveChanges is called by the caller (within the same transaction)
|
// SaveChanges is called by the caller (within the same transaction)
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,5 @@ public interface IDocumentStorageService
|
|||||||
{
|
{
|
||||||
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
|
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
|
||||||
Task<string> GetPresignedUrlAsync(string objectKey);
|
Task<string> GetPresignedUrlAsync(string objectKey);
|
||||||
|
Task<Stream> DownloadAsync(string objectKey);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public interface IOcrService
|
||||||
|
{
|
||||||
|
Task<OcrExtractionResult> ExtractAsync(Stream documentStream, string contentType);
|
||||||
|
}
|
||||||
@@ -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<string, string> 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<string> TimestampHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"time", "date", "datetime", "date/time", "recorded", "recorded at", "timestamp"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> GenericTableHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"label", "name", "parameter", "field", "item", "value", "result", "reading"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly DocumentIntelligenceClient _client;
|
||||||
|
private readonly ILogger<AzureDocumentOcrService> _logger;
|
||||||
|
|
||||||
|
public AzureDocumentOcrService(
|
||||||
|
IOptions<OcrOptions> options,
|
||||||
|
ILogger<AzureDocumentOcrService> logger)
|
||||||
|
{
|
||||||
|
var opts = options.Value.Azure;
|
||||||
|
_client = new DocumentIntelligenceClient(
|
||||||
|
new Uri(opts.Endpoint),
|
||||||
|
new AzureKeyCredential(opts.ApiKey));
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OcrExtractionResult> 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<OcrExtractedField>();
|
||||||
|
|
||||||
|
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<OcrExtractedField> ExtractTableObservations(DocumentTable table)
|
||||||
|
{
|
||||||
|
var fields = new List<OcrExtractedField>();
|
||||||
|
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<OcrExtractedField> ExtractLabelValueTable(DocumentTable table)
|
||||||
|
{
|
||||||
|
var fields = new List<OcrExtractedField>();
|
||||||
|
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<OcrExtractedField> ExtractGridTable(DocumentTable table)
|
||||||
|
{
|
||||||
|
var fields = new List<OcrExtractedField>();
|
||||||
|
var headerCells = table.Cells
|
||||||
|
.Where(c => c.RowIndex == 0)
|
||||||
|
.OrderBy(c => c.ColumnIndex)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (headerCells.Count == 0)
|
||||||
|
return fields;
|
||||||
|
|
||||||
|
var columnMappings = new Dictionary<int, string?>();
|
||||||
|
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<string>()
|
||||||
|
.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<DocumentTableCell> 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<DocumentTableCell> cells, int row, int column)
|
||||||
|
{
|
||||||
|
return cells
|
||||||
|
.FirstOrDefault(c => c.RowIndex == row && c.ColumnIndex == column)
|
||||||
|
?.Content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Bgra32>(rawBytes, width, height);
|
||||||
|
var output = new MemoryStream();
|
||||||
|
image.SaveAsPng(output);
|
||||||
|
output.Position = 0;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<TesseractOcrService> _logger;
|
||||||
|
|
||||||
|
public TesseractOcrService(
|
||||||
|
IOptions<OcrOptions> options,
|
||||||
|
ImagePreprocessor preprocessor,
|
||||||
|
ILogger<TesseractOcrService> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value.Tesseract;
|
||||||
|
_preprocessor = preprocessor;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OcrExtractionResult> 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<OcrExtractedField> ParseClinicalText(string text, float meanConfidence)
|
||||||
|
{
|
||||||
|
var fields = new List<OcrExtractedField>();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return fields;
|
||||||
|
|
||||||
|
var baseConfidence = Math.Clamp(meanConfidence / 100f, 0.0, 1.0);
|
||||||
|
var matchedFields = new HashSet<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -526,7 +526,7 @@ public class PromotionService : IPromotionService
|
|||||||
return encounter;
|
return encounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
|
private Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
|
||||||
DigitizationBatch batch, Guid patientId, Guid encounterId,
|
DigitizationBatch batch, Guid patientId, Guid encounterId,
|
||||||
bool enableRetroactiveAlerts, DateTimeOffset now)
|
bool enableRetroactiveAlerts, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
@@ -600,7 +600,7 @@ public class PromotionService : IPromotionService
|
|||||||
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
|
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
|
||||||
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
|
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
|
||||||
|
|
||||||
return (observationIds.ToArray(), outboxCount);
|
return Task.FromResult((observationIds.ToArray(), outboxCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
|
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
|
||||||
|
|||||||
@@ -179,10 +179,12 @@ public class WorkQueueService : IWorkQueueService
|
|||||||
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
|
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
|
||||||
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
|
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
|
||||||
|
|
||||||
return new WorkQueueOverviewResponse(
|
return new WorkQueueOverviewResponse
|
||||||
StatusCounts: statusCounts,
|
{
|
||||||
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
|
StatusCounts = statusCounts,
|
||||||
RejectRate: Math.Round(rejectRate, 4),
|
AverageTimeInQueueMinutes = Math.Round(avgTimeInQueueMinutes, 1),
|
||||||
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
|
RejectRate = Math.Round(rejectRate, 4),
|
||||||
|
OldestPendingVerificationMinutes = Math.Round(oldestPendingMinutes, 1)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Azure.AI.DocumentIntelligence" Version="1.0.0" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="Docnet.Core" Version="2.6.0" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
|
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
|
||||||
@@ -28,8 +30,10 @@
|
|||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
<PackageReference Include="Tesseract" Version="5.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -76,5 +76,19 @@
|
|||||||
"PublisherName": "VigilCare Records",
|
"PublisherName": "VigilCare Records",
|
||||||
"PublisherUrl": "https://vigilcare.local",
|
"PublisherUrl": "https://vigilcare.local",
|
||||||
"ServerVersion": "1.0.0"
|
"ServerVersion": "1.0.0"
|
||||||
|
},
|
||||||
|
"Ocr": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Provider": "azure",
|
||||||
|
"ConfidenceThreshold": 0.7,
|
||||||
|
"PollIntervalSeconds": 15,
|
||||||
|
"Azure": {
|
||||||
|
"Endpoint": "",
|
||||||
|
"ApiKey": ""
|
||||||
|
},
|
||||||
|
"Tesseract": {
|
||||||
|
"DataPath": "/usr/share/tessdata",
|
||||||
|
"Language": "eng"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+670
@@ -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: <repo>/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<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer<</Size 4/Root 1 0 R>>
|
||||||
|
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 "$@"
|
||||||
@@ -79,6 +79,7 @@ export function emptyDraft(
|
|||||||
status: 'IN_ENTRY',
|
status: 'IN_ENTRY',
|
||||||
batchType,
|
batchType,
|
||||||
fieldRequirements: fieldRequirementsForBatchType(batchType),
|
fieldRequirements: fieldRequirementsForBatchType(batchType),
|
||||||
|
ocrConfidence: null,
|
||||||
patient: null,
|
patient: null,
|
||||||
encounter: null,
|
encounter: null,
|
||||||
observations: [],
|
observations: [],
|
||||||
|
|||||||
@@ -52,4 +52,16 @@
|
|||||||
.status-badge {
|
.status-badge {
|
||||||
@apply px-2 py-1 rounded-full text-xs font-medium;
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ocrConfidence" class="ocr-banner">
|
||||||
|
Pre-filled by OCR ({{ ocrConfidence.provider }}).
|
||||||
|
Review all values against the scan before submitting.
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Patient section -->
|
<!-- Patient section -->
|
||||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
v-model="patient.fullName"
|
v-model="patient.fullName"
|
||||||
@blur="savePatient"
|
@blur="savePatient"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -29,12 +34,16 @@
|
|||||||
v-model="patient.dateOfBirth"
|
v-model="patient.dateOfBirth"
|
||||||
@blur="savePatient"
|
@blur="savePatient"
|
||||||
type="date"
|
type="date"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.dateOfBirth')]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500">Sex</label>
|
<label class="block text-xs text-gray-500">Sex</label>
|
||||||
<select v-model="patient.sex" @change="savePatient" class="form-input text-sm">
|
<select
|
||||||
|
v-model="patient.sex"
|
||||||
|
@change="savePatient"
|
||||||
|
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.sex')]"
|
||||||
|
>
|
||||||
<option value="">Select...</option>
|
<option value="">Select...</option>
|
||||||
<option value="male">Male</option>
|
<option value="male">Male</option>
|
||||||
<option value="female">Female</option>
|
<option value="female">Female</option>
|
||||||
@@ -146,12 +155,16 @@
|
|||||||
v-model="encounter.admissionDate"
|
v-model="encounter.admissionDate"
|
||||||
@blur="saveEncounter"
|
@blur="saveEncounter"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500">Department</label>
|
<label class="block text-xs text-gray-500">Department</label>
|
||||||
<select v-model="encounter.department" @change="saveEncounter" class="form-input text-sm">
|
<select
|
||||||
|
v-model="encounter.department"
|
||||||
|
@change="saveEncounter"
|
||||||
|
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]"
|
||||||
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -162,7 +175,7 @@
|
|||||||
v-model="encounter.roomBed"
|
v-model="encounter.roomBed"
|
||||||
@blur="saveEncounter"
|
@blur="saveEncounter"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -171,7 +184,7 @@
|
|||||||
v-model="encounter.admissionReason"
|
v-model="encounter.admissionReason"
|
||||||
@blur="saveEncounter"
|
@blur="saveEncounter"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionReason')]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showEncounterSummary">
|
<div v-if="showEncounterSummary">
|
||||||
@@ -203,6 +216,7 @@
|
|||||||
v-for="obs in observations"
|
v-for="obs in observations"
|
||||||
:key="obs.id"
|
:key="obs.id"
|
||||||
:observation="obs"
|
:observation="obs"
|
||||||
|
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
|
||||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||||
@delete="handleObsDelete"
|
@delete="handleObsDelete"
|
||||||
/>
|
/>
|
||||||
@@ -233,6 +247,7 @@
|
|||||||
import { ref, reactive, computed, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
|
||||||
import ObservationRow from '../components/ObservationRow.vue'
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
@@ -271,6 +286,14 @@ const departments = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
|
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 showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
|
||||||
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
|
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
|
||||||
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
|
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
class="form-input text-sm"
|
:class="['form-input', 'text-sm', valueInputClass]"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,7 @@ const props = defineProps<{
|
|||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
showVerified?: boolean
|
showVerified?: boolean
|
||||||
verified?: boolean
|
verified?: boolean
|
||||||
|
valueInputClass?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p>
|
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ocrConfidence" class="ocr-banner">
|
||||||
|
Values pre-filled by OCR ({{ ocrConfidence.provider }}).
|
||||||
|
Colored borders indicate extraction confidence — verify each value against the scan.
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Patient review -->
|
<!-- Patient review -->
|
||||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||||
@@ -26,7 +31,10 @@
|
|||||||
/>
|
/>
|
||||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-2 pl-8 font-medium">
|
<p
|
||||||
|
class="text-sm mt-2 pl-8 font-medium"
|
||||||
|
:class="fieldConfidenceClass(field.path)"
|
||||||
|
>
|
||||||
{{ field.value || '(empty)' }}
|
{{ field.value || '(empty)' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +55,10 @@
|
|||||||
/>
|
/>
|
||||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-2 pl-8 font-medium">
|
<p
|
||||||
|
class="text-sm mt-2 pl-8 font-medium"
|
||||||
|
:class="fieldConfidenceClass(field.path)"
|
||||||
|
>
|
||||||
{{ field.value || '(empty)' }}
|
{{ field.value || '(empty)' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +79,10 @@
|
|||||||
/>
|
/>
|
||||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-2 pl-8 font-medium">
|
<p
|
||||||
|
class="text-sm mt-2 pl-8 font-medium"
|
||||||
|
:class="fieldConfidenceClass(field.path)"
|
||||||
|
>
|
||||||
{{ field.value || '(empty)' }}
|
{{ field.value || '(empty)' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +103,10 @@
|
|||||||
/>
|
/>
|
||||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm mt-2 pl-8 font-medium">
|
<p
|
||||||
|
class="text-sm mt-2 pl-8 font-medium"
|
||||||
|
:class="fieldConfidenceClass(field.path)"
|
||||||
|
>
|
||||||
{{ field.value || '(empty)' }}
|
{{ field.value || '(empty)' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +126,7 @@
|
|||||||
:readonly="true"
|
:readonly="true"
|
||||||
:show-verified="true"
|
:show-verified="true"
|
||||||
:verified="fieldChecks[`observations[${index}].value`] ?? false"
|
:verified="fieldChecks[`observations[${index}].value`] ?? false"
|
||||||
|
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
|
||||||
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
|
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,6 +208,7 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
|
||||||
import ObservationRow from '../components/ObservationRow.vue'
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
@@ -221,6 +240,14 @@ const medicationFields = ref<FieldInfo[]>([])
|
|||||||
const encounterFields = ref<FieldInfo[]>([])
|
const encounterFields = ref<FieldInfo[]>([])
|
||||||
|
|
||||||
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
|
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 showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
|
||||||
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
|
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
|
||||||
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
|
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type ComputedRef } from 'vue'
|
||||||
|
import type { OcrConfidenceMap } from '../types'
|
||||||
|
|
||||||
|
export function useOcrFieldConfidence(
|
||||||
|
ocrConfidence: ComputedRef<OcrConfidenceMap | null | undefined>,
|
||||||
|
) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -126,11 +126,19 @@ export interface DraftObservation {
|
|||||||
note: string | null
|
note: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OcrConfidenceMap {
|
||||||
|
provider: string
|
||||||
|
processedAt: string
|
||||||
|
durationMs: number
|
||||||
|
fieldConfidences: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchDraft {
|
export interface BatchDraft {
|
||||||
batchId: string
|
batchId: string
|
||||||
status: string
|
status: string
|
||||||
batchType: string
|
batchType: string
|
||||||
fieldRequirements: BatchTypeFieldRequirements
|
fieldRequirements: BatchTypeFieldRequirements
|
||||||
|
ocrConfidence: OcrConfidenceMap | null
|
||||||
patient: DraftPatient | null
|
patient: DraftPatient | null
|
||||||
encounter: DraftEncounter | null
|
encounter: DraftEncounter | null
|
||||||
observations: DraftObservation[]
|
observations: DraftObservation[]
|
||||||
|
|||||||
Reference in New Issue
Block a user