Private
Public Access
1
0

Add AI categorization with Ollama: settings, auto-classify on sync/import, and manual classify from Transactions

Adds local LLM-based transaction categorization using Ollama:
- Settings UI to configure Ollama URL, model, confidence threshold
- Auto-categorization during bank sync and file import
- Manual "Classify Uncategorized" button on Transactions screen that respects active filters
- Payee default category learning loop for classified transactions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 18:41:12 -05:00
parent 57d1051213
commit 1c7abb7ffc
18 changed files with 2423 additions and 3 deletions
@@ -20,6 +20,7 @@ public class BankSyncService : IBankSyncService
private readonly SimpleFinSyncProvider _simpleFin;
private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService;
private readonly IAiCategorizationService _aiCategorization;
private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger;
@@ -30,6 +31,7 @@ public class BankSyncService : IBankSyncService
SimpleFinSyncProvider simpleFin,
IDuplicateDetectionService duplicateService,
IPayeeService payeeService,
IAiCategorizationService aiCategorization,
IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger)
{
@@ -39,6 +41,7 @@ public class BankSyncService : IBankSyncService
_simpleFin = simpleFin;
_duplicateService = duplicateService;
_payeeService = payeeService;
_aiCategorization = aiCategorization;
_hub = hub;
_logger = logger;
}
@@ -500,6 +503,39 @@ public class BankSyncService : IBankSyncService
}
}
// AI auto-categorization for uncategorized transactions
try
{
var uncategorized = _db.ChangeTracker.Entries<Transaction>()
.Where(e => e.State == EntityState.Added && e.Entity.CategoryId == null)
.Select(e => e.Entity)
.ToList();
if (uncategorized.Count > 0)
{
var classifications = await _aiCategorization.ClassifyTransactionsAsync(userId, uncategorized);
foreach (var c in classifications)
{
if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count)
{
uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value;
var txn = uncategorized[c.TransactionIndex];
if (txn.PayeeId.HasValue)
{
var payee = await _db.Payees.FindAsync(txn.PayeeId.Value);
if (payee != null && payee.DefaultCategoryId == null)
payee.DefaultCategoryId = c.CategoryId.Value;
}
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI categorization failed during sync, continuing without");
}
connection.LastSyncAt = DateTime.UtcNow;
connection.LastSyncError = accountResults.Any(r => r.Error != null)
? accountResults.First(r => r.Error != null).Error