feat:AI分析页面接入AI

This commit is contained in:
2025-11-13 17:08:31 +08:00
parent 71eba31740
commit 15a4fa618f
12 changed files with 256 additions and 23 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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)

View 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();
}

View 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 });
}

View 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;

View File

@@ -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' });

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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;
}
}

View 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;
}
});
}

View File

@@ -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>