Files
vigilcare-records/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs
T

217 lines
7.7 KiB
C#

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