217 lines
7.7 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|