diff --git a/frontend/src/composables/useNotifications.ts b/frontend/src/composables/useNotifications.ts index 4aa70fa..1d1b5ff 100644 --- a/frontend/src/composables/useNotifications.ts +++ b/frontend/src/composables/useNotifications.ts @@ -1,7 +1,7 @@ import { ref, computed } from 'vue' import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr' import { useAuthStore } from '@/stores/auth' -import type { AppNotification, FileImportNotification, BankSyncNotification } from '@/types' +import type { AppNotification, FileImportNotification, BankSyncNotification, AiClassificationNotification } from '@/types' const notifications = ref([]) const isConnected = ref(false) @@ -42,6 +42,16 @@ function connect() { }) }) + connection.on('AiClassificationComplete', (data: AiClassificationNotification) => { + notifications.value.unshift({ + id: crypto.randomUUID(), + type: 'AiClassificationComplete', + data, + timestamp: new Date().toISOString(), + read: false + }) + }) + connection.onclose(() => { isConnected.value = false }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d32670f..e342a3e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -414,10 +414,17 @@ export interface BankSyncNotification { status: string } +export interface AiClassificationNotification { + totalUncategorized: number + classified: number + skipped: number + error?: string +} + export interface AppNotification { id: string - type: 'FileImportReady' | 'BankSyncComplete' - data: FileImportNotification | BankSyncNotification + type: 'FileImportReady' | 'BankSyncComplete' | 'AiClassificationComplete' + data: FileImportNotification | BankSyncNotification | AiClassificationNotification timestamp: string read: boolean } diff --git a/src/Purrse.Api/Controllers/AiCategorizationController.cs b/src/Purrse.Api/Controllers/AiCategorizationController.cs index 01f2647..90e8de9 100644 --- a/src/Purrse.Api/Controllers/AiCategorizationController.cs +++ b/src/Purrse.Api/Controllers/AiCategorizationController.cs @@ -1,5 +1,7 @@ +using System.Threading.Channels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Purrse.Api.Services; using Purrse.Core.DTOs; using Purrse.Core.Interfaces.Services; using System.Security.Claims; @@ -12,9 +14,14 @@ namespace Purrse.Api.Controllers; public class AiCategorizationController : ControllerBase { private readonly IAiCategorizationService _aiService; + private readonly Channel _classificationChannel; private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); - public AiCategorizationController(IAiCategorizationService aiService) => _aiService = aiService; + public AiCategorizationController(IAiCategorizationService aiService, Channel classificationChannel) + { + _aiService = aiService; + _classificationChannel = classificationChannel; + } [HttpGet("settings")] public async Task> GetSettings() @@ -29,6 +36,10 @@ public class AiCategorizationController : ControllerBase => Ok(await _aiService.TestConnectionAsync(UserId)); [HttpPost("classify")] - public async Task> ClassifyUncategorized(ClassifyUncategorizedRequest request) - => Ok(await _aiService.ClassifyUncategorizedAsync(UserId, request)); + public async Task ClassifyUncategorized(ClassifyUncategorizedRequest request) + { + var job = new ClassificationJob(UserId, request.AccountId, request.StartDate, request.EndDate, request.SearchText, request.Status); + await _classificationChannel.Writer.WriteAsync(job); + return Accepted(new { message = "Classification job queued" }); + } } diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index a04a36c..65c0c44 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Threading.Channels; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -99,6 +100,8 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddSingleton(Channel.CreateUnbounded()); +builder.Services.AddHostedService(); // SignalR builder.Services.AddSignalR(); diff --git a/src/Purrse.Api/Services/AiClassificationBackgroundService.cs b/src/Purrse.Api/Services/AiClassificationBackgroundService.cs new file mode 100644 index 0000000..1624c31 --- /dev/null +++ b/src/Purrse.Api/Services/AiClassificationBackgroundService.cs @@ -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 _channel; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public AiClassificationBackgroundService( + Channel channel, + IServiceScopeFactory scopeFactory, + IHubContext hubContext, + ILogger 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(); + 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); + } + } + } + } +}