feat:新增ai分析,新增储值账户,优化通知规则
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user