Commit bc2abecc authored by ThinhNC's avatar ThinhNC

feat(ai): add Gemini-powered financial assistant

parent 132dba7b
...@@ -38,6 +38,15 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -38,6 +38,15 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
có thể tắt hoặc chỉnh chu kỳ bằng các biến `NOTIFICATION_*`. có thể tắt hoặc chỉnh chu kỳ bằng các biến `NOTIFICATION_*`.
- Giao dịch chi bất thường được cảnh báo khi có ít nhất 5 giao dịch lịch sử 90 ngày trong cùng - Giao dịch chi bất thường được cảnh báo khi có ít nhất 5 giao dịch lịch sử 90 ngày trong cùng
ví và số tiền mới đạt ít nhất 3 lần trung bình; đây là heuristic có thể thay bằng AI sau. ví và số tiền mới đạt ít nhất 3 lần trung bình; đây là heuristic có thể thay bằng AI sau.
- AI Financial Assistant là module read-only/stateless dưới `/api/v1/ai-assistant`, gồm phân loại
giao dịch, OCR hóa đơn, hỏi đáp, phân tích xu hướng/bất thường và khuyến nghị ngân sách/tiết kiệm.
Tích hợp AI đi qua `AIProvider`; adapter mặc định là Gemini REST và hỗ trợ danh sách key xoay vòng,
failover. Context không chứa thông tin profile, location hoặc receipt URL, có giới hạn dữ liệu,
output token, timeout và rate limit riêng; mọi structured output được Zod kiểm tra lại.
- Quy ước tổ chức validation của module AI: `ai-assistant.validation.ts` chỉ chứa Zod schema cho
HTTP input; `ai-assistant-response.validation.ts` chứa Zod schema kiểm tra output từ AI;
`ai-assistant-provider.schema.ts` chứa JSON Schema gửi cho provider. Service chỉ chọn và áp dụng
validator/schema theo use case, không khai báo Zod schema trực tiếp trong file service.
## Trạng thái đã biết ## Trạng thái đã biết
...@@ -50,6 +59,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -50,6 +59,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
thời trả các category hệ thống dùng chung. thời trả các category hệ thống dùng chung.
- Financial Reports có API tổng quan, chuỗi dòng tiền, cơ cấu chi tiêu theo danh mục và - Financial Reports có API tổng quan, chuỗi dòng tiền, cơ cấu chi tiêu theo danh mục và
hiệu quả ngân sách dưới `/api/v1/reports`. hiệu quả ngân sách dưới `/api/v1/reports`.
- AI Financial Assistant có 5 API đã mô tả trong Swagger. Module không cần model/migration mới;
cần cấu hình `GEMINI_API_KEYS` để gọi provider, nếu thiếu thì chỉ các AI endpoint trả lỗi 503.
- Các migration `20260728170000_improve_wallet_management` - Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category; `20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index `20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
......
...@@ -57,11 +57,14 @@ Express router ...@@ -57,11 +57,14 @@ Express router
## Phạm vi hiện tại ## Phạm vi hiện tại
- Route hoạt động: health, auth, users, wallets, categories, transactions, budgets, - Route hoạt động: health, auth, users, wallets, categories, transactions, budgets,
saving goals và financial reports. saving goals, financial reports và AI Financial Assistant.
- Wallet, Category, Transaction và Budget có module API theo ownership trong - Wallet, Category, Transaction và Budget có module API theo ownership trong
`src/modules/`. `src/modules/`.
- Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency, - Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database. không lưu snapshot báo cáo riêng trong database.
- AI Financial Assistant cung cấp phân loại giao dịch, OCR hóa đơn, hỏi đáp tài chính,
phân tích xu hướng/bất thường và khuyến nghị. Module chỉ đọc dữ liệu thuộc người dùng,
không lưu hội thoại hoặc kết quả AI và truy cập mô hình qua provider interface.
- Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận; - Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận;
Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền. Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm - Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
......
...@@ -34,3 +34,13 @@ RECEIPT_MAX_FILE_SIZE_MB=5 ...@@ -34,3 +34,13 @@ RECEIPT_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000 NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000 NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
AI_PROVIDER=gemini
GEMINI_API_KEYS=replace_with_key_1,replace_with_key_2
GEMINI_MODEL=gemini-2.5-flash
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
AI_REQUEST_TIMEOUT_MS=30000
AI_MAX_OUTPUT_TOKENS=2048
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_WINDOW_MS=900000
import { envConfig } from '../../config/env.config';
import { AIProvider, AIProviderError } from './ai-provider';
import { GeminiProvider } from './gemini.provider';
let provider: AIProvider | undefined;
export function getAIProvider(): AIProvider {
if (provider) {
return provider;
}
if (envConfig.ai.provider !== 'gemini') {
throw new AIProviderError(
'NOT_CONFIGURED',
`Unsupported AI provider: ${envConfig.ai.provider}`,
);
}
provider = new GeminiProvider({
apiKeys: envConfig.ai.geminiApiKeys,
model: envConfig.ai.geminiModel,
baseUrl: envConfig.ai.geminiBaseUrl,
timeoutMs: envConfig.ai.requestTimeoutMs,
defaultMaxOutputTokens: envConfig.ai.maxOutputTokens,
});
return provider;
}
export interface AIInlineData {
mimeType: string;
data: string;
}
export type AIContentPart =
| { text: string }
| { inlineData: AIInlineData };
export interface AIGenerateRequest {
systemInstruction: string;
parts: AIContentPart[];
responseJsonSchema: Record<string, unknown>;
temperature?: number;
maxOutputTokens?: number;
}
export interface AIUsage {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}
export interface AIGenerateResponse {
data: unknown;
provider: string;
model: string;
usage: AIUsage;
}
export type AIProviderErrorReason =
| 'NOT_CONFIGURED'
| 'UNAVAILABLE'
| 'INVALID_RESPONSE';
export class AIProviderError extends Error {
constructor(public readonly reason: AIProviderErrorReason, message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export interface AIProvider {
readonly name: string;
readonly model: string;
generateStructured(request: AIGenerateRequest): Promise<AIGenerateResponse>;
}
import {
AIGenerateRequest,
AIGenerateResponse,
AIProvider,
AIProviderError,
} from './ai-provider';
interface GeminiProviderOptions {
apiKeys: string[];
model: string;
baseUrl: string;
timeoutMs: number;
defaultMaxOutputTokens: number;
}
interface GeminiResponse {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>;
};
}>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
}
class GeminiHttpError extends Error {
constructor(
public readonly status: number,
public readonly retryable: boolean,
) {
super(`Gemini request failed with status ${status}`);
}
}
export class GeminiProvider implements AIProvider {
readonly name = 'gemini';
readonly model: string;
private readonly apiKeys: string[];
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly defaultMaxOutputTokens: number;
private nextKeyIndex = 0;
constructor(options: GeminiProviderOptions) {
this.apiKeys = options.apiKeys;
this.model = options.model;
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.timeoutMs = options.timeoutMs;
this.defaultMaxOutputTokens = options.defaultMaxOutputTokens;
}
async generateStructured(request: AIGenerateRequest): Promise<AIGenerateResponse> {
if (this.apiKeys.length === 0) {
throw new AIProviderError(
'NOT_CONFIGURED',
'The AI provider has not been configured',
);
}
const startIndex = this.nextKeyIndex % this.apiKeys.length;
this.nextKeyIndex = (this.nextKeyIndex + 1) % this.apiKeys.length;
let lastError: unknown;
for (let offset = 0; offset < this.apiKeys.length; offset += 1) {
const keyIndex = (startIndex + offset) % this.apiKeys.length;
try {
return await this.requestWithKey(this.apiKeys[keyIndex], request);
} catch (error) {
lastError = error;
if (error instanceof AIProviderError && error.reason === 'INVALID_RESPONSE') {
throw error;
}
if (error instanceof GeminiHttpError && !error.retryable) {
break;
}
}
}
if (lastError instanceof AIProviderError) {
throw lastError;
}
throw new AIProviderError(
'UNAVAILABLE',
'The AI provider is temporarily unavailable',
);
}
private async requestWithKey(
apiKey: string,
request: AIGenerateRequest,
): Promise<AIGenerateResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(
`${this.baseUrl}/models/${encodeURIComponent(this.model)}:generateContent`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
systemInstruction: {
parts: [{ text: request.systemInstruction }],
},
contents: [{ role: 'user', parts: request.parts }],
generationConfig: {
temperature: request.temperature ?? 0.2,
maxOutputTokens:
request.maxOutputTokens ?? this.defaultMaxOutputTokens,
responseMimeType: 'application/json',
responseJsonSchema: request.responseJsonSchema,
},
}),
signal: controller.signal,
},
);
if (!response.ok) {
const retryable = response.status === 401
|| response.status === 403
|| response.status === 408
|| response.status === 429
|| response.status >= 500;
throw new GeminiHttpError(response.status, retryable);
}
const payload = await response.json() as GeminiResponse;
const text = payload.candidates?.[0]?.content?.parts
?.map((part) => part.text ?? '')
.join('')
.trim();
if (!text) {
throw new AIProviderError(
'INVALID_RESPONSE',
'The AI provider returned an empty response',
);
}
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new AIProviderError(
'INVALID_RESPONSE',
'The AI provider returned malformed structured data',
);
}
return {
data,
provider: this.name,
model: this.model,
usage: {
promptTokens: payload.usageMetadata?.promptTokenCount ?? null,
completionTokens: payload.usageMetadata?.candidatesTokenCount ?? null,
totalTokens: payload.usageMetadata?.totalTokenCount ?? null,
},
};
} catch (error) {
if (error instanceof AIProviderError || error instanceof GeminiHttpError) {
throw error;
}
throw new AIProviderError(
'UNAVAILABLE',
'The AI provider request timed out or could not be completed',
);
} finally {
clearTimeout(timeout);
}
}
}
...@@ -27,6 +27,10 @@ export const ERROR_CODE = { ...@@ -27,6 +27,10 @@ export const ERROR_CODE = {
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED', FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE', FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND', RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
AI_PROVIDER_NOT_CONFIGURED: 'AI_PROVIDER_NOT_CONFIGURED',
AI_PROVIDER_UNAVAILABLE: 'AI_PROVIDER_UNAVAILABLE',
AI_RESPONSE_INVALID: 'AI_RESPONSE_INVALID',
AI_RATE_LIMIT_EXCEEDED: 'AI_RATE_LIMIT_EXCEEDED',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND', CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED', CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED', PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
......
...@@ -59,4 +59,40 @@ export const envConfig = { ...@@ -59,4 +59,40 @@ export const envConfig = {
: 300000; : 300000;
})(), })(),
}, },
ai: {
provider: process.env.AI_PROVIDER || 'gemini',
geminiApiKeys: Array.from(new Set(
(process.env.GEMINI_API_KEYS || process.env.GEMINI_API_KEY || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
)),
geminiModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
geminiBaseUrl:
process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta',
requestTimeoutMs: (() => {
const value = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '30000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 120000 ? value : 30000;
})(),
maxOutputTokens: (() => {
const value = parseInt(process.env.AI_MAX_OUTPUT_TOKENS || '2048', 10);
return Number.isFinite(value) && value >= 256 && value <= 8192 ? value : 2048;
})(),
maxContextTransactions: (() => {
const value = parseInt(process.env.AI_MAX_CONTEXT_TRANSACTIONS || '200', 10);
return Number.isFinite(value) && value >= 20 && value <= 500 ? value : 200;
})(),
rateLimit: {
maxRequests: (() => {
const value = parseInt(process.env.AI_RATE_LIMIT_MAX_REQUESTS || '20', 10);
return Number.isFinite(value) && value >= 1 && value <= 500 ? value : 20;
})(),
windowMs: (() => {
const value = parseInt(process.env.AI_RATE_LIMIT_WINDOW_MS || '900000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 86400000
? value
: 900000;
})(),
},
},
}; };
This diff is collapsed.
import { NextFunction, Request, Response } from 'express';
import { ERROR_CODE } from '../common/errors/error-code';
import { envConfig } from '../config/env.config';
interface RateLimitRecord {
count: number;
resetAt: number;
}
const requestCounts = new Map<string, RateLimitRecord>();
let lastCleanupAt = 0;
export function aiRateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const key = req.user?.id || req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
if (now - lastCleanupAt >= envConfig.ai.rateLimit.windowMs) {
requestCounts.forEach((value, recordKey) => {
if (now >= value.resetAt) {
requestCounts.delete(recordKey);
}
});
lastCleanupAt = now;
}
const record = requestCounts.get(key);
if (!record || now >= record.resetAt) {
requestCounts.set(key, {
count: 1,
resetAt: now + envConfig.ai.rateLimit.windowMs,
});
next();
return;
}
record.count += 1;
if (record.count <= envConfig.ai.rateLimit.maxRequests) {
next();
return;
}
const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
message: 'AI request limit exceeded, please try again later',
code: ERROR_CODE.AI_RATE_LIMIT_EXCEEDED,
});
}
export const classificationJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['categoryId', 'confidence', 'reasoning'],
properties: {
categoryId: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 },
reasoning: { type: 'string' },
},
} satisfies Record<string, unknown>;
export const receiptJsonSchema = {
type: 'object',
additionalProperties: false,
required: [
'merchant',
'transactionDate',
'totalAmount',
'currency',
'taxAmount',
'categoryId',
'lineItems',
'rawText',
'confidence',
'warnings',
],
properties: {
merchant: { type: ['string', 'null'] },
transactionDate: { type: ['string', 'null'] },
totalAmount: { type: ['string', 'null'] },
currency: { type: ['string', 'null'] },
taxAmount: { type: ['string', 'null'] },
categoryId: { type: ['string', 'null'] },
lineItems: {
type: 'array',
maxItems: 100,
items: {
type: 'object',
additionalProperties: false,
required: ['name', 'quantity', 'unitPrice', 'totalAmount'],
properties: {
name: { type: 'string' },
quantity: { type: ['number', 'null'], minimum: 0 },
unitPrice: { type: ['string', 'null'] },
totalAmount: { type: ['string', 'null'] },
},
},
},
rawText: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 },
warnings: { type: 'array', maxItems: 10, items: { type: 'string' } },
},
} satisfies Record<string, unknown>;
export const chatJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['answer', 'highlights', 'caveats', 'suggestedActions'],
properties: {
answer: { type: 'string' },
highlights: { type: 'array', maxItems: 8, items: { type: 'string' } },
caveats: { type: 'array', maxItems: 8, items: { type: 'string' } },
suggestedActions: { type: 'array', maxItems: 8, items: { type: 'string' } },
},
} satisfies Record<string, unknown>;
export const insightsJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['summary', 'trends', 'anomalies', 'recommendations'],
properties: {
summary: { type: 'string' },
trends: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: ['title', 'direction', 'description', 'evidence'],
properties: {
title: { type: 'string' },
direction: { type: 'string', enum: ['UP', 'DOWN', 'STABLE'] },
description: { type: 'string' },
evidence: { type: 'string' },
},
},
},
anomalies: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: ['title', 'severity', 'description', 'evidence'],
properties: {
title: { type: 'string' },
severity: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
description: { type: 'string' },
evidence: { type: 'string' },
},
},
},
recommendations: {
type: 'array',
maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: ['title', 'priority', 'description'],
properties: {
title: { type: 'string' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
description: { type: 'string' },
},
},
},
},
} satisfies Record<string, unknown>;
export const recommendationsJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['summary', 'budgetRecommendations', 'savingRecommendations', 'actions'],
properties: {
summary: { type: 'string' },
budgetRecommendations: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
additionalProperties: false,
required: ['categoryName', 'currency', 'suggestedLimit', 'rationale'],
properties: {
categoryName: { type: ['string', 'null'] },
currency: { type: 'string' },
suggestedLimit: { type: 'string' },
rationale: { type: 'string' },
},
},
},
savingRecommendations: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
additionalProperties: false,
required: ['goalName', 'currency', 'suggestedMonthlyContribution', 'rationale'],
properties: {
goalName: { type: ['string', 'null'] },
currency: { type: 'string' },
suggestedMonthlyContribution: { type: 'string' },
rationale: { type: 'string' },
},
},
},
actions: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
additionalProperties: false,
required: ['title', 'priority', 'description'],
properties: {
title: { type: 'string' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
description: { type: 'string' },
},
},
},
},
} satisfies Record<string, unknown>;
import { z } from 'zod';
const MONEY_PATTERN = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
export type AIResponseValidator<T> = z.ZodType<T>;
export const classificationResponseSchema = z.object({
categoryId: z.string().uuid(),
confidence: z.number().min(0).max(1),
reasoning: z.string().trim().min(1).max(1000),
});
export const receiptResponseSchema = z.object({
merchant: z.string().trim().max(300).nullable(),
transactionDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(),
totalAmount: z.string().regex(MONEY_PATTERN).nullable(),
currency: z.string().regex(/^[A-Z]{3}$/).nullable(),
taxAmount: z.string().regex(MONEY_PATTERN).nullable(),
categoryId: z.string().uuid().nullable(),
lineItems: z.array(z.object({
name: z.string().trim().min(1).max(300),
quantity: z.number().nonnegative().nullable(),
unitPrice: z.string().regex(MONEY_PATTERN).nullable(),
totalAmount: z.string().regex(MONEY_PATTERN).nullable(),
})).max(100),
rawText: z.string().max(8000),
confidence: z.number().min(0).max(1),
warnings: z.array(z.string().trim().min(1).max(500)).max(10),
});
export const chatResponseSchema = z.object({
answer: z.string().trim().min(1).max(5000),
highlights: z.array(z.string().trim().min(1).max(500)).max(8),
caveats: z.array(z.string().trim().min(1).max(500)).max(8),
suggestedActions: z.array(z.string().trim().min(1).max(500)).max(8),
});
export const insightsResponseSchema = z.object({
summary: z.string().trim().min(1).max(3000),
trends: z.array(z.object({
title: z.string().trim().min(1).max(200),
direction: z.enum(['UP', 'DOWN', 'STABLE']),
description: z.string().trim().min(1).max(1000),
evidence: z.string().trim().min(1).max(1000),
})).max(8),
anomalies: z.array(z.object({
title: z.string().trim().min(1).max(200),
severity: z.enum(['LOW', 'MEDIUM', 'HIGH']),
description: z.string().trim().min(1).max(1000),
evidence: z.string().trim().min(1).max(1000),
})).max(8),
recommendations: z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
description: z.string().trim().min(1).max(1000),
})).max(8),
});
export const recommendationsResponseSchema = z.object({
summary: z.string().trim().min(1).max(3000),
budgetRecommendations: z.array(z.object({
categoryName: z.string().trim().min(1).max(200).nullable(),
currency: z.string().regex(/^[A-Z]{3}$/),
suggestedLimit: z.string().regex(MONEY_PATTERN),
rationale: z.string().trim().min(1).max(1000),
})).max(10),
savingRecommendations: z.array(z.object({
goalName: z.string().trim().min(1).max(200).nullable(),
currency: z.string().regex(/^[A-Z]{3}$/),
suggestedMonthlyContribution: z.string().regex(MONEY_PATTERN),
rationale: z.string().trim().min(1).max(1000),
})).max(10),
actions: z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
description: z.string().trim().min(1).max(1000),
})).max(10),
});
import { NextFunction, Request, Response } from 'express';
import {
CategorizeTransactionDto,
ExtractReceiptDto,
FinancialChatDto,
FinancialInsightsDto,
FinancialRecommendationsDto,
} from './ai-assistant.dto';
import { AIAssistantService } from './ai-assistant.service';
export class AIAssistantController {
private readonly service = new AIAssistantService();
categorizeTransaction = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const result = await this.service.categorizeTransaction(
req.user.id,
req.body as CategorizeTransactionDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
extractReceipt = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.extractReceipt(
req.user.id,
req.file!,
req.body as ExtractReceiptDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
chat = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.chat(
req.user.id,
req.body as FinancialChatDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
analyzeInsights = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.analyzeInsights(
req.user.id,
req.body as FinancialInsightsDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
recommend = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.recommend(
req.user.id,
req.body as FinancialRecommendationsDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
}
import { TransactionType } from '@prisma/client';
import { AIUsage } from '../../common/ai/ai-provider';
export interface AIAnalysisScopeDto {
dateFrom?: Date;
dateTo?: Date;
currency?: string;
}
export interface CategorizeTransactionDto {
description: string;
amount?: string;
type?: TransactionType;
merchant?: string;
occurredAt?: Date;
}
export interface ExtractReceiptDto {
languageHint?: string;
currencyHint?: string;
}
export interface FinancialChatDto extends AIAnalysisScopeDto {
question: string;
}
export interface FinancialInsightsDto extends AIAnalysisScopeDto {
focus: 'ALL' | 'SPENDING' | 'INCOME' | 'CASH_FLOW';
}
export interface FinancialRecommendationsDto extends AIAnalysisScopeDto {
priority: 'BALANCED' | 'REDUCE_SPENDING' | 'GROW_SAVINGS';
}
export interface AIResponseMetaDto {
provider: string;
model: string;
usage: AIUsage;
context?: {
from: Date;
to: Date;
currency: string | null;
transactionCount: number;
totalTransactionCount: number;
truncated: boolean;
};
}
export interface AIServiceResult<T> {
data: T;
meta: AIResponseMetaDto;
}
import { Prisma, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
const aiCategorySelect = {
id: true,
name: true,
type: true,
isSystem: true,
} satisfies Prisma.CategorySelect;
const aiWalletSelect = {
id: true,
name: true,
balance: true,
currency: true,
isDefault: true,
isArchived: true,
} satisfies Prisma.WalletSelect;
const aiTransactionSelect = {
walletId: true,
categoryId: true,
amount: true,
type: true,
description: true,
date: true,
wallet: {
select: {
name: true,
currency: true,
},
},
category: {
select: {
id: true,
name: true,
},
},
} satisfies Prisma.TransactionSelect;
const aiBudgetSelect = {
name: true,
amount: true,
currency: true,
type: true,
startDate: true,
endDate: true,
alertThreshold: true,
category: { select: { name: true } },
} satisfies Prisma.BudgetSelect;
const aiSavingGoalSelect = {
id: true,
name: true,
targetAmount: true,
currency: true,
targetDate: true,
status: true,
} satisfies Prisma.SavingGoalSelect;
export type AICategoryRecord = Prisma.CategoryGetPayload<{
select: typeof aiCategorySelect;
}>;
export type AIWalletRecord = Prisma.WalletGetPayload<{
select: typeof aiWalletSelect;
}>;
export type AITransactionRecord = Prisma.TransactionGetPayload<{
select: typeof aiTransactionSelect;
}>;
export type AIBudgetRecord = Prisma.BudgetGetPayload<{
select: typeof aiBudgetSelect;
}>;
export type AISavingGoalRecord = Prisma.SavingGoalGetPayload<{
select: typeof aiSavingGoalSelect;
}>;
export interface AISavingContributionSummary {
savingGoalId: string;
amount: Prisma.Decimal;
}
export interface AIFinancialContextRecord {
wallets: AIWalletRecord[];
categories: AICategoryRecord[];
transactions: AITransactionRecord[];
totalTransactionCount: number;
transactionSummaries: Array<{
walletId: string;
categoryId: string;
type: TransactionType;
amount: Prisma.Decimal;
count: number;
}>;
budgets: AIBudgetRecord[];
totalBudgetCount: number;
savingGoals: AISavingGoalRecord[];
totalSavingGoalCount: number;
contributionSummaries: AISavingContributionSummary[];
}
export class AIAssistantRepository {
findVisibleCategories(userId: string, type?: TransactionType, limit = 200) {
return prisma.category.findMany({
where: {
isArchived: false,
...(type ? { type } : {}),
OR: [
{ userId, isSystem: false },
{ userId: null, isSystem: true },
],
},
select: aiCategorySelect,
orderBy: [{ type: 'asc' }, { name: 'asc' }, { id: 'asc' }],
take: limit,
});
}
async getFinancialContext(
userId: string,
from: Date,
to: Date,
maxTransactions: number,
currency?: string,
): Promise<AIFinancialContextRecord> {
const transactionWhere: Prisma.TransactionWhereInput = {
userId,
date: { gte: from, lt: to },
...(currency ? { wallet: { currency } } : {}),
};
const [
wallets,
categories,
transactions,
totalTransactionCount,
groupedTransactions,
budgets,
totalBudgetCount,
savingGoals,
totalSavingGoalCount,
] =
await Promise.all([
prisma.wallet.findMany({
where: {
userId,
...(currency ? { currency } : {}),
},
select: aiWalletSelect,
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
}),
this.findVisibleCategories(userId, undefined, 500),
prisma.transaction.findMany({
where: transactionWhere,
select: aiTransactionSelect,
orderBy: [{ date: 'desc' }, { id: 'desc' }],
take: maxTransactions,
}),
prisma.transaction.count({ where: transactionWhere }),
prisma.transaction.groupBy({
by: ['walletId', 'categoryId', 'type'],
where: transactionWhere,
_sum: { amount: true },
_count: { _all: true },
}),
prisma.budget.findMany({
where: {
userId,
isArchived: false,
startDate: { lt: to },
endDate: { gt: from },
...(currency ? { currency } : {}),
},
select: aiBudgetSelect,
orderBy: [{ endDate: 'asc' }, { id: 'asc' }],
take: 100,
}),
prisma.budget.count({
where: {
userId,
isArchived: false,
startDate: { lt: to },
endDate: { gt: from },
...(currency ? { currency } : {}),
},
}),
prisma.savingGoal.findMany({
where: {
userId,
isArchived: false,
...(currency ? { currency } : {}),
},
select: aiSavingGoalSelect,
orderBy: [{ targetDate: 'asc' }, { id: 'asc' }],
take: 100,
}),
prisma.savingGoal.count({
where: {
userId,
isArchived: false,
...(currency ? { currency } : {}),
},
}),
]);
const goalIds = savingGoals.map((goal) => goal.id);
const groupedContributions = goalIds.length === 0
? []
: await prisma.savingContribution.groupBy({
by: ['savingGoalId'],
where: { savingGoalId: { in: goalIds } },
_sum: { amount: true },
});
return {
wallets,
categories,
transactions,
totalTransactionCount,
transactionSummaries: groupedTransactions.map((item) => ({
walletId: item.walletId,
categoryId: item.categoryId,
type: item.type,
amount: item._sum.amount ?? new Prisma.Decimal(0),
count: item._count._all,
})),
budgets,
totalBudgetCount,
savingGoals,
totalSavingGoalCount,
contributionSummaries: groupedContributions.map((item) => ({
savingGoalId: item.savingGoalId,
amount: item._sum.amount ?? new Prisma.Decimal(0),
})),
};
}
}
import { Router } from 'express';
import { aiRateLimitMiddleware } from '../../middlewares/ai-rate-limit.middleware';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { receiptUploadMiddleware } from '../transactions/transaction-upload.middleware';
import { AIAssistantController } from './ai-assistant.controller';
import {
categorizeTransactionSchema,
extractReceiptSchema,
financialChatSchema,
financialInsightsSchema,
financialRecommendationsSchema,
} from './ai-assistant.validation';
const router = Router();
const controller = new AIAssistantController();
router.use(authMiddleware);
router.use(aiRateLimitMiddleware);
router.post(
'/categorize',
validate(categorizeTransactionSchema),
controller.categorizeTransaction,
);
router.post(
'/receipts/extract',
receiptUploadMiddleware,
validate(extractReceiptSchema),
controller.extractReceipt,
);
router.post('/chat', validate(financialChatSchema), controller.chat);
router.post(
'/insights/analyze',
validate(financialInsightsSchema),
controller.analyzeInsights,
);
router.post(
'/recommendations',
validate(financialRecommendationsSchema),
controller.recommend,
);
export default router;
This diff is collapsed.
import { z } from 'zod';
const moneySchema = z
.string()
.trim()
.regex(/^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/, 'Amount must be a positive decimal value');
const optionalDateSchema = z.preprocess(
(value) => value === '' ? undefined : value,
z
.string()
.datetime({ offset: true, message: 'Date must be a valid ISO 8601 date-time' })
.transform((value) => new Date(value))
.optional(),
);
const optionalCurrencySchema = z.preprocess(
(value) => value === '' ? undefined : value,
z
.string()
.trim()
.length(3, 'Currency must contain exactly 3 letters')
.regex(/^[A-Za-z]{3}$/, 'Currency must contain only letters')
.transform((value) => value.toUpperCase())
.optional(),
);
const analysisScopeShape = {
dateFrom: optionalDateSchema,
dateTo: optionalDateSchema,
currency: optionalCurrencySchema,
};
function validateDateRange(
data: { dateFrom?: Date; dateTo?: Date },
context: z.RefinementCtx,
) {
if ((data.dateFrom && !data.dateTo) || (!data.dateFrom && data.dateTo)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [data.dateFrom ? 'dateTo' : 'dateFrom'],
message: 'dateFrom and dateTo must be provided together',
});
}
if (data.dateFrom && data.dateTo && data.dateFrom >= data.dateTo) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['dateTo'],
message: 'dateTo must be after dateFrom',
});
}
}
export const categorizeTransactionSchema = z.object({
description: z.string().trim().min(2).max(500),
amount: moneySchema.optional(),
type: z.enum(['INCOME', 'EXPENSE']).optional(),
merchant: z.string().trim().min(1).max(200).optional(),
occurredAt: optionalDateSchema,
});
export const extractReceiptSchema = z.object({
languageHint: z.preprocess(
(value) => value === '' ? undefined : value,
z.string().trim().min(2).max(20).optional(),
),
currencyHint: optionalCurrencySchema,
});
export const financialChatSchema = z
.object({
...analysisScopeShape,
question: z.string().trim().min(3).max(1000),
})
.superRefine(validateDateRange);
export const financialInsightsSchema = z
.object({
...analysisScopeShape,
focus: z
.enum(['ALL', 'SPENDING', 'INCOME', 'CASH_FLOW'])
.optional()
.default('ALL'),
})
.superRefine(validateDateRange);
export const financialRecommendationsSchema = z
.object({
...analysisScopeShape,
priority: z
.enum(['BALANCED', 'REDUCE_SPENDING', 'GROW_SAVINGS'])
.optional()
.default('BALANCED'),
})
.superRefine(validateDateRange);
...@@ -9,6 +9,7 @@ import savingGoalRoute from '../modules/saving-goals/saving-goal.route'; ...@@ -9,6 +9,7 @@ import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import reportRoute from '../modules/reports/report.route'; import reportRoute from '../modules/reports/report.route';
import notificationRoute from '../modules/notifications/notification.route'; import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route'; import reminderRoute from '../modules/reminders/reminder.route';
import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route';
const router = Router(); const router = Router();
...@@ -26,5 +27,6 @@ router.use('/saving-goals', savingGoalRoute); ...@@ -26,5 +27,6 @@ router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute); router.use('/reports', reportRoute);
router.use('/notifications', notificationRoute); router.use('/notifications', notificationRoute);
router.use('/reminders', reminderRoute); router.use('/reminders', reminderRoute);
router.use('/ai-assistant', aiAssistantRoute);
export default router; export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment