using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; /// /// 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. /// public class OcrProcessingService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly OcrOptions _options; private readonly ILogger _logger; public OcrProcessingService( IServiceScopeFactory scopeFactory, IOptions options, ILogger 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(); var ocr = scope.ServiceProvider.GetRequiredService(); var storage = scope.ServiceProvider.GetRequiredService(); var preFiller = scope.ServiceProvider.GetRequiredService(); 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); } } }