feature: Optional OCR-Assisted Draft Pre-Fill

This commit is contained in:
voltsrage
2026-06-28 01:28:54 +08:00
parent 5039dbb979
commit c160c5f912
42 changed files with 3592 additions and 33 deletions
@@ -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>
/// FHIR search: GET /fhir/Encounter?patient=X&status=Y&date=Z
/// FHIR search: GET /fhir/Encounter?patient=X&amp;status=Y&amp;date=Z
/// Supports search by patient reference, status, and date range.
/// </summary>
[HttpGet]
@@ -25,7 +25,7 @@ public class FhirObservationController : ControllerBase
}
/// <summary>
/// FHIR search: GET /fhir/Observation?patient=X&code=Y&date=Z&category=W
/// FHIR search: GET /fhir/Observation?patient=X&amp;code=Y&amp;date=Z&amp;category=W
/// Supports search by patient reference, LOINC code, date range, and category.
/// </summary>
[HttpGet]
@@ -29,7 +29,7 @@ public class FhirPatientController : ControllerBase
}
/// <summary>
/// FHIR search: GET /fhir/Patient?name=X&birthdate=Y&identifier=Z
/// FHIR search: GET /fhir/Patient?name=X&amp;birthdate=Y&amp;identifier=Z
/// Supports search by name (contains), birthdate (exact), and MRN identifier.
/// Returns a FHIR Bundle of type searchset.
/// </summary>
+1
View File
@@ -24,6 +24,7 @@ public class AppDbContext : DbContext
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
public DbSet<OcrResult> OcrResults => Set<OcrResult>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -8,7 +8,7 @@ public class DigitizationEventConfiguration : IEntityTypeConfiguration<Digitizat
builder.ToTable("digitization_events", 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')");
"event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -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);
}
}
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 =>
{
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted')");
t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed', 'document_accessed', 'cancelled', 'draft_field_updated', 'draft_observation_deleted', 'ocr_started', 'ocr_completed', 'ocr_failed')");
});
});
@@ -1007,6 +1007,51 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.ToTable("observations", "clinical");
});
modelBuilder.Entity("OcrResult", b =>
{
b.Property<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 =>
{
b.Property<Guid>("Id")
@@ -1506,6 +1551,17 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("Patient");
});
modelBuilder.Entity("OcrResult", b =>
{
b.HasOne("DigitizationBatch", "Batch")
.WithOne()
.HasForeignKey("OcrResult", "BatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("PromotionAttempt", b =>
{
b.HasOne("DigitizationBatch", "Batch")
@@ -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,
Cancelled,
DraftFieldUpdated,
DraftObservationDeleted
DraftObservationDeleted,
OcrStarted,
OcrCompleted,
OcrFailed
}
public static class DigitizationEventTypeExtensions
@@ -52,6 +55,9 @@ public static class DigitizationEventTypeExtensions
DigitizationEventType.Cancelled => "cancelled",
DigitizationEventType.DraftFieldUpdated => "draft_field_updated",
DigitizationEventType.DraftObservationDeleted => "draft_observation_deleted",
DigitizationEventType.OcrStarted => "ocr_started",
DigitizationEventType.OcrCompleted => "ocr_completed",
DigitizationEventType.OcrFailed => "ocr_failed",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
@@ -80,6 +86,9 @@ public static class DigitizationEventTypeExtensions
"cancelled" => DigitizationEventType.Cancelled,
"draft_field_updated" => DigitizationEventType.DraftFieldUpdated,
"draft_observation_deleted" => DigitizationEventType.DraftObservationDeleted,
"ocr_started" => DigitizationEventType.OcrStarted,
"ocr_completed" => DigitizationEventType.OcrCompleted,
"ocr_failed" => DigitizationEventType.OcrFailed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
};
}
@@ -2,7 +2,8 @@ public record DraftPayloadResponse(
Guid BatchId,
string Status,
string BatchType,
BatchTypeFieldRequirements FieldRequirements,
BatchTypeFieldRequirements FieldRequirements,
OcrConfidenceMap? OcrConfidence,
DraftPatientDto? Patient,
DraftEncounterDto? Encounter,
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.
/// Returned by GET /api/v1/work-queue/overview.
/// </summary>
public record WorkQueueOverviewResponse(
public record WorkQueueOverviewResponse
{
/// <summary>
/// Count of batches per status. Key is the DB status string
/// (e.g. "UPLOADED", "IN_ENTRY", "PENDING_VERIFICATION").
/// All 8 statuses are always present, even if count is 0.
/// </summary>
Dictionary<string, int> StatusCounts,
public required Dictionary<string, int> StatusCounts { get; init; }
/// <summary>
/// Average time in minutes that batches currently in PendingVerification
/// have been waiting. Zero if no batches are pending.
/// </summary>
double AverageTimeInQueueMinutes,
public required double AverageTimeInQueueMinutes { get; init; }
/// <summary>
/// Rejection rate as a decimal (0.0 to 1.0). Calculated as
/// rejections / (rejections + verifications) over the last 24 hours.
/// </summary>
double RejectRate,
public required double RejectRate { get; init; }
/// <summary>
/// Age in minutes of the oldest batch in PendingVerification status.
/// Zero if no batches are pending.
/// </summary>
double OldestPendingVerificationMinutes
);
public required double OldestPendingVerificationMinutes { get; init; }
}
+17
View File
@@ -53,6 +53,23 @@ try
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
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
@@ -58,6 +58,18 @@ public class DocumentStorageService : IDocumentStorageService
.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()
{
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
@@ -31,11 +31,29 @@ public class DraftService : IDraftService
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
var ocrResult = await _db.OcrResults
.AsNoTracking()
.FirstOrDefaultAsync(o => o.BatchId == batchId);
OcrConfidenceMap? ocrConfidence = null;
if (ocrResult is not null)
{
var fieldConfidences = JsonSerializer.Deserialize<Dictionary<string, double>>(
ocrResult.FieldConfidencesJson) ?? new Dictionary<string, double>();
ocrConfidence = new OcrConfidenceMap(
ocrResult.Provider,
ocrResult.ProcessedAt,
ocrResult.DurationMs,
fieldConfidences);
}
return new DraftPayloadResponse(
batch.Id,
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
ocrConfidence,
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
@@ -22,7 +22,7 @@ public class IdempotencyService : IIdempotencyService
return record;
}
public async Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
public Task SaveAsync(string idempotencyKey, string operationName, Guid resourceId,
int httpStatusCode, object responseBody, TimeSpan? ttl = null)
{
var effectiveTtl = ttl ?? DefaultTtl;
@@ -46,5 +46,6 @@ public class IdempotencyService : IIdempotencyService
_db.IdempotencyRecords.Add(record);
// SaveChanges is called by the caller (within the same transaction)
return Task.CompletedTask;
}
}
@@ -2,4 +2,5 @@ public interface IDocumentStorageService
{
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
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;
}
private async Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
private Task<(Guid[] observationIds, int outboxCount)> PromoteObservationsAsync(
DigitizationBatch batch, Guid patientId, Guid encounterId,
bool enableRetroactiveAlerts, DateTimeOffset now)
{
@@ -600,7 +600,7 @@ public class PromotionService : IPromotionService
"{OutboxCount} outbox events (shouldAlert={ShouldAlert}, track={Track})",
observationIds.Count, batch.Id, outboxCount, shouldAlert, batch.Track.ToDbString());
return (observationIds.ToArray(), outboxCount);
return Task.FromResult((observationIds.ToArray(), outboxCount));
}
public async Task<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}",
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
return new WorkQueueOverviewResponse(
StatusCounts: statusCounts,
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
RejectRate: Math.Round(rejectRate, 4),
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
return new WorkQueueOverviewResponse
{
StatusCounts = statusCounts,
AverageTimeInQueueMinutes = Math.Round(avgTimeInQueueMinutes, 1),
RejectRate = Math.Round(rejectRate, 4),
OldestPendingVerificationMinutes = Math.Round(oldestPendingMinutes, 1)
};
}
}
@@ -11,7 +11,9 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<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="Docnet.Core" Version="2.6.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.2" />
<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.Sinks.Console" Version="5.0.1" />
<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="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Tesseract" Version="5.2.0" />
</ItemGroup>
</Project>
+14
View File
@@ -76,5 +76,19 @@
"PublisherName": "VigilCare Records",
"PublisherUrl": "https://vigilcare.local",
"ServerVersion": "1.0.0"
},
"Ocr": {
"Enabled": false,
"Provider": "azure",
"ConfidenceThreshold": 0.7,
"PollIntervalSeconds": 15,
"Azure": {
"Endpoint": "",
"ApiKey": ""
},
"Tesseract": {
"DataPath": "/usr/share/tessdata",
"Language": "eng"
}
}
}