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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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