Private
Public Access
1
0

Run AI classification in background with SignalR notifications

Move the classify-uncategorized endpoint from synchronous to async.
The controller now queues a ClassificationJob to an unbounded channel
and returns 202 Accepted. A new BackgroundService processes jobs and
pushes results (or errors) to the user via SignalR. The frontend
listens for the AiClassificationComplete event and surfaces it in the
notification system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-10 23:53:14 -05:00
parent c2a4272585
commit 333b21224d
5 changed files with 130 additions and 6 deletions
@@ -0,0 +1,93 @@
using System.Threading.Channels;
using Microsoft.AspNetCore.SignalR;
using Purrse.Api.Hubs;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Interfaces.Services;
namespace Purrse.Api.Services;
public record ClassificationJob(
Guid UserId,
Guid? AccountId,
DateTime? StartDate,
DateTime? EndDate,
string? SearchText,
TransactionStatus? Status)
{
public ClassifyUncategorizedRequest ToRequest() =>
new(AccountId, StartDate, EndDate, SearchText, Status);
}
public class AiClassificationBackgroundService : BackgroundService
{
private readonly Channel<ClassificationJob> _channel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHubContext<NotificationHub> _hubContext;
private readonly ILogger<AiClassificationBackgroundService> _logger;
public AiClassificationBackgroundService(
Channel<ClassificationJob> channel,
IServiceScopeFactory scopeFactory,
IHubContext<NotificationHub> hubContext,
ILogger<AiClassificationBackgroundService> logger)
{
_channel = channel;
_scopeFactory = scopeFactory;
_hubContext = hubContext;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AI classification background service started");
await foreach (var job in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
_logger.LogInformation("Processing AI classification job for user {UserId}", job.UserId);
using var scope = _scopeFactory.CreateScope();
var aiService = scope.ServiceProvider.GetRequiredService<IAiCategorizationService>();
var result = await aiService.ClassifyUncategorizedAsync(job.UserId, job.ToRequest());
await _hubContext.Clients.Group(job.UserId.ToString())
.SendAsync("AiClassificationComplete", new
{
totalUncategorized = result.TotalUncategorized,
classified = result.Classified,
skipped = result.Skipped
}, stoppingToken);
_logger.LogInformation(
"AI classification complete for user {UserId}: {Classified}/{Total} classified",
job.UserId, result.Classified, result.TotalUncategorized);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "AI classification job failed for user {UserId}", job.UserId);
try
{
await _hubContext.Clients.Group(job.UserId.ToString())
.SendAsync("AiClassificationComplete", new
{
totalUncategorized = 0,
classified = 0,
skipped = 0,
error = ex.Message
}, stoppingToken);
}
catch (Exception notifyEx)
{
_logger.LogWarning(notifyEx, "Failed to send error notification for user {UserId}", job.UserId);
}
}
}
}
}