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,
}
}
}

View File

@@ -7,12 +7,46 @@ const statusMap = {
error: 2,
}
const TRANSACTION_SELECT_FIELDS = `
id,
amount,
merchant,
category,
date,
note,
sync_status,
ai_category,
ai_tags,
ai_confidence,
ai_reason,
ai_status,
ai_model,
ai_normalized_merchant
`
const normalizeStatus = (value) => {
if (value === 1) return 'synced'
if (value === 2) return 'error'
return 'pending'
}
const normalizeAiStatus = (value) => {
if (['applied', 'suggested', 'failed'].includes(value)) {
return value
}
return 'idle'
}
const parseAiTags = (value) => {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
} catch {
return []
}
}
const mapRow = (row) => ({
id: row.id,
amount: Number(row.amount),
@@ -21,13 +55,92 @@ const mapRow = (row) => ({
date: row.date,
note: row.note || '',
syncStatus: normalizeStatus(row.sync_status),
aiCategory: row.ai_category || '',
aiTags: parseAiTags(row.ai_tags),
aiConfidence: typeof row.ai_confidence === 'number' ? row.ai_confidence : Number(row.ai_confidence || 0),
aiReason: row.ai_reason || '',
aiStatus: normalizeAiStatus(row.ai_status),
aiModel: row.ai_model || '',
aiNormalizedMerchant: row.ai_normalized_merchant || '',
})
const getMerchantKey = (merchant) =>
String(merchant || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.slice(0, 120)
const upsertMerchantProfile = async ({ merchant, normalizedMerchant, category, tags }) => {
const sourceName = normalizedMerchant || merchant
const merchantKey = getMerchantKey(sourceName)
if (!merchantKey) return
const db = await getDb()
const result = await db.query(
`
SELECT merchant_key, hit_count
FROM merchant_profiles
WHERE merchant_key = ?
`,
[merchantKey],
)
const hitCount = Number(result?.values?.[0]?.hit_count || 0)
const payload = {
normalizedMerchant: sourceName,
category: category || 'Uncategorized',
tags: JSON.stringify(Array.isArray(tags) ? tags : []),
updatedAt: new Date().toISOString(),
}
if (result?.values?.length) {
await db.run(
`
UPDATE merchant_profiles
SET normalized_merchant = ?, category = ?, tags = ?, hit_count = ?, updated_at = ?
WHERE merchant_key = ?
`,
[
payload.normalizedMerchant,
payload.category,
payload.tags,
hitCount + 1,
payload.updatedAt,
merchantKey,
],
)
return
}
await db.run(
`
INSERT INTO merchant_profiles (
merchant_key,
normalized_merchant,
category,
tags,
hit_count,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
`,
[
merchantKey,
payload.normalizedMerchant,
payload.category,
payload.tags,
1,
payload.updatedAt,
],
)
}
export const fetchTransactions = async () => {
const db = await getDb()
const result = await db.query(
`
SELECT id, amount, merchant, category, date, note, sync_status
SELECT ${TRANSACTION_SELECT_FIELDS}
FROM transactions
ORDER BY date DESC
`,
@@ -35,6 +148,45 @@ export const fetchTransactions = async () => {
return (result?.values || []).map(mapRow)
}
export const getTransactionById = async (id) => {
const db = await getDb()
const result = await db.query(
`
SELECT ${TRANSACTION_SELECT_FIELDS}
FROM transactions
WHERE id = ?
`,
[id],
)
const row = result?.values?.[0]
return row ? mapRow(row) : null
}
export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') => {
const db = await getDb()
const safeLimit = Math.max(1, Math.min(Number(limit) || 6, 20))
const params = []
const whereClause = excludeId ? 'WHERE id != ?' : ''
if (excludeId) {
params.push(excludeId)
}
params.push(safeLimit)
const result = await db.query(
`
SELECT ${TRANSACTION_SELECT_FIELDS}
FROM transactions
${whereClause}
ORDER BY date DESC
LIMIT ?
`,
params,
)
return (result?.values || []).map(mapRow)
}
export const insertTransaction = async (payload) => {
const db = await getDb()
const id = payload.id || uuidv4()
@@ -69,6 +221,13 @@ export const insertTransaction = async (payload) => {
id,
...sanitized,
syncStatus: payload.syncStatus || 'pending',
aiCategory: '',
aiTags: [],
aiConfidence: 0,
aiReason: '',
aiStatus: 'idle',
aiModel: '',
aiNormalizedMerchant: '',
}
}
@@ -92,18 +251,72 @@ export const updateTransaction = async (payload) => {
)
await saveDbToStore()
return getTransactionById(payload.id)
}
const result = await db.query(
export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => {
const existing = await getTransactionById(transactionId)
if (!existing) return null
const applyCategory = !!options.applyCategory
const nextCategory = applyCategory ? enrichment.category || existing.category : existing.category
const nextStatus = options.status || (applyCategory ? 'applied' : 'suggested')
const db = await getDb()
await db.run(
`
SELECT id, amount, merchant, category, date, note, sync_status
FROM transactions
UPDATE transactions
SET category = ?, ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ?
WHERE id = ?
`,
[payload.id],
[
nextCategory,
enrichment.category || null,
JSON.stringify(Array.isArray(enrichment.tags) ? enrichment.tags : []),
Number(enrichment.confidence || 0),
enrichment.reason || null,
nextStatus,
options.model || null,
enrichment.normalizedMerchant || null,
transactionId,
],
)
const updated = result?.values?.[0]
return updated ? mapRow(updated) : null
if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) {
await upsertMerchantProfile({
merchant: existing.merchant,
normalizedMerchant: enrichment.normalizedMerchant,
category: enrichment.category,
tags: enrichment.tags,
})
}
await saveDbToStore()
return getTransactionById(transactionId)
}
export const markTransactionAiFailed = async (transactionId, reason, model = '') => {
const db = await getDb()
await db.run(
`
UPDATE transactions
SET ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ?
WHERE id = ?
`,
[
null,
JSON.stringify([]),
0,
reason || 'AI enrichment failed',
'failed',
model || null,
null,
transactionId,
],
)
await saveDbToStore()
return getTransactionById(transactionId)
}
export const deleteTransaction = async (id) => {