Private
Public Access
1
0

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

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

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