feat:新增ai分析,新增储值账户,优化通知规则

This commit is contained in:
2026-03-12 14:03:01 +08:00
parent 6a00875246
commit 6ca962a187
22 changed files with 1294 additions and 237 deletions

View File

@@ -1,9 +1,13 @@
import { buildTransactionEnrichmentMessages } from '../../lib/aiPrompt.js'
import {
buildFinanceChatMessages,
buildTransactionEnrichmentMessages,
} from '../../lib/aiPrompt.js'
import { useSettingsStore } from '../../stores/settings.js'
import {
applyAiEnrichment,
fetchRecentTransactionsForAi,
markTransactionAiFailed,
markTransactionAiRunning,
} from '../transactionService.js'
import { DeepSeekProvider } from './deepseekProvider.js'
@@ -24,11 +28,13 @@ const createProvider = (settingsStore) => {
})
}
export const isAiReady = (settingsStore = useSettingsStore()) =>
!!settingsStore.aiAutoCategoryEnabled && !!settingsStore.aiApiKey
export const hasAiAccess = (settingsStore = useSettingsStore()) => !!settingsStore.aiApiKey
export const maybeEnrichTransactionWithAi = async (transaction) => {
if (!transaction?.id) return transaction
export const isAiReady = (settingsStore = useSettingsStore()) =>
!!settingsStore.aiAutoCategoryEnabled && hasAiAccess(settingsStore)
export const maybeEnrichTransactionWithAi = async (transaction, options = {}) => {
if (!transaction?.id || transaction.entryType === 'transfer') return transaction
const settingsStore = useSettingsStore()
if (!isAiReady(settingsStore)) {
@@ -36,11 +42,16 @@ export const maybeEnrichTransactionWithAi = async (transaction) => {
}
try {
const running = await markTransactionAiRunning(transaction.id, settingsStore.aiModel)
if (running) {
options.onProgress?.(running)
}
const provider = createProvider(settingsStore)
const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id)
const messages = buildTransactionEnrichmentMessages({
transaction,
similarTransactions,
similarTransactions: similarTransactions.filter((item) => item.entryType !== 'transfer'),
})
const result = await provider.enrichTransaction({ messages })
const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold)
@@ -61,3 +72,19 @@ export const maybeEnrichTransactionWithAi = async (transaction) => {
return failed || transaction
}
}
export const askFinanceAssistant = async ({ question, transactions = [], conversation = [] }) => {
const settingsStore = useSettingsStore()
if (!hasAiAccess(settingsStore)) {
throw new Error('请先在设置页填写 DeepSeek API Key')
}
const provider = createProvider(settingsStore)
const messages = buildFinanceChatMessages({
question,
transactions,
conversation,
})
return provider.chat({ messages })
}

View File

@@ -1,6 +1,17 @@
import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js'
import { AiProvider } from './aiProvider.js'
const extractMessageContent = (data) => {
const content = data?.choices?.[0]?.message?.content
if (Array.isArray(content)) {
return content
.map((item) => (typeof item?.text === 'string' ? item.text : ''))
.join('')
.trim()
}
return typeof content === 'string' ? content.trim() : ''
}
export class DeepSeekProvider extends AiProvider {
constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) {
super()
@@ -9,7 +20,7 @@ export class DeepSeekProvider extends AiProvider {
this.model = model
}
async enrichTransaction({ messages }) {
async createCompletion(payload) {
if (!this.apiKey) {
throw new Error('DeepSeek API key is required')
}
@@ -22,9 +33,7 @@ export class DeepSeekProvider extends AiProvider {
},
body: JSON.stringify({
model: this.model,
temperature: 0.2,
response_format: { type: 'json_object' },
messages,
...payload,
}),
})
@@ -33,8 +42,17 @@ export class DeepSeekProvider extends AiProvider {
throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim())
}
const data = await response.json()
const content = data?.choices?.[0]?.message?.content
return response.json()
}
async enrichTransaction({ messages }) {
const data = await this.createCompletion({
temperature: 0.2,
response_format: { type: 'json_object' },
messages,
})
const content = extractMessageContent(data)
if (!content) {
throw new Error('DeepSeek returned an empty response')
}
@@ -52,4 +70,22 @@ export class DeepSeekProvider extends AiProvider {
model: data?.model || this.model,
}
}
async chat({ messages, temperature = 0.6, maxTokens = 1200 }) {
const data = await this.createCompletion({
temperature,
max_tokens: maxTokens,
messages,
})
const content = extractMessageContent(data)
if (!content) {
throw new Error('DeepSeek returned an empty response')
}
return {
content,
model: data?.model || this.model,
}
}
}

View File

@@ -1,4 +1,5 @@
import notificationRulesSource from '../config/notificationRules.js'
import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js'
import notificationRulesSource from '../config/notificationRules.js'
const fallbackRules = notificationRulesSource.map((rule) => ({
...rule,
@@ -10,13 +11,36 @@ const fallbackRules = notificationRulesSource.map((rule) => ({
const amountPatterns = [
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
]
let cachedRules = [...fallbackRules]
let remoteRuleFetcher = async () => []
const buildLedgerPayload = (rule, notification, merchant) => {
const entryType = rule?.entryType || 'expense'
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
const channelName = notification?.channel || ''
const resolveName = (field, fromChannelFlag) => {
if (rule?.[fromChannelFlag] && channelName) return channelName
return rule?.[field] || defaults[field] || ''
}
return {
entryType,
fundSourceType: rule?.fundSourceType || defaults.fundSourceType,
fundSourceName: resolveName('fundSourceName', 'fundSourceNameFromChannel'),
fundTargetType: rule?.fundTargetType || defaults.fundTargetType,
fundTargetName:
resolveName('fundTargetName', 'fundTargetNameFromChannel') ||
(entryType === 'expense' ? merchant : defaults.fundTargetName),
impactExpense: rule?.impactExpense ?? defaults.impactExpense,
impactIncome: rule?.impactIncome ?? defaults.impactIncome,
}
}
const buildEmptyTransaction = (notification, options = {}) => ({
id: options.id,
merchant: options.merchant || 'Unknown',
@@ -25,6 +49,7 @@ const buildEmptyTransaction = (notification, options = {}) => ({
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
note: notification?.text || '',
syncStatus: 'pending',
...DEFAULT_LEDGER_BY_ENTRY_TYPE.expense,
})
export const setRemoteRuleFetcher = (fetcher) => {
@@ -78,6 +103,13 @@ const looksLikeAmount = (raw = '') => {
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
}
const hasTransitRouteAmountFallback = (rule, text = '') => {
if (rule?.id !== 'transit-card-expense') return false
return /[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[:]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)/.test(
text,
)
}
const extractMerchant = (text, rule) => {
const content = text || ''
@@ -134,7 +166,8 @@ const matchRule = (notification, rule) => {
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
const requiredTextHit =
!rule.requiredTextPatterns?.length ||
rule.requiredTextPatterns.some((word) => text.includes(word))
rule.requiredTextPatterns.some((word) => text.includes(word)) ||
hasTransitRouteAmountFallback(rule, text)
return channelHit && keywordHit && requiredTextHit
}
@@ -161,8 +194,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
}
const direction = options.direction || rule.direction || 'expense'
const normalizedAmount =
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
if (merchant === 'Unknown' && rule.defaultMerchant) {
@@ -170,6 +202,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
}
const date = options.date || notification?.createdAt || new Date().toISOString()
const ledger = buildLedgerPayload(rule, notification, merchant)
const transaction = {
id: options.id,
@@ -179,6 +212,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
date: new Date(date).toISOString(),
note: notification?.text || '',
syncStatus: 'pending',
...ledger,
}
const requiresConfirmation =

View File

@@ -1,4 +1,8 @@
import { v4 as uuidv4 } from 'uuid'
import { v4 as uuidv4 } from 'uuid'
import {
DEFAULT_LEDGER_BY_ENTRY_TYPE,
getAccountTypeLabel,
} from '../config/ledger.js'
import { getDb, saveDbToStore } from '../lib/sqlite'
const statusMap = {
@@ -15,6 +19,13 @@ const TRANSACTION_SELECT_FIELDS = `
date,
note,
sync_status,
entry_type,
fund_source_type,
fund_source_name,
fund_target_type,
fund_target_name,
impact_expense,
impact_income,
ai_category,
ai_tags,
ai_confidence,
@@ -31,7 +42,7 @@ const normalizeStatus = (value) => {
}
const normalizeAiStatus = (value) => {
if (['applied', 'suggested', 'failed'].includes(value)) {
if (['running', 'applied', 'suggested', 'failed'].includes(value)) {
return value
}
return 'idle'
@@ -47,22 +58,68 @@ const parseAiTags = (value) => {
}
}
const mapRow = (row) => ({
id: row.id,
amount: Number(row.amount),
merchant: row.merchant,
category: row.category || 'Uncategorized',
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 normalizeFlag = (value, fallback = false) => {
if (value === 1 || value === true || value === '1') return true
if (value === 0 || value === false || value === '0') return false
return fallback
}
const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense')
const buildLedgerDefaults = (payload = {}, existing = null) => {
const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0)
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
return {
entryType,
fundSourceType: payload.fundSourceType || existing?.fundSourceType || defaults.fundSourceType,
fundSourceName:
payload.fundSourceName ||
existing?.fundSourceName ||
defaults.fundSourceName ||
getAccountTypeLabel(defaults.fundSourceType),
fundTargetType: payload.fundTargetType || existing?.fundTargetType || defaults.fundTargetType,
fundTargetName:
payload.fundTargetName ||
existing?.fundTargetName ||
defaults.fundTargetName ||
getAccountTypeLabel(defaults.fundTargetType),
impactExpense:
payload.impactExpense ?? existing?.impactExpense ?? defaults.impactExpense ?? entryType === 'expense',
impactIncome:
payload.impactIncome ?? existing?.impactIncome ?? defaults.impactIncome ?? entryType === 'income',
}
}
const mapRow = (row) => {
const aiConfidenceValue = Number(row.ai_confidence)
const entryType = row.entry_type || inferEntryTypeFromAmount(row.amount)
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
return {
id: row.id,
amount: Number(row.amount),
merchant: row.merchant,
category: row.category || 'Uncategorized',
date: row.date,
note: row.note || '',
syncStatus: normalizeStatus(row.sync_status),
entryType,
fundSourceType: row.fund_source_type || defaults.fundSourceType,
fundSourceName: row.fund_source_name || defaults.fundSourceName || '',
fundTargetType: row.fund_target_type || defaults.fundTargetType,
fundTargetName: row.fund_target_name || defaults.fundTargetName || '',
impactExpense: normalizeFlag(row.impact_expense, defaults.impactExpense),
impactIncome: normalizeFlag(row.impact_income, defaults.impactIncome),
aiCategory: row.ai_category || '',
aiTags: parseAiTags(row.ai_tags),
aiConfidence: Number.isFinite(aiConfidenceValue) ? aiConfidenceValue : 0,
aiReason: row.ai_reason || '',
aiStatus: normalizeAiStatus(row.ai_status),
aiModel: row.ai_model || '',
aiNormalizedMerchant: row.ai_normalized_merchant || '',
}
}
const getMerchantKey = (merchant) =>
String(merchant || '')
@@ -190,19 +247,37 @@ export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') =>
export const insertTransaction = async (payload) => {
const db = await getDb()
const id = payload.id || uuidv4()
const amount = Number(payload.amount)
const ledger = buildLedgerDefaults(payload)
const sanitized = {
merchant: payload.merchant?.trim() || 'Unknown',
category: payload.category?.trim() || 'Uncategorized',
note: payload.note?.trim() || '',
date: payload.date,
amount: Number(payload.amount),
amount,
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
...ledger,
}
await db.run(
`
INSERT INTO transactions (id, amount, merchant, category, date, note, sync_status)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO transactions (
id,
amount,
merchant,
category,
date,
note,
sync_status,
entry_type,
fund_source_type,
fund_source_name,
fund_target_type,
fund_target_name,
impact_expense,
impact_income
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
@@ -212,6 +287,13 @@ export const insertTransaction = async (payload) => {
sanitized.date,
sanitized.note || null,
sanitized.syncStatus,
sanitized.entryType,
sanitized.fundSourceType,
sanitized.fundSourceName || null,
sanitized.fundTargetType,
sanitized.fundTargetName || null,
sanitized.impactExpense ? 1 : 0,
sanitized.impactIncome ? 1 : 0,
],
)
@@ -232,20 +314,31 @@ export const insertTransaction = async (payload) => {
}
export const updateTransaction = async (payload) => {
const existing = await getTransactionById(payload.id)
if (!existing) return null
const db = await getDb()
const ledger = buildLedgerDefaults(payload, existing)
await db.run(
`
UPDATE transactions
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ?
WHERE id = ?
`,
[
Number(payload.amount),
payload.merchant?.trim() || 'Unknown',
payload.category?.trim() || 'Uncategorized',
payload.merchant?.trim() || existing.merchant || 'Unknown',
payload.category?.trim() || existing.category || 'Uncategorized',
payload.date,
payload.note?.trim() || null,
statusMap[payload.syncStatus] ?? statusMap.pending,
ledger.entryType,
ledger.fundSourceType,
ledger.fundSourceName || null,
ledger.fundTargetType,
ledger.fundTargetName || null,
ledger.impactExpense ? 1 : 0,
ledger.impactIncome ? 1 : 0,
payload.id,
],
)
@@ -254,6 +347,21 @@ export const updateTransaction = async (payload) => {
return getTransactionById(payload.id)
}
export const markTransactionAiRunning = async (transactionId, model = '') => {
const db = await getDb()
await db.run(
`
UPDATE transactions
SET ai_status = ?, ai_model = ?, ai_reason = ?
WHERE id = ?
`,
['running', model || null, null, transactionId],
)
await saveDbToStore()
return getTransactionById(transactionId)
}
export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => {
const existing = await getTransactionById(transactionId)
if (!existing) return null
@@ -282,7 +390,10 @@ export const applyAiEnrichment = async (transactionId, enrichment, options = {})
],
)
if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) {
if (
!options.skipMerchantProfile &&
(enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)
) {
await upsertMerchantProfile({
merchant: existing.merchant,
normalizedMerchant: enrichment.normalizedMerchant,