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
+16
View File
@@ -0,0 +1,16 @@
import api from './api'
import type { AiCategorizationSettings, OllamaConnectionTestResult, ClassifyUncategorizedResult } from '@/types'
export const aiCategorizationApi = {
getSettings: () =>
api.get<AiCategorizationSettings>('/ai-categorization/settings'),
updateSettings: (data: AiCategorizationSettings) =>
api.put<AiCategorizationSettings>('/ai-categorization/settings', data),
testConnection: () =>
api.post<OllamaConnectionTestResult>('/ai-categorization/test-connection'),
classifyUncategorized: (data: { accountId?: string; startDate?: string; endDate?: string; searchText?: string; status?: string }) =>
api.post<ClassifyUncategorizedResult>('/ai-categorization/classify', data),
}
+22
View File
@@ -485,6 +485,28 @@ export interface CreateLinkTokenResponse {
expiration: string
}
// AI Categorization
export interface AiCategorizationSettings {
isEnabled: boolean
ollamaUrl: string
modelName: string
confidenceThreshold: number
timeoutSeconds: number
updatePayeeDefaults: boolean
}
export interface OllamaConnectionTestResult {
success: boolean
errorMessage: string | null
availableModels: string[] | null
}
export interface ClassifyUncategorizedResult {
totalUncategorized: number
classified: number
skipped: number
}
// Split form helper
export interface SplitFormItem {
categoryId: string | null
+178 -2
View File
@@ -4,6 +4,7 @@
<v-tab value="profile">Profile</v-tab>
<v-tab value="connections">Bank Connections</v-tab>
<v-tab value="history">Sync History</v-tab>
<v-tab value="ai">AI Categorization</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab" class="mt-4">
@@ -354,6 +355,124 @@
</v-data-table>
</v-card>
</v-tabs-window-item>
<!-- Tab 4: AI Categorization -->
<v-tabs-window-item value="ai">
<v-alert type="info" variant="tonal" class="mb-4">
AI Categorization uses a local Ollama LLM to automatically categorize transactions when payee matching doesn't produce a category.
Ollama runs locally on your machine — no data is sent to external services.
</v-alert>
<v-card>
<v-card-title>AI Categorization Settings</v-card-title>
<v-card-text>
<v-switch
v-model="aiSettings.isEnabled"
label="Enable AI Categorization"
color="primary"
hide-details
class="mb-4"
/>
<v-row dense>
<v-col cols="12" sm="6">
<v-text-field
v-model="aiSettings.ollamaUrl"
label="Ollama URL"
variant="outlined"
density="compact"
placeholder="http://localhost:11434"
/>
</v-col>
<v-col cols="12" sm="6">
<v-combobox
v-model="aiSettings.modelName"
:items="availableModels"
label="Model"
variant="outlined"
density="compact"
placeholder="llama3.1:8b"
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" sm="6">
<div class="text-body-2 mb-1">Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}</div>
<v-slider
v-model="aiSettings.confidenceThreshold"
:min="0.1"
:max="1.0"
:step="0.05"
thumb-label
color="primary"
hide-details
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model.number="aiSettings.timeoutSeconds"
label="Timeout (seconds)"
variant="outlined"
density="compact"
type="number"
:min="5"
:max="120"
/>
</v-col>
</v-row>
<v-checkbox
v-model="aiSettings.updatePayeeDefaults"
label="Auto-update payee default categories (learning loop)"
color="primary"
hide-details
class="mb-4"
/>
<div class="d-flex ga-2">
<v-btn
color="primary"
:loading="aiSaving"
@click="saveAiSettings"
>
Save
</v-btn>
<v-btn
variant="tonal"
color="secondary"
:loading="aiTesting"
@click="testAiConnection"
>
Test Connection
</v-btn>
</div>
<v-alert
v-if="aiTestResult"
:type="aiTestResult.success ? 'success' : 'error'"
variant="tonal"
class="mt-4"
closable
@click:close="aiTestResult = null"
>
<template v-if="aiTestResult.success">
Connected to Ollama. Available models: {{ aiTestResult.availableModels?.join(', ') || 'none' }}
</template>
<template v-else>
Connection failed: {{ aiTestResult.errorMessage }}
</template>
</v-alert>
</v-card-text>
</v-card>
<v-snackbar v-model="showAiSuccess" :timeout="4000" color="success">
AI categorization settings saved
</v-snackbar>
<v-snackbar v-model="showAiError" :timeout="6000" color="error">
{{ aiErrorMessage }}
</v-snackbar>
</v-tabs-window-item>
</v-tabs-window>
</div>
</template>
@@ -363,7 +482,8 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { bankSyncApi } from '@/services/bankSync'
import { accountsApi } from '@/services/accounts'
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials } from '@/types'
import { aiCategorizationApi } from '@/services/aiCategorization'
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult } from '@/types'
const authStore = useAuthStore()
@@ -674,7 +794,63 @@ async function deleteConnection() {
}
}
// AI Categorization
const aiSettings = reactive<AiCategorizationSettings>({
isEnabled: false,
ollamaUrl: 'http://localhost:11434',
modelName: 'llama3.1:8b',
confidenceThreshold: 0.7,
timeoutSeconds: 30,
updatePayeeDefaults: true
})
const aiSaving = ref(false)
const aiTesting = ref(false)
const aiTestResult = ref<OllamaConnectionTestResult | null>(null)
const availableModels = ref<string[]>([])
const showAiSuccess = ref(false)
const showAiError = ref(false)
const aiErrorMessage = ref('')
async function loadAiSettings() {
try {
const { data } = await aiCategorizationApi.getSettings()
Object.assign(aiSettings, data)
} catch {
// Not configured yet — defaults are fine
}
}
async function saveAiSettings() {
aiSaving.value = true
try {
const { data } = await aiCategorizationApi.updateSettings({ ...aiSettings })
Object.assign(aiSettings, data)
showAiSuccess.value = true
} catch (err: any) {
aiErrorMessage.value = err.response?.data?.error || 'Failed to save AI settings'
showAiError.value = true
} finally {
aiSaving.value = false
}
}
async function testAiConnection() {
aiTesting.value = true
aiTestResult.value = null
try {
const { data } = await aiCategorizationApi.testConnection()
aiTestResult.value = data
if (data.availableModels && data.availableModels.length > 0) {
availableModels.value = data.availableModels
}
} catch (err: any) {
aiTestResult.value = { success: false, errorMessage: err.response?.data?.error || 'Connection test failed', availableModels: null }
} finally {
aiTesting.value = false
}
}
onMounted(async () => {
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()])
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
})
</script>
@@ -3,6 +3,16 @@
<div class="d-flex align-center mb-4">
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
<v-spacer />
<v-btn
color="info"
variant="tonal"
prepend-icon="mdi-brain"
:loading="classifying"
class="mr-2"
@click="classifyUncategorized"
>
Classify Uncategorized
</v-btn>
<v-btn color="secondary" prepend-icon="mdi-swap-horizontal" class="mr-2" @click="showTransferDialog = true">Transfer</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Transaction</v-btn>
</div>
@@ -171,6 +181,10 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="4000">
{{ classifySnackbarText }}
</v-snackbar>
</div>
</template>
@@ -178,6 +192,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { transactionsApi } from '@/services/transactions'
import { categoriesApi } from '@/services/categories'
import { aiCategorizationApi } from '@/services/aiCategorization'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type { Transaction, Category, SplitFormItem } from '@/types'
@@ -198,6 +213,10 @@ const editingTxn = ref(false)
const editingTxnId = ref('')
const useSplits = ref(false)
const splits = ref<SplitFormItem[]>([])
const classifying = ref(false)
const classifySnackbar = ref(false)
const classifySnackbarText = ref('')
const classifySnackbarColor = ref('success')
const headers = [
{ title: 'Date', key: 'date', width: '100px' },
@@ -369,4 +388,27 @@ async function createTransfer() {
showTransferDialog.value = false
await loadTransactions()
}
async function classifyUncategorized() {
classifying.value = true
try {
const { data } = await aiCategorizationApi.classifyUncategorized({
accountId: props.id || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
searchText: search.value || undefined,
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
})
classifySnackbarColor.value = 'success'
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
classifySnackbar.value = true
await doSearch()
} catch (e: any) {
classifySnackbarColor.value = 'error'
classifySnackbarText.value = e?.response?.data?.message || 'Failed to classify transactions'
classifySnackbar.value = true
} finally {
classifying.value = false
}
}
</script>
@@ -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));
}
+2
View File
@@ -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
+38 -1
View File
@@ -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!;
}
+1
View File
@@ -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();
}
}
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");
+1
View File
@@ -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)
{