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>
|
||||
|
||||
Reference in New Issue
Block a user