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:
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Purrse.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/ai-categorization")]
|
||||
public class AiCategorizationController : ControllerBase
|
||||
{
|
||||
private readonly IAiCategorizationService _aiService;
|
||||
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
public AiCategorizationController(IAiCategorizationService aiService) => _aiService = aiService;
|
||||
|
||||
[HttpGet("settings")]
|
||||
public async Task<ActionResult<AiCategorizationSettingsResponse>> GetSettings()
|
||||
=> Ok(await _aiService.GetSettingsAsync(UserId));
|
||||
|
||||
[HttpPut("settings")]
|
||||
public async Task<ActionResult<AiCategorizationSettingsResponse>> UpdateSettings(UpdateAiCategorizationSettingsRequest request)
|
||||
=> Ok(await _aiService.UpdateSettingsAsync(UserId, request));
|
||||
|
||||
[HttpPost("test-connection")]
|
||||
public async Task<ActionResult<OllamaConnectionTestResponse>> TestConnection()
|
||||
=> Ok(await _aiService.TestConnectionAsync(UserId));
|
||||
|
||||
[HttpPost("classify")]
|
||||
public async Task<ActionResult<ClassifyUncategorizedResponse>> ClassifyUncategorized(ClassifyUncategorizedRequest request)
|
||||
=> Ok(await _aiService.ClassifyUncategorizedAsync(UserId, request));
|
||||
}
|
||||
@@ -78,7 +78,9 @@ builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
|
||||
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
||||
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
|
||||
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
||||
builder.Services.AddHttpClient("Ollama");
|
||||
|
||||
// Built-in file parsers
|
||||
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class AiCategorizationService : IAiCategorizationService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<AiCategorizationService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public AiCategorizationService(
|
||||
PurrseDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<AiCategorizationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AiCategorizationSettingsResponse> GetSettingsAsync(Guid userId)
|
||||
{
|
||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
return new AiCategorizationSettingsResponse(
|
||||
false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true);
|
||||
}
|
||||
|
||||
return new AiCategorizationSettingsResponse(
|
||||
settings.IsEnabled, settings.OllamaUrl, settings.ModelName,
|
||||
settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults);
|
||||
}
|
||||
|
||||
public async Task<AiCategorizationSettingsResponse> UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request)
|
||||
{
|
||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new AiCategorizationSettings
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId
|
||||
};
|
||||
_db.AiCategorizationSettings.Add(settings);
|
||||
}
|
||||
|
||||
settings.IsEnabled = request.IsEnabled;
|
||||
settings.OllamaUrl = request.OllamaUrl;
|
||||
settings.ModelName = request.ModelName;
|
||||
settings.ConfidenceThreshold = request.ConfidenceThreshold;
|
||||
settings.TimeoutSeconds = request.TimeoutSeconds;
|
||||
settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults;
|
||||
settings.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new AiCategorizationSettingsResponse(
|
||||
settings.IsEnabled, settings.OllamaUrl, settings.ModelName,
|
||||
settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults);
|
||||
}
|
||||
|
||||
public async Task<OllamaConnectionTestResponse> TestConnectionAsync(Guid userId)
|
||||
{
|
||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("Ollama");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var response = await client.GetAsync($"{ollamaUrl.TrimEnd('/')}/api/tags");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var tagsResponse = JsonSerializer.Deserialize<OllamaTagsResponse>(json, JsonOptions);
|
||||
|
||||
var modelNames = tagsResponse?.Models?.Select(m => m.Name).ToList() ?? new List<string>();
|
||||
|
||||
return new OllamaConnectionTestResponse(true, null, modelNames);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Ollama connection test failed for user {UserId}", userId);
|
||||
return new OllamaConnectionTestResponse(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TransactionClassification>> ClassifyTransactionsAsync(Guid userId, List<Transaction> uncategorizedTransactions)
|
||||
{
|
||||
if (uncategorizedTransactions.Count == 0)
|
||||
return new List<TransactionClassification>();
|
||||
|
||||
try
|
||||
{
|
||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
if (settings == null || !settings.IsEnabled)
|
||||
return new List<TransactionClassification>();
|
||||
|
||||
// Load all active categories with parent names
|
||||
var categories = await _db.Categories
|
||||
.Include(c => c.Parent)
|
||||
.Where(c => c.UserId == userId && c.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
if (categories.Count == 0)
|
||||
return new List<TransactionClassification>();
|
||||
|
||||
// Load 25 recent categorized transactions as few-shot examples
|
||||
var recentCategorized = await _db.Transactions
|
||||
.Include(t => t.Category).ThenInclude(c => c!.Parent)
|
||||
.Include(t => t.Account)
|
||||
.Where(t => t.Account.UserId == userId && t.CategoryId != null)
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Take(25)
|
||||
.ToListAsync();
|
||||
|
||||
var allClassifications = new List<TransactionClassification>();
|
||||
|
||||
// Process in batches of 50
|
||||
for (int batchStart = 0; batchStart < uncategorizedTransactions.Count; batchStart += 50)
|
||||
{
|
||||
var batch = uncategorizedTransactions.Skip(batchStart).Take(50).ToList();
|
||||
var batchClassifications = await ClassifyBatchAsync(settings, categories, recentCategorized, batch, batchStart);
|
||||
allClassifications.AddRange(batchClassifications);
|
||||
}
|
||||
|
||||
return allClassifications;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI categorization failed for user {UserId}", userId);
|
||||
return new List<TransactionClassification>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<TransactionClassification>> ClassifyBatchAsync(
|
||||
AiCategorizationSettings settings,
|
||||
List<Category> categories,
|
||||
List<Transaction> recentCategorized,
|
||||
List<Transaction> batch,
|
||||
int globalOffset)
|
||||
{
|
||||
var systemMessage = BuildSystemMessage(categories);
|
||||
var userMessage = BuildUserMessage(recentCategorized, batch);
|
||||
|
||||
var chatRequest = new OllamaChatRequest(
|
||||
settings.ModelName,
|
||||
new List<OllamaChatMessage>
|
||||
{
|
||||
new("system", systemMessage),
|
||||
new("user", userMessage)
|
||||
},
|
||||
false,
|
||||
"json");
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("Ollama");
|
||||
client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds);
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(chatRequest, JsonOptions);
|
||||
var content = new StringContent(requestJson, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.PostAsync($"{settings.OllamaUrl.TrimEnd('/')}/api/chat", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
var chatResponse = JsonSerializer.Deserialize<OllamaChatResponse>(responseJson, JsonOptions);
|
||||
|
||||
if (chatResponse?.Message?.Content == null)
|
||||
return new List<TransactionClassification>();
|
||||
|
||||
return ParseClassifications(chatResponse.Message.Content, categories, batch.Count, globalOffset, settings.ConfidenceThreshold);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Ollama API call failed for batch starting at {Offset}", globalOffset);
|
||||
return new List<TransactionClassification>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSystemMessage(List<Category> categories)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category.");
|
||||
sb.AppendLine("Respond with JSON: {\"classifications\": [{\"index\": 0, \"categoryId\": \"...\", \"categoryName\": \"...\", \"confidence\": 0.9}, ...]}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Available categories:");
|
||||
|
||||
foreach (var category in categories)
|
||||
{
|
||||
var parentInfo = category.Parent != null ? $", under \"{category.Parent.Name}\"" : "";
|
||||
sb.AppendLine($"- {category.Id} \"{category.Name}\" ({category.Type}{parentInfo})");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildUserMessage(List<Transaction> recentCategorized, List<Transaction> batch)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (recentCategorized.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Recent categorized transactions (for reference):");
|
||||
for (int i = 0; i < recentCategorized.Count; i++)
|
||||
{
|
||||
var t = recentCategorized[i];
|
||||
var categoryName = t.Category?.Parent != null
|
||||
? $"{t.Category.Parent.Name} > {t.Category.Name}"
|
||||
: t.Category?.Name ?? "Unknown";
|
||||
sb.AppendLine($"{i + 1}. \"{t.PayeeName}\" (${t.Amount:F2}) -> \"{categoryName}\"");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("Classify these uncategorized transactions:");
|
||||
for (int i = 0; i < batch.Count; i++)
|
||||
{
|
||||
var t = batch[i];
|
||||
var type = t.Amount >= 0 ? "credit" : "debit";
|
||||
sb.AppendLine($"{i}. \"{t.PayeeName}\" (${t.Amount:F2}, {type}, {t.Date:yyyy-MM-dd}, memo: \"{t.Memo}\")");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<TransactionClassification> ParseClassifications(
|
||||
string responseContent,
|
||||
List<Category> categories,
|
||||
int batchSize,
|
||||
int globalOffset,
|
||||
double confidenceThreshold)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseContent);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("classifications", out var classificationsArray))
|
||||
return new List<TransactionClassification>();
|
||||
|
||||
var categoryLookup = categories.ToDictionary(c => c.Id);
|
||||
var categoryNameLookup = categories
|
||||
.GroupBy(c => c.Name.ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var results = new List<TransactionClassification>();
|
||||
|
||||
foreach (var item in classificationsArray.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("index", out var indexProp))
|
||||
continue;
|
||||
|
||||
var localIndex = indexProp.GetInt32();
|
||||
if (localIndex < 0 || localIndex >= batchSize)
|
||||
continue;
|
||||
|
||||
var globalIndex = globalOffset + localIndex;
|
||||
|
||||
double confidence = 0;
|
||||
if (item.TryGetProperty("confidence", out var confProp))
|
||||
confidence = confProp.GetDouble();
|
||||
|
||||
if (confidence < confidenceThreshold)
|
||||
continue;
|
||||
|
||||
Guid? categoryId = null;
|
||||
string? categoryName = null;
|
||||
|
||||
// Try to resolve by categoryId first
|
||||
if (item.TryGetProperty("categoryId", out var catIdProp) && catIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (Guid.TryParse(catIdProp.GetString(), out var parsedId) && categoryLookup.ContainsKey(parsedId))
|
||||
{
|
||||
categoryId = parsedId;
|
||||
categoryName = categoryLookup[parsedId].Name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to name matching
|
||||
if (!categoryId.HasValue && item.TryGetProperty("categoryName", out var catNameProp) && catNameProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var name = catNameProp.GetString()?.ToLowerInvariant();
|
||||
if (name != null && categoryNameLookup.TryGetValue(name, out var matched))
|
||||
{
|
||||
categoryId = matched.Id;
|
||||
categoryName = matched.Name;
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryId.HasValue)
|
||||
{
|
||||
results.Add(new TransactionClassification(globalIndex, categoryId, categoryName, confidence));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<TransactionClassification>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClassifyUncategorizedResponse> ClassifyUncategorizedAsync(Guid userId, ClassifyUncategorizedRequest request)
|
||||
{
|
||||
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
if (settings == null || !settings.IsEnabled)
|
||||
return new ClassifyUncategorizedResponse(0, 0, 0);
|
||||
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Account)
|
||||
.Where(t => t.Account.UserId == userId && t.CategoryId == null);
|
||||
|
||||
if (request.AccountId.HasValue)
|
||||
query = query.Where(t => t.AccountId == request.AccountId.Value);
|
||||
if (request.StartDate.HasValue)
|
||||
query = query.Where(t => t.Date >= request.StartDate.Value);
|
||||
if (request.EndDate.HasValue)
|
||||
query = query.Where(t => t.Date <= request.EndDate.Value);
|
||||
if (request.Status.HasValue)
|
||||
query = query.Where(t => t.Status == request.Status.Value);
|
||||
if (!string.IsNullOrEmpty(request.SearchText))
|
||||
query = query.Where(t =>
|
||||
(t.PayeeName != null && t.PayeeName.Contains(request.SearchText)) ||
|
||||
(t.Memo != null && t.Memo.Contains(request.SearchText)) ||
|
||||
(t.ReferenceNumber != null && t.ReferenceNumber.Contains(request.SearchText)));
|
||||
|
||||
var uncategorized = await query.OrderByDescending(t => t.Date).ToListAsync();
|
||||
|
||||
if (uncategorized.Count == 0)
|
||||
return new ClassifyUncategorizedResponse(0, 0, 0);
|
||||
|
||||
var classifications = await ClassifyTransactionsAsync(userId, uncategorized);
|
||||
|
||||
int classified = 0;
|
||||
foreach (var c in classifications)
|
||||
{
|
||||
if (c.CategoryId.HasValue && c.TransactionIndex >= 0 && c.TransactionIndex < uncategorized.Count)
|
||||
{
|
||||
uncategorized[c.TransactionIndex].CategoryId = c.CategoryId.Value;
|
||||
classified++;
|
||||
|
||||
if (settings.UpdatePayeeDefaults)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ClassifyUncategorizedResponse(uncategorized.Count, classified, uncategorized.Count - classified);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,14 +16,18 @@ public class ImportService : IImportService
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly IDuplicateDetectionService _duplicateService;
|
||||
private readonly IPayeeService _payeeService;
|
||||
private readonly IAiCategorizationService _aiCategorization;
|
||||
private readonly IEnumerable<IFileParser> _parsers;
|
||||
private readonly ILogger<ImportService> _logger;
|
||||
|
||||
public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable<IFileParser> parsers)
|
||||
public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IAiCategorizationService aiCategorization, IEnumerable<IFileParser> parsers, ILogger<ImportService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_duplicateService = duplicateService;
|
||||
_payeeService = payeeService;
|
||||
_aiCategorization = aiCategorization;
|
||||
_parsers = parsers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ImportUploadResponse> UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream)
|
||||
@@ -135,6 +139,39 @@ public class ImportService : IImportService
|
||||
}
|
||||
}
|
||||
|
||||
// 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(batch.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 import, continuing without");
|
||||
}
|
||||
|
||||
batch.ImportedCount = importedCount;
|
||||
batch.SkippedCount = skippedCount;
|
||||
batch.DuplicateCount = mergedCount;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Purrse.Core.Enums;
|
||||
|
||||
namespace Purrse.Core.DTOs;
|
||||
|
||||
// Settings
|
||||
public record AiCategorizationSettingsResponse(
|
||||
bool IsEnabled,
|
||||
string OllamaUrl,
|
||||
string ModelName,
|
||||
double ConfidenceThreshold,
|
||||
int TimeoutSeconds,
|
||||
bool UpdatePayeeDefaults);
|
||||
|
||||
public record UpdateAiCategorizationSettingsRequest(
|
||||
bool IsEnabled,
|
||||
string OllamaUrl,
|
||||
string ModelName,
|
||||
double ConfidenceThreshold,
|
||||
int TimeoutSeconds,
|
||||
bool UpdatePayeeDefaults);
|
||||
|
||||
// Connection test
|
||||
public record OllamaConnectionTestResponse(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
List<string>? AvailableModels);
|
||||
|
||||
// Ollama API communication (internal)
|
||||
public record OllamaChatRequest(string Model, List<OllamaChatMessage> Messages, bool Stream, string Format);
|
||||
public record OllamaChatMessage(string Role, string Content);
|
||||
public record OllamaChatResponse(OllamaChatMessage? Message, bool Done);
|
||||
public record OllamaTagsResponse(List<OllamaModelInfo>? Models);
|
||||
public record OllamaModelInfo(string Name, string? Model);
|
||||
|
||||
// Classify uncategorized (manual trigger from Transactions screen)
|
||||
public record ClassifyUncategorizedRequest(
|
||||
Guid? AccountId,
|
||||
DateTime? StartDate,
|
||||
DateTime? EndDate,
|
||||
string? SearchText,
|
||||
TransactionStatus? Status);
|
||||
|
||||
public record ClassifyUncategorizedResponse(
|
||||
int TotalUncategorized,
|
||||
int Classified,
|
||||
int Skipped);
|
||||
|
||||
// Classification result
|
||||
public record TransactionClassification(int TransactionIndex, Guid? CategoryId, string? CategoryName, double Confidence);
|
||||
@@ -0,0 +1,13 @@
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Models;
|
||||
|
||||
namespace Purrse.Core.Interfaces.Services;
|
||||
|
||||
public interface IAiCategorizationService
|
||||
{
|
||||
Task<AiCategorizationSettingsResponse> GetSettingsAsync(Guid userId);
|
||||
Task<AiCategorizationSettingsResponse> UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request);
|
||||
Task<OllamaConnectionTestResponse> TestConnectionAsync(Guid userId);
|
||||
Task<List<TransactionClassification>> ClassifyTransactionsAsync(Guid userId, List<Transaction> uncategorizedTransactions);
|
||||
Task<ClassifyUncategorizedResponse> ClassifyUncategorizedAsync(Guid userId, ClassifyUncategorizedRequest request);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Purrse.Core.Models;
|
||||
|
||||
public class AiCategorizationSettings
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public string OllamaUrl { get; set; } = "http://localhost:11434";
|
||||
public string ModelName { get; set; } = "llama3.1:8b";
|
||||
public double ConfidenceThreshold { get; set; } = 0.7;
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
public bool UpdatePayeeDefaults { get; set; } = true;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -14,4 +14,5 @@ public class User
|
||||
public ICollection<Account> Accounts { get; set; } = new List<Account>();
|
||||
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
|
||||
public BankSyncSettings? BankSyncSettings { get; set; }
|
||||
public AiCategorizationSettings? AiCategorizationSettings { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Purrse.Core.Models;
|
||||
|
||||
namespace Purrse.Data.Configurations;
|
||||
|
||||
public class AiCategorizationSettingsConfiguration : IEntityTypeConfiguration<AiCategorizationSettings>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AiCategorizationSettings> builder)
|
||||
{
|
||||
builder.ToTable("ai_categorization_settings");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.OllamaUrl).HasMaxLength(500);
|
||||
builder.Property(s => s.ModelName).HasMaxLength(100);
|
||||
|
||||
builder.HasOne(s => s.User)
|
||||
.WithOne(u => u.AiCategorizationSettings)
|
||||
.HasForeignKey<AiCategorizationSettings>(s => s.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(s => s.UserId).IsUnique();
|
||||
}
|
||||
}
|
||||
+1464
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Purrse.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiCategorizationSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ai_categorization_settings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
OllamaUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ModelName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
ConfidenceThreshold = table.Column<double>(type: "double precision", nullable: false),
|
||||
TimeoutSeconds = table.Column<int>(type: "integer", nullable: false),
|
||||
UpdatePayeeDefaults = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ai_categorization_settings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ai_categorization_settings_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ai_categorization_settings_UserId",
|
||||
table: "ai_categorization_settings",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ai_categorization_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,48 @@ namespace Purrse.Data.Migrations
|
||||
b.ToTable("accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("ConfidenceThreshold")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ModelName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OllamaUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("TimeoutSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("UpdatePayeeDefaults")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ai_categorization_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -994,6 +1036,17 @@ namespace Purrse.Data.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b =>
|
||||
{
|
||||
b.HasOne("Purrse.Core.Models.User", "User")
|
||||
.WithOne("AiCategorizationSettings")
|
||||
.HasForeignKey("Purrse.Core.Models.AiCategorizationSettings", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b =>
|
||||
{
|
||||
b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail")
|
||||
@@ -1396,6 +1449,8 @@ namespace Purrse.Data.Migrations
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("AiCategorizationSettings");
|
||||
|
||||
b.Navigation("BankSyncSettings");
|
||||
|
||||
b.Navigation("SyncConnections");
|
||||
|
||||
@@ -30,6 +30,7 @@ public class PurrseDbContext : DbContext
|
||||
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
|
||||
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
||||
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
||||
public DbSet<AiCategorizationSettings> AiCategorizationSettings => Set<AiCategorizationSettings>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user