feat:AI分析页面接入AI
This commit is contained in:
@@ -22,5 +22,5 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
|
||||
# Feature flags
|
||||
AI_ENABLE_CLASSIFICATION=false
|
||||
AI_AUTO_LEARN_RULES=false
|
||||
AI_ENABLE_CLASSIFICATION=true
|
||||
AI_AUTO_LEARN_RULES=true
|
||||
|
||||
@@ -7,6 +7,7 @@ import { router } from './routes/index.js';
|
||||
import { notFoundHandler } from './middlewares/not-found.js';
|
||||
import { errorHandler } from './middlewares/error-handler.js';
|
||||
import { logger } from './config/logger.js';
|
||||
import { auditLogMiddleware } from './middlewares/audit-log.js';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
@@ -34,6 +35,9 @@ export function createApp() {
|
||||
})
|
||||
);
|
||||
|
||||
// Request/response audit log (after parsers, before routes)
|
||||
app.use(auditLogMiddleware);
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'AI Bill API',
|
||||
|
||||
@@ -24,15 +24,16 @@ const envSchema = z
|
||||
NOTIFICATION_ALLOWED_PACKAGES: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
OCR_PROJECT_ID: z.string().optional(),
|
||||
FILE_STORAGE_BUCKET: z.string().optional()
|
||||
FILE_STORAGE_BUCKET: z.string().optional(),
|
||||
// DeepSeek / AI
|
||||
DEEPSEEK_API_KEY: z.string().optional(),
|
||||
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
|
||||
DEEPSEEK_MODEL: z.string().default('deepseek-chat'),
|
||||
AI_ENABLE_CLASSIFICATION: z.coerce.boolean().default(false),
|
||||
AI_AUTO_LEARN_RULES: z.coerce.boolean().default(false)
|
||||
})
|
||||
.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)
|
||||
|
||||
64
apps/backend/src/middlewares/audit-log.ts
Normal file
64
apps/backend/src/middlewares/audit-log.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { logger } from '../config/logger.js';
|
||||
|
||||
const REDACT_KEYS = new Set(['password', 'token', 'refreshToken', 'authorization', 'apiKey', 'secret']);
|
||||
|
||||
function redact(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (Array.isArray(value)) return value.map((v) => redact(v));
|
||||
if (typeof value === 'object') {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
obj[k] = REDACT_KEYS.has(k.toLowerCase()) ? '***redacted***' : redact(v);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function truncate(str: unknown, max = 2000): unknown {
|
||||
if (typeof str !== 'string') return str;
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + `…(+${str.length - max} chars)`;
|
||||
}
|
||||
|
||||
export function auditLogMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const start = Date.now();
|
||||
const reqInfo = {
|
||||
method: req.method,
|
||||
url: req.originalUrl || req.url,
|
||||
query: redact(req.query),
|
||||
body: redact(req.body)
|
||||
};
|
||||
|
||||
// Capture response body by monkey-patching res.json/res.send
|
||||
let responseBody: unknown;
|
||||
const originalJson = res.json.bind(res);
|
||||
const originalSend = res.send.bind(res);
|
||||
|
||||
res.json = ((body?: any) => {
|
||||
responseBody = body;
|
||||
return originalJson(body);
|
||||
}) as typeof res.json;
|
||||
|
||||
res.send = ((body?: any) => {
|
||||
responseBody = body;
|
||||
return originalSend(body);
|
||||
}) as typeof res.send;
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const status = res.statusCode;
|
||||
let out: unknown = responseBody;
|
||||
if (typeof out === 'string') {
|
||||
// Try to avoid logging massive HTML or binary-like outputs
|
||||
out = truncate(out, 2000);
|
||||
} else if (typeof out === 'object') {
|
||||
out = redact(out);
|
||||
}
|
||||
logger.info({ ...reqInfo, status, durationMs: duration, response: out }, 'HTTP');
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
31
apps/backend/src/modules/ai/ai.controller.ts
Normal file
31
apps/backend/src/modules/ai/ai.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../../config/env.js';
|
||||
import { aiClassifyByText } from '../../services/ai.service.js';
|
||||
|
||||
const classifySchema = z.object({
|
||||
title: z.string().min(1),
|
||||
body: z.string().optional()
|
||||
});
|
||||
|
||||
export async function getAiStatusHandler(_req: Request, res: Response) {
|
||||
const configured = Boolean(env.DEEPSEEK_API_KEY);
|
||||
return res.json({
|
||||
data: {
|
||||
configured,
|
||||
enableClassification: env.AI_ENABLE_CLASSIFICATION,
|
||||
autoLearnRules: env.AI_AUTO_LEARN_RULES,
|
||||
model: env.DEEPSEEK_MODEL,
|
||||
baseURL: env.DEEPSEEK_BASE_URL
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function classifyTextHandler(req: Request, res: Response) {
|
||||
if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' });
|
||||
const { title, body } = classifySchema.parse(req.body);
|
||||
const data = await aiClassifyByText(title, body);
|
||||
if (!data) return res.status(200).json({ message: 'No AI classification available' });
|
||||
return res.json({ data });
|
||||
}
|
||||
|
||||
13
apps/backend/src/modules/ai/ai.router.ts
Normal file
13
apps/backend/src/modules/ai/ai.router.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { getAiStatusHandler, classifyTextHandler } from './ai.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/status', getAiStatusHandler);
|
||||
router.post('/classify', classifyTextHandler);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -110,10 +110,10 @@ export const createTransactionFromNotificationHandler = async (req: Request, res
|
||||
|
||||
export const aiClassifyTransactionHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||
if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' });
|
||||
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)) {
|
||||
@@ -176,6 +176,7 @@ export const createRuleFromTransactionHandler = async (req: Request, res: Respon
|
||||
|
||||
export const aiSuggestRuleFromTransactionHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||
if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' });
|
||||
const id = req.params.id;
|
||||
const txn = await getTransactionById(id, req.user.id);
|
||||
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
|
||||
|
||||
@@ -68,19 +68,36 @@ export async function createTransaction(payload: CreateTransactionInput, userId?
|
||||
const needsAutoCategory =
|
||||
!payload.category || ['未分类', '待分类', '待分類', '类别待定', '待确认'].includes(payload.category);
|
||||
const classification = needsAutoCategory ? classifyByText(payload.title, payload.notes) : null;
|
||||
let aiClassification: Awaited<ReturnType<typeof aiClassifyByText>> | null = null;
|
||||
if (needsAutoCategory && env.AI_ENABLE_CLASSIFICATION) {
|
||||
try {
|
||||
aiClassification = await aiClassifyByText(payload.title, payload.notes ?? '');
|
||||
} catch {
|
||||
aiClassification = null;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedCategory = classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出');
|
||||
const resolvedType = classification?.type ?? payload.type;
|
||||
const resolvedCategory =
|
||||
aiClassification?.category ?? classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出');
|
||||
const resolvedType = (aiClassification?.type as any) ?? classification?.type ?? payload.type;
|
||||
const metadata =
|
||||
classification && classification.ruleId
|
||||
aiClassification
|
||||
? {
|
||||
...payload.metadata,
|
||||
classification: {
|
||||
ruleId: classification.ruleId,
|
||||
confidence: classification.confidence
|
||||
source: 'ai',
|
||||
confidence: aiClassification.confidence
|
||||
}
|
||||
}
|
||||
: payload.metadata;
|
||||
: classification && classification.ruleId
|
||||
? {
|
||||
...payload.metadata,
|
||||
classification: {
|
||||
ruleId: classification.ruleId,
|
||||
confidence: classification.confidence
|
||||
}
|
||||
}
|
||||
: payload.metadata;
|
||||
|
||||
const resolvedUserId = userId ?? payload.userId ?? (await getDefaultUserId());
|
||||
const { userId: _ignoredUserId, ...restPayload } = payload;
|
||||
|
||||
@@ -6,6 +6,7 @@ import budgetsRouter from '../modules/budgets/budgets.router.js';
|
||||
import notificationsRouter from '../modules/notifications/notifications.router.js';
|
||||
import notificationRulesRouter from '../modules/notifications/notification-rules.router.js';
|
||||
import categoriesRouter from '../modules/categories/categories.router.js';
|
||||
import aiRouter from '../modules/ai/ai.router.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -20,5 +21,6 @@ router.use('/budgets', budgetsRouter);
|
||||
router.use('/notifications', notificationsRouter);
|
||||
router.use('/notification-rules', notificationRulesRouter);
|
||||
router.use('/categories', categoriesRouter);
|
||||
router.use('/ai', aiRouter);
|
||||
|
||||
export { router };
|
||||
|
||||
@@ -27,12 +27,32 @@ export interface AiRuleSuggestion {
|
||||
confidence?: number; // 0-1
|
||||
}
|
||||
|
||||
function safeJson<T = unknown>(text: string): T | null {
|
||||
function extractJson<T = unknown>(text: string): T | null {
|
||||
// Try direct parse
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} catch {}
|
||||
|
||||
// Try code fences
|
||||
const fence = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
if (fence?.[1]) {
|
||||
try {
|
||||
return JSON.parse(fence[1]) as T;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Try first {...} block
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const slice = text.slice(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(slice) as T;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
logger.warn({ preview: text?.slice(0, 160) }, 'AI JSON parse failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function aiClassifyByText(title: string, body?: string): Promise<AiClassification | null> {
|
||||
@@ -45,15 +65,19 @@ export async function aiClassifyByText(title: string, body?: string): Promise<Ai
|
||||
标题: ${title}\n正文: ${body ?? ''}`;
|
||||
|
||||
try {
|
||||
logger.info({ model: env.DEEPSEEK_MODEL, titlePreview: title?.slice(0, 80), bodyPreview: (body ?? '').slice(0, 120) }, 'AI classify request');
|
||||
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 }
|
||||
]
|
||||
],
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' } as any
|
||||
});
|
||||
const content = res.choices?.[0]?.message?.content ?? '';
|
||||
const parsed = safeJson<AiClassification>(content);
|
||||
const parsed = extractJson<AiClassification>(content);
|
||||
logger.info({ contentPreview: content.slice(0, 200), parsed }, 'AI classify response');
|
||||
return parsed ?? null;
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'AI classify failed');
|
||||
@@ -84,19 +108,22 @@ export async function aiSuggestRuleForNotification(input: {
|
||||
标题: ${input.title}\n正文: ${input.body}\n包名: ${input.packageName}`;
|
||||
|
||||
try {
|
||||
logger.info({ model: env.DEEPSEEK_MODEL, packageName: input.packageName, titlePreview: input.title?.slice(0, 80) }, 'AI suggest rule request');
|
||||
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 }
|
||||
]
|
||||
],
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' } as any
|
||||
});
|
||||
const content = res.choices?.[0]?.message?.content ?? '';
|
||||
const parsed = safeJson<AiRuleSuggestion>(content);
|
||||
const parsed = extractJson<AiRuleSuggestion>(content);
|
||||
logger.info({ contentPreview: content.slice(0, 200), parsed }, 'AI suggest rule response');
|
||||
return parsed ?? null;
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'AI rule suggestion failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
apps/frontend/src/composables/useAi.ts
Normal file
37
apps/frontend/src/composables/useAi.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
|
||||
export interface AiStatus {
|
||||
configured: boolean;
|
||||
enableClassification: boolean;
|
||||
autoLearnRules: boolean;
|
||||
model: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
export interface AiClassification {
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amount?: number;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export function useAiStatusQuery() {
|
||||
return useQuery<AiStatus>({
|
||||
queryKey: ['ai-status'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/ai/status');
|
||||
return data.data as AiStatus;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useAiClassifyMutation() {
|
||||
return useMutation<AiClassification, unknown, { title: string; body?: string }>({
|
||||
mutationFn: async (payload) => {
|
||||
const { data } = await apiClient.post('/ai/classify', payload);
|
||||
return data.data as AiClassification;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useSpendingInsightQuery, useCalorieEstimationMutation } from '../../../composables/useAnalysis';
|
||||
import { useAiStatusQuery, useAiClassifyMutation } from '../../../composables/useAi';
|
||||
|
||||
const range = ref<'30d' | '90d'>('30d');
|
||||
const { data: insight } = useSpendingInsightQuery(range);
|
||||
@@ -43,6 +44,22 @@ const submitCalorieQuery = async () => {
|
||||
calorieForm.query = '';
|
||||
}
|
||||
};
|
||||
|
||||
// AI 文本分类(用于测试通知/自由文本)
|
||||
const aiStatusQuery = useAiStatusQuery();
|
||||
const aiClassify = useAiClassifyMutation();
|
||||
const aiForm = reactive({ title: '', body: '' });
|
||||
const aiResult = ref<string>('');
|
||||
|
||||
const submitAiClassify = async () => {
|
||||
if (!aiForm.title.trim() && !aiForm.body.trim()) return;
|
||||
try {
|
||||
const res = await aiClassify.mutateAsync({ title: aiForm.title.trim() || '(无标题)', body: aiForm.body.trim() || undefined });
|
||||
aiResult.value = `分类: ${res.category ?? '未知'}\n类型: ${res.type ?? '-'}\n金额: ${res.amount ?? '-'}\n置信度: ${res.confidence ?? '-'}`;
|
||||
} catch (error: any) {
|
||||
aiResult.value = error?.response?.data?.message ?? 'AI 服务不可用';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,5 +162,24 @@ const submitCalorieQuery = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">AI 文本分类</h2>
|
||||
<span class="text-xs" :class="aiStatusQuery.data.value?.configured ? 'text-emerald-600' : 'text-red-500'">
|
||||
{{ aiStatusQuery.data.value?.configured ? `已启用 · ${aiStatusQuery.data.value?.model}` : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<input v-model="aiForm.title" type="text" class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="标题,如:支付宝支付成功" />
|
||||
<textarea v-model="aiForm.body" rows="3" class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="正文,如:已支付 25.50 元"></textarea>
|
||||
<div class="flex justify-end">
|
||||
<button class="px-4 py-2 rounded-xl bg-indigo-500 text-white disabled:opacity-60" :disabled="aiClassify.isPending.value || !aiStatusQuery.data.value?.configured" @click="submitAiClassify">
|
||||
{{ aiClassify.isPending.value ? '分析中...' : '测试分类' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-if="aiResult" class="text-sm bg-gray-50 rounded-2xl p-4 whitespace-pre-wrap">{{ aiResult }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user