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,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),
|
||||||
|
}
|
||||||
@@ -485,6 +485,28 @@ export interface CreateLinkTokenResponse {
|
|||||||
expiration: string
|
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
|
// Split form helper
|
||||||
export interface SplitFormItem {
|
export interface SplitFormItem {
|
||||||
categoryId: string | null
|
categoryId: string | null
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<v-tab value="profile">Profile</v-tab>
|
<v-tab value="profile">Profile</v-tab>
|
||||||
<v-tab value="connections">Bank Connections</v-tab>
|
<v-tab value="connections">Bank Connections</v-tab>
|
||||||
<v-tab value="history">Sync History</v-tab>
|
<v-tab value="history">Sync History</v-tab>
|
||||||
|
<v-tab value="ai">AI Categorization</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<v-tabs-window v-model="activeTab" class="mt-4">
|
<v-tabs-window v-model="activeTab" class="mt-4">
|
||||||
@@ -354,6 +355,124 @@
|
|||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-tabs-window-item>
|
</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>
|
</v-tabs-window>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -363,7 +482,8 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { bankSyncApi } from '@/services/bankSync'
|
import { bankSyncApi } from '@/services/bankSync'
|
||||||
import { accountsApi } from '@/services/accounts'
|
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()
|
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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()])
|
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
<div class="d-flex align-center mb-4">
|
<div class="d-flex align-center mb-4">
|
||||||
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
||||||
<v-spacer />
|
<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="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>
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Transaction</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +181,10 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="4000">
|
||||||
|
{{ classifySnackbarText }}
|
||||||
|
</v-snackbar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -178,6 +192,7 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { transactionsApi } from '@/services/transactions'
|
import { transactionsApi } from '@/services/transactions'
|
||||||
import { categoriesApi } from '@/services/categories'
|
import { categoriesApi } from '@/services/categories'
|
||||||
|
import { aiCategorizationApi } from '@/services/aiCategorization'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import { formatCurrency, formatDate } from '@/utils/formatters'
|
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||||
import type { Transaction, Category, SplitFormItem } from '@/types'
|
import type { Transaction, Category, SplitFormItem } from '@/types'
|
||||||
@@ -198,6 +213,10 @@ const editingTxn = ref(false)
|
|||||||
const editingTxnId = ref('')
|
const editingTxnId = ref('')
|
||||||
const useSplits = ref(false)
|
const useSplits = ref(false)
|
||||||
const splits = ref<SplitFormItem[]>([])
|
const splits = ref<SplitFormItem[]>([])
|
||||||
|
const classifying = ref(false)
|
||||||
|
const classifySnackbar = ref(false)
|
||||||
|
const classifySnackbarText = ref('')
|
||||||
|
const classifySnackbarColor = ref('success')
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'Date', key: 'date', width: '100px' },
|
{ title: 'Date', key: 'date', width: '100px' },
|
||||||
@@ -369,4 +388,27 @@ async function createTransfer() {
|
|||||||
showTransferDialog.value = false
|
showTransferDialog.value = false
|
||||||
await loadTransactions()
|
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>
|
</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));
|
||||||
|
}
|
||||||
@@ -78,7 +78,9 @@ builder.Services.AddScoped<IDashboardService, DashboardService>();
|
|||||||
builder.Services.AddScoped<IReportService, ReportService>();
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
|
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
|
||||||
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
||||||
|
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
|
||||||
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
||||||
|
builder.Services.AddHttpClient("Ollama");
|
||||||
|
|
||||||
// Built-in file parsers
|
// Built-in file parsers
|
||||||
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
|
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 SimpleFinSyncProvider _simpleFin;
|
||||||
private readonly IDuplicateDetectionService _duplicateService;
|
private readonly IDuplicateDetectionService _duplicateService;
|
||||||
private readonly IPayeeService _payeeService;
|
private readonly IPayeeService _payeeService;
|
||||||
|
private readonly IAiCategorizationService _aiCategorization;
|
||||||
private readonly IHubContext<NotificationHub> _hub;
|
private readonly IHubContext<NotificationHub> _hub;
|
||||||
private readonly ILogger<BankSyncService> _logger;
|
private readonly ILogger<BankSyncService> _logger;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ public class BankSyncService : IBankSyncService
|
|||||||
SimpleFinSyncProvider simpleFin,
|
SimpleFinSyncProvider simpleFin,
|
||||||
IDuplicateDetectionService duplicateService,
|
IDuplicateDetectionService duplicateService,
|
||||||
IPayeeService payeeService,
|
IPayeeService payeeService,
|
||||||
|
IAiCategorizationService aiCategorization,
|
||||||
IHubContext<NotificationHub> hub,
|
IHubContext<NotificationHub> hub,
|
||||||
ILogger<BankSyncService> logger)
|
ILogger<BankSyncService> logger)
|
||||||
{
|
{
|
||||||
@@ -39,6 +41,7 @@ public class BankSyncService : IBankSyncService
|
|||||||
_simpleFin = simpleFin;
|
_simpleFin = simpleFin;
|
||||||
_duplicateService = duplicateService;
|
_duplicateService = duplicateService;
|
||||||
_payeeService = payeeService;
|
_payeeService = payeeService;
|
||||||
|
_aiCategorization = aiCategorization;
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
_logger = logger;
|
_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.LastSyncAt = DateTime.UtcNow;
|
||||||
connection.LastSyncError = accountResults.Any(r => r.Error != null)
|
connection.LastSyncError = accountResults.Any(r => r.Error != null)
|
||||||
? accountResults.First(r => r.Error != null).Error
|
? accountResults.First(r => r.Error != null).Error
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ public class ImportService : IImportService
|
|||||||
private readonly PurrseDbContext _db;
|
private readonly PurrseDbContext _db;
|
||||||
private readonly IDuplicateDetectionService _duplicateService;
|
private readonly IDuplicateDetectionService _duplicateService;
|
||||||
private readonly IPayeeService _payeeService;
|
private readonly IPayeeService _payeeService;
|
||||||
|
private readonly IAiCategorizationService _aiCategorization;
|
||||||
private readonly IEnumerable<IFileParser> _parsers;
|
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;
|
_db = db;
|
||||||
_duplicateService = duplicateService;
|
_duplicateService = duplicateService;
|
||||||
_payeeService = payeeService;
|
_payeeService = payeeService;
|
||||||
|
_aiCategorization = aiCategorization;
|
||||||
_parsers = parsers;
|
_parsers = parsers;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImportUploadResponse> UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream)
|
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.ImportedCount = importedCount;
|
||||||
batch.SkippedCount = skippedCount;
|
batch.SkippedCount = skippedCount;
|
||||||
batch.DuplicateCount = mergedCount;
|
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<Account> Accounts { get; set; } = new List<Account>();
|
||||||
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
|
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
|
||||||
public BankSyncSettings? BankSyncSettings { get; set; }
|
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);
|
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 =>
|
modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -994,6 +1036,17 @@ namespace Purrse.Data.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail")
|
b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail")
|
||||||
@@ -1396,6 +1449,8 @@ namespace Purrse.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Accounts");
|
b.Navigation("Accounts");
|
||||||
|
|
||||||
|
b.Navigation("AiCategorizationSettings");
|
||||||
|
|
||||||
b.Navigation("BankSyncSettings");
|
b.Navigation("BankSyncSettings");
|
||||||
|
|
||||||
b.Navigation("SyncConnections");
|
b.Navigation("SyncConnections");
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class PurrseDbContext : DbContext
|
|||||||
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
|
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
|
||||||
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
||||||
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
||||||
|
public DbSet<AiCategorizationSettings> AiCategorizationSettings => Set<AiCategorizationSettings>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user