feat: 添加 AI 分类功能,支持交易自动分类与标签生成

This commit is contained in:
2026-03-12 10:59:44 +08:00
parent 8cbe01dd9c
commit 6a00875246
12 changed files with 594 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
export class AiProvider {
async enrichTransaction() {
throw new Error('AI provider is not implemented')
}
}

View File

@@ -0,0 +1,63 @@
import { buildTransactionEnrichmentMessages } from '../../lib/aiPrompt.js'
import { useSettingsStore } from '../../stores/settings.js'
import {
applyAiEnrichment,
fetchRecentTransactionsForAi,
markTransactionAiFailed,
} from '../transactionService.js'
import { DeepSeekProvider } from './deepseekProvider.js'
const clampThreshold = (value) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 0.9
return Math.min(Math.max(numeric, 0), 1)
}
const createProvider = (settingsStore) => {
if (settingsStore.aiProvider !== 'deepseek') {
throw new Error(`Unsupported AI provider: ${settingsStore.aiProvider}`)
}
return new DeepSeekProvider({
apiKey: settingsStore.aiApiKey,
model: settingsStore.aiModel,
})
}
export const isAiReady = (settingsStore = useSettingsStore()) =>
!!settingsStore.aiAutoCategoryEnabled && !!settingsStore.aiApiKey
export const maybeEnrichTransactionWithAi = async (transaction) => {
if (!transaction?.id) return transaction
const settingsStore = useSettingsStore()
if (!isAiReady(settingsStore)) {
return transaction
}
try {
const provider = createProvider(settingsStore)
const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id)
const messages = buildTransactionEnrichmentMessages({
transaction,
similarTransactions,
})
const result = await provider.enrichTransaction({ messages })
const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold)
const updated = await applyAiEnrichment(transaction.id, result.normalized, {
applyCategory: result.normalized.confidence >= threshold,
model: result.model,
})
return updated || transaction
} catch (error) {
console.warn('[ai] transaction enrichment failed', error)
const failed = await markTransactionAiFailed(
transaction.id,
error?.message || 'AI enrichment failed',
settingsStore.aiModel,
)
return failed || transaction
}
}

View File

@@ -0,0 +1,55 @@
import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js'
import { AiProvider } from './aiProvider.js'
export class DeepSeekProvider extends AiProvider {
constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) {
super()
this.apiKey = apiKey
this.baseUrl = baseUrl.replace(/\/$/, '')
this.model = model
}
async enrichTransaction({ messages }) {
if (!this.apiKey) {
throw new Error('DeepSeek API key is required')
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
temperature: 0.2,
response_format: { type: 'json_object' },
messages,
}),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim())
}
const data = await response.json()
const content = data?.choices?.[0]?.message?.content
if (!content) {
throw new Error('DeepSeek returned an empty response')
}
let parsed
try {
parsed = JSON.parse(content)
} catch {
throw new Error('DeepSeek returned invalid JSON')
}
return {
raw: parsed,
normalized: normalizeEnrichmentResult(parsed),
model: data?.model || this.model,
}
}
}