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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user