diff --git a/apps/backend/.env.example b/apps/backend/.env.example index c5ad5ef..0d59044 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -11,3 +11,12 @@ NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm GEMINI_API_KEY= OCR_PROJECT_ID= FILE_STORAGE_BUCKET= + +# DeepSeek AI (OpenAI compatible) +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat + +# Feature flags +AI_ENABLE_CLASSIFICATION=false +AI_AUTO_LEARN_RULES=false diff --git a/apps/backend/.env.production b/apps/backend/.env.production index 8141900..9b25bf2 100644 --- a/apps/backend/.env.production +++ b/apps/backend/.env.production @@ -15,6 +15,12 @@ NOTIFICATION_WEBHOOK_SECRET=bf921ce9ac433ba2fdfccbccb50e7d805ac89f166962e4b8437a NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm # Optional integrations (leave blank if unused) -GEMINI_API_KEY= -OCR_PROJECT_ID= -FILE_STORAGE_BUCKET= + +# DeepSeek AI (OpenAI compatible) +DEEPSEEK_API_KEY=sk-aa152188952b4708a087693af9082721 +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat + +# Feature flags +AI_ENABLE_CLASSIFICATION=false +AI_AUTO_LEARN_RULES=false diff --git a/apps/backend/package.json b/apps/backend/package.json index e24bb30..ea23408 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", + "openai": "^4.58.1", "mongoose": "^7.6.0", "multer": "^1.4.5-lts.1", "pino": "^9.5.0", diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index 77dee2d..7203646 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -28,6 +28,11 @@ const envSchema = z }) .transform((values) => ({ ...values, + DEEPSEEK_API_KEY: (values as any).DEEPSEEK_API_KEY as string | undefined, + DEEPSEEK_BASE_URL: ((values as any).DEEPSEEK_BASE_URL as string | undefined) ?? 'https://api.deepseek.com', + DEEPSEEK_MODEL: ((values as any).DEEPSEEK_MODEL as string | undefined) ?? 'deepseek-chat', + AI_ENABLE_CLASSIFICATION: ((values as any).AI_ENABLE_CLASSIFICATION as string | undefined) === 'true', + AI_AUTO_LEARN_RULES: ((values as any).AI_AUTO_LEARN_RULES as string | undefined) === 'true', isProduction: values.NODE_ENV === 'production', notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES ? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean) diff --git a/apps/backend/src/modules/notifications/notification-rules.service.ts b/apps/backend/src/modules/notifications/notification-rules.service.ts index d73d269..91dd1f8 100644 --- a/apps/backend/src/modules/notifications/notification-rules.service.ts +++ b/apps/backend/src/modules/notifications/notification-rules.service.ts @@ -145,4 +145,3 @@ export async function applyNotificationRules(input: { return { ignore: false, overrides: {} }; } - diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts index a1d4f5a..97e6989 100644 --- a/apps/backend/src/modules/transactions/transactions.controller.ts +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -11,6 +11,9 @@ import { ingestNotification } from './transactions.service.js'; import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js'; +import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js'; +import { createRule } from '../notifications/notification-rules.service.js'; +import { TransactionModel } from '../../models/transaction.model.js'; export const listTransactionsHandler = async (req: Request, res: Response) => { if (!req.user) { @@ -104,3 +107,91 @@ export const createTransactionFromNotificationHandler = async (req: Request, res data: transaction }); }; + +export const aiClassifyTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + const id = req.params.id; + const txn = await getTransactionById(id, req.user.id); + if (!txn) return res.status(404).json({ message: 'Transaction not found' }); + if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' }); + const bodyText = (txn.notes as string | undefined) ?? (txn.metadata as any)?.rawBody ?? ''; + const ai = await aiClassifyByText(txn.title, bodyText); + if (!ai || (!ai.category && !ai.type && !ai.amount)) { + return res.status(200).json({ data: txn, message: 'No AI classification available' }); + } + const updated = await updateTransaction(id, { + category: ai.category ?? txn.category, + type: (ai.type as any) ?? txn.type, + amount: ai.amount ?? txn.amount + } as UpdateTransactionInput, req.user.id); + return res.json({ data: updated }); +}; + +export const createBlacklistRuleFromTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + const id = req.params.id; + const txn = await getTransactionById(id, req.user.id); + if (!txn) return res.status(404).json({ message: 'Transaction not found' }); + if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' }); + const pkg = (txn.metadata as any)?.packageName as string | undefined; + const title = txn.title; + if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' }); + const rule = await createRule({ + name: `黑名单-${pkg}-${title.substring(0, 10)}`, + enabled: true, + priority: 95, + packageName: pkg, + keywords: [title], + action: 'ignore' + } as any); + return res.status(201).json({ data: rule }); +}; + +export const createRuleFromTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + const id = req.params.id; + const txn = await getTransactionById(id, req.user.id); + if (!txn) return res.status(404).json({ message: 'Transaction not found' }); + if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' }); + const pkg = (txn.metadata as any)?.packageName as string | undefined; + const title = txn.title; + if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' }); + const rule = await createRule({ + name: `规则-${pkg}-${title.substring(0, 10)}`, + enabled: true, + priority: 80, + packageName: pkg, + keywords: [title], + action: 'record', + category: (txn as any).category, + type: (txn as any).type + } as any); + return res.status(201).json({ data: rule }); +}; + +export const aiSuggestRuleFromTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + const id = req.params.id; + const txn = await getTransactionById(id, req.user.id); + if (!txn) return res.status(404).json({ message: 'Transaction not found' }); + if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' }); + const pkg = (txn.metadata as any)?.packageName as string | undefined; + const body = (txn.metadata as any)?.rawBody as string | undefined; + const title = txn.title; + if (!pkg) return res.status(400).json({ message: 'Missing notification metadata' }); + const suggestion = await aiSuggestRuleForNotification({ packageName: pkg, title: title ?? '', body: body ?? '' }); + if (!suggestion) return res.status(200).json({ message: 'No AI suggestion' }); + const rule = await createRule({ + name: suggestion.name ?? `AI规则-${pkg}-${Date.now()}`, + enabled: suggestion.enabled ?? false, + priority: suggestion.priority ?? 60, + packageName: pkg, + keywords: suggestion.keywords ?? [], + pattern: suggestion.pattern ?? undefined, + action: suggestion.action ?? 'record', + category: suggestion.category ?? undefined, + type: suggestion.type ?? undefined, + amountPattern: suggestion.amountPattern ?? undefined + } as any); + return res.status(201).json({ data: rule }); +}; diff --git a/apps/backend/src/modules/transactions/transactions.router.ts b/apps/backend/src/modules/transactions/transactions.router.ts index 83289a7..307410a 100644 --- a/apps/backend/src/modules/transactions/transactions.router.ts +++ b/apps/backend/src/modules/transactions/transactions.router.ts @@ -13,7 +13,11 @@ import { getTransactionHandler, updateTransactionHandler, deleteTransactionHandler, - createTransactionFromNotificationHandler + createTransactionFromNotificationHandler, + aiClassifyTransactionHandler, + createBlacklistRuleFromTransactionHandler, + createRuleFromTransactionHandler, + aiSuggestRuleFromTransactionHandler } from './transactions.controller.js'; const router = Router(); @@ -27,5 +31,9 @@ router.post('/', validateRequest({ body: createTransactionSchema }), createTrans router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler); router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler); router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler); +router.post('/:id/ai-classify', validateRequest({ params: transactionIdSchema }), aiClassifyTransactionHandler); +router.post('/:id/rules/blacklist', validateRequest({ params: transactionIdSchema }), createBlacklistRuleFromTransactionHandler); +router.post('/:id/rules/from-notification', validateRequest({ params: transactionIdSchema }), createRuleFromTransactionHandler); +router.post('/:id/rules/ai-suggest', validateRequest({ params: transactionIdSchema }), aiSuggestRuleFromTransactionHandler); export default router; diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index 76ec0ae..36c43b4 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -7,7 +7,9 @@ import { getChannelByPackage } from '../notifications/notification-channels.serv import { getDefaultUserId } from '../../services/user-context.js'; import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js'; import { parseNotificationPayload } from './notification.parser.js'; -import { applyNotificationRules } from '../notifications/notification-rules.service.js'; +import { applyNotificationRules, createRule } from '../notifications/notification-rules.service.js'; +import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js'; +import { env } from '../../config/env.js'; export interface TransactionDto { id: string; @@ -187,6 +189,14 @@ export async function ingestNotification(payload: NotificationPayload): Promise< const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate); + // Optionally enhance with AI classification + if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) { + const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? ''); + if (ai?.category) parsed.category = ai.category; + if (ai?.type) parsed.type = ai.type; + if (ai?.amount && (!parsed.amount || parsed.amount === 0)) parsed.amount = ai.amount; + } + const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`; const notificationHash = createHash('sha256').update(hashSource).digest('hex'); @@ -219,5 +229,36 @@ export async function ingestNotification(payload: NotificationPayload): Promise< }; const userId = await getDefaultUserId(); - return createTransaction(transactionPayload, userId); + const created = await createTransaction(transactionPayload, userId); + + // Auto-learn rule (as draft) in background + if (env.AI_AUTO_LEARN_RULES) { + void (async () => { + try { + const suggestion = await aiSuggestRuleForNotification({ + packageName: payload.packageName, + title: payload.title ?? '', + body: payload.body ?? '' + }); + if (suggestion && (suggestion.keywords?.length || suggestion.pattern)) { + await createRule({ + name: suggestion.name ?? `AI规则-${payload.packageName}-${Date.now()}`, + enabled: suggestion.enabled ?? false, + priority: suggestion.priority ?? 60, + packageName: payload.packageName, + keywords: suggestion.keywords ?? [], + pattern: suggestion.pattern ?? undefined, + action: suggestion.action ?? 'record', + category: suggestion.category ?? undefined, + type: suggestion.type ?? undefined, + amountPattern: suggestion.amountPattern ?? undefined + } as any); + } + } catch (e) { + // swallow errors + } + })(); + } + + return created; } diff --git a/apps/backend/src/services/ai.service.ts b/apps/backend/src/services/ai.service.ts new file mode 100644 index 0000000..4d1a507 --- /dev/null +++ b/apps/backend/src/services/ai.service.ts @@ -0,0 +1,102 @@ +import OpenAI from 'openai'; +import { env } from '../config/env.js'; +import { logger } from '../config/logger.js'; + +const ai = env.DEEPSEEK_API_KEY + ? new OpenAI({ apiKey: env.DEEPSEEK_API_KEY, baseURL: env.DEEPSEEK_BASE_URL }) + : null; + +export interface AiClassification { + category?: string; + type?: 'income' | 'expense'; + amount?: number; + confidence?: number; // 0-1 +} + +export interface AiRuleSuggestion { + enabled?: boolean; + action?: 'record' | 'ignore'; + name?: string; + priority?: number; + packageName?: string; + keywords?: string[]; + pattern?: string; + category?: string; + type?: 'income' | 'expense'; + amountPattern?: string; + confidence?: number; // 0-1 +} + +function safeJson(text: string): T | null { + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} + +export async function aiClassifyByText(title: string, body?: string): Promise { + if (!ai) return null; + const prompt = `你是一个财务助手。请从通知文本中判断账单类型与分类,并尽量提取金额。 +只返回JSON,不要任何多余解释。例如:{"category":"餐饮","type":"expense","amount":25.5,"confidence":0.8} +常见分类:餐饮、交通、购物、生活缴费、医疗、教育、娱乐、转账、退款、理财、公益、收入、其他支出。 +如果无法判断,保持相应字段为空或省略,confidence 介于 0 到 1。 + +标题: ${title}\n正文: ${body ?? ''}`; + + try { + const res = await ai.chat.completions.create({ + model: env.DEEPSEEK_MODEL, + messages: [ + { role: 'system', content: 'You are a helpful assistant that outputs only JSON.' }, + { role: 'user', content: prompt } + ] + }); + const content = res.choices?.[0]?.message?.content ?? ''; + const parsed = safeJson(content); + return parsed ?? null; + } catch (error) { + logger.warn({ error }, 'AI classify failed'); + return null; + } +} + +export async function aiSuggestRuleForNotification(input: { + packageName: string; + title: string; + body: string; +}): Promise { + if (!ai) return null; + const prompt = `你是一个通知解析助手。根据通知标题与正文,生成一个用于匹配的规则建议,返回纯JSON: +{ + "enabled": false, + "action": "record" | "ignore", + "name": "简短规则名", + "priority": 60, + "keywords": ["多个关键词可选"], + "pattern": "可选的正则表达式(不要加分隔符)", + "category": "可选 分类", + "type": "income" | "expense", + "amountPattern": "可选,金额捕获组", + "confidence": 0.0-1.0 +} +说明:keywords 与 pattern 二选一或都给;pattern 不要包含 /;若该通知应忽略,请 action 设为 "ignore"。 +标题: ${input.title}\n正文: ${input.body}\n包名: ${input.packageName}`; + + try { + const res = await ai.chat.completions.create({ + model: env.DEEPSEEK_MODEL, + messages: [ + { role: 'system', content: 'You are a helpful assistant that outputs only JSON.' }, + { role: 'user', content: prompt } + ] + }); + const content = res.choices?.[0]?.message?.content ?? ''; + const parsed = safeJson(content); + return parsed ?? null; + } catch (error) { + logger.warn({ error }, 'AI rule suggestion failed'); + return null; + } +} + diff --git a/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue b/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue index 5cc67c8..f0b1763 100644 --- a/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue +++ b/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue @@ -5,6 +5,7 @@ import LucideIcon from '../../../components/common/LucideIcon.vue'; import { useTransactionQuery } from '../../../composables/useTransaction'; import { useUpdateTransactionMutation } from '../../../composables/useTransactions'; import { PRESET_CATEGORIES } from '../../../lib/categories'; +import { apiClient } from '../../../lib/api/client'; const route = useRoute(); const router = useRouter(); @@ -27,6 +28,53 @@ const saveCategory = async () => { await updateMutation.mutateAsync({ id: txn.value.id, payload: { category: editing.category || '未分类' } }); await query.refetch(); }; + +const feedback = ref(null); +const show = (msg: string) => { + feedback.value = msg; + setTimeout(() => (feedback.value = null), 2000); +}; + +const useAiClassify = async () => { + if (!txn.value?.id) return; + try { + await apiClient.post(`/transactions/${txn.value.id}/ai-classify`); + await query.refetch(); + show('已使用 AI 分类'); + } catch (e) { + show('AI 分类失败'); + } +}; + +const createBlacklistRule = async () => { + if (!txn.value?.id) return; + try { + await apiClient.post(`/transactions/${txn.value.id}/rules/blacklist`); + show('已加入黑名单规则'); + } catch (e) { + show('创建黑名单失败'); + } +}; + +const createRuleFromNotification = async () => { + if (!txn.value?.id) return; + try { + await apiClient.post(`/transactions/${txn.value.id}/rules/from-notification`); + show('已根据该通知创建规则'); + } catch (e) { + show('创建规则失败'); + } +}; + +const aiSuggestRule = async () => { + if (!txn.value?.id) return; + try { + await apiClient.post(`/transactions/${txn.value.id}/rules/ai-suggest`); + show('已创建 AI 规则草案'); + } catch (e) { + show('AI 规则分析失败'); + } +};