feat: 添加 AI 分类功能,支持交易自动分类与标签生成
This commit is contained in:
5
src/services/ai/aiProvider.js
Normal file
5
src/services/ai/aiProvider.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export class AiProvider {
|
||||
async enrichTransaction() {
|
||||
throw new Error('AI provider is not implemented')
|
||||
}
|
||||
}
|
||||
63
src/services/ai/aiService.js
Normal file
63
src/services/ai/aiService.js
Normal 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
|
||||
}
|
||||
}
|
||||
55
src/services/ai/deepseekProvider.js
Normal file
55
src/services/ai/deepseekProvider.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user