Commit 854a1442 authored by ThinhNC's avatar ThinhNC

feat: add ai-assistant module with schemas, validation, controller, routes, and services

parent 93ab2d01
import { currencyExchangeRateSchema } from './ai-assistant.validation';
import { exchangeRateResponseSchema } from './ai-assistant-response.validation';
import { AIAssistantService } from './ai-assistant.service';
import { getAIProvider } from '../../common/ai/ai-provider.factory';
import { systemSettingService } from '../system-settings/system-setting.service';
jest.mock('../../common/ai/ai-provider.factory', () => ({
getAIProvider: jest.fn(),
}));
jest.mock('./admin-ai.repository', () => ({
adminAiRepository: {
createLog: jest.fn().mockResolvedValue({}),
},
}));
jest.mock('../system-settings/system-setting.service', () => ({
systemSettingService: {
getBoolean: jest.fn().mockResolvedValue(true),
},
}));
describe('Currency Exchange Rate AI & Validation Tests', () => {
describe('currencyExchangeRateSchema', () => {
it('should validate and transform valid currency codes', () => {
const result = currencyExchangeRateSchema.parse({
from: 'usd',
to: 'vnd',
amount: 100,
});
expect(result.from).toBe('USD');
expect(result.to).toBe('VND');
expect(result.amount).toBe(100);
});
it('should default amount to 1 when omitted', () => {
const result = currencyExchangeRateSchema.parse({
from: 'EUR',
to: 'USD',
});
expect(result.amount).toBe(1);
});
it('should reject invalid currency codes', () => {
expect(() =>
currencyExchangeRateSchema.parse({
from: 'US',
to: 'VND',
}),
).toThrow();
expect(() =>
currencyExchangeRateSchema.parse({
from: '123',
to: 'VND',
}),
).toThrow();
});
it('should reject non-positive amounts', () => {
expect(() =>
currencyExchangeRateSchema.parse({
from: 'USD',
to: 'VND',
amount: -5,
}),
).toThrow();
});
});
describe('exchangeRateResponseSchema', () => {
it('should validate structured AI response', () => {
const parsed = exchangeRateResponseSchema.parse({
from: 'USD',
to: 'VND',
rate: 25450,
note: 'Tỷ giá thị trường tự do tham khảo',
});
expect(parsed.from).toBe('USD');
expect(parsed.to).toBe('VND');
expect(parsed.rate).toBe(25450);
expect(parsed.note).toBe('Tỷ giá thị trường tự do tham khảo');
});
it('should accept string rate and convert to number', () => {
const parsed = exchangeRateResponseSchema.parse({
from: 'EUR',
to: 'USD',
rate: '1.085',
});
expect(parsed.rate).toBe(1.085);
});
});
describe('AIAssistantService.getExchangeRate', () => {
let service: AIAssistantService;
beforeEach(() => {
jest.clearAllMocks();
(systemSettingService.getBoolean as jest.Mock).mockResolvedValue(true);
service = new AIAssistantService();
});
it('should return identity rate 1 with 0 tokens when from equals to', async () => {
const result = await service.getExchangeRate('test-user-id', {
from: 'USD',
to: 'USD',
amount: 50,
});
expect(result.data.from).toBe('USD');
expect(result.data.to).toBe('USD');
expect(result.data.rate).toBe(1);
expect(result.data.amount).toBe(50);
expect(result.data.convertedAmount).toBe(50);
expect(result.meta.usage.totalTokens).toBe(0);
expect(getAIProvider).not.toHaveBeenCalled();
});
it('should call AI provider when currencies differ and return accurate calculation', async () => {
const mockGenerate = jest.fn().mockResolvedValue({
data: {
from: 'USD',
to: 'VND',
rate: 25400,
note: 'Tỷ giá tham khảo Vietcombank',
},
provider: 'gemini',
model: 'gemini-1.5-flash',
usage: { promptTokens: 35, completionTokens: 18, totalTokens: 53 },
});
(getAIProvider as jest.Mock).mockReturnValue({
generateStructured: mockGenerate,
});
const result = await service.getExchangeRate('test-user-id', {
from: 'USD',
to: 'VND',
amount: 10,
});
expect(mockGenerate).toHaveBeenCalled();
expect(result.data.from).toBe('USD');
expect(result.data.to).toBe('VND');
expect(result.data.rate).toBe(25400);
expect(result.data.amount).toBe(10);
expect(result.data.convertedAmount).toBe(254000);
expect(result.data.formattedRate).toContain('1 USD = 25,400 VND');
expect(result.data.note).toBe('Tỷ giá tham khảo Vietcombank');
expect(result.meta.provider).toBe('gemini');
expect(result.meta.usage.totalTokens).toBe(53);
});
});
});
......@@ -166,3 +166,15 @@ export const recommendationsJsonSchema = {
},
},
} satisfies Record<string, unknown>;
export const exchangeRateJsonSchema = {
type: 'object',
additionalProperties: false,
required: ['from', 'to', 'rate', 'note'],
properties: {
from: { type: 'string' },
to: { type: 'string' },
rate: { type: 'number' },
note: { type: 'string' },
},
} satisfies Record<string, unknown>;
......@@ -205,3 +205,13 @@ export const recommendationsResponseSchema = z.object({
})).max(10)
),
});
export const exchangeRateResponseSchema = z.object({
from: currencySchema,
to: currencySchema,
rate: z.preprocess(
(val) => (typeof val === 'string' ? parseFloat(val) : val),
z.number().positive(),
),
note: z.preprocess(cleanStringOrNull, z.string().trim().max(500).nullable()).optional(),
});
import { NextFunction, Request, Response } from 'express';
import {
CategorizeTransactionDto,
CurrencyExchangeRateDto,
ExtractReceiptDto,
FinancialChatDto,
FinancialInsightsDto,
......@@ -75,4 +76,16 @@ export class AIAssistantController {
next(error);
}
};
getExchangeRate = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getExchangeRate(
req.user.id,
req.body as CurrencyExchangeRateDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
}
......@@ -32,6 +32,22 @@ export interface FinancialRecommendationsDto extends AIAnalysisScopeDto {
priority: 'BALANCED' | 'REDUCE_SPENDING' | 'GROW_SAVINGS';
}
export interface CurrencyExchangeRateDto {
from: string;
to: string;
amount?: number;
}
export interface ExchangeRateResultDto {
from: string;
to: string;
rate: number;
amount: number;
convertedAmount: number;
formattedRate?: string;
note?: string;
}
export interface AIResponseMetaDto {
provider: string;
model: string;
......
......@@ -8,6 +8,7 @@ import { receiptUploadMiddleware } from '../transactions/transaction-upload.midd
import { AIAssistantController } from './ai-assistant.controller';
import {
categorizeTransactionSchema,
currencyExchangeRateSchema,
extractReceiptSchema,
financialChatSchema,
financialInsightsSchema,
......@@ -43,5 +44,10 @@ router.post(
validate(financialRecommendationsSchema),
controller.recommend,
);
router.post(
'/exchange-rate',
validate(currencyExchangeRateSchema),
controller.getExchangeRate,
);
export default router;
......@@ -16,11 +16,12 @@ import {
AIAnalysisScopeDto,
AIServiceResult,
CategorizeTransactionDto,
CurrencyExchangeRateDto,
ExchangeRateResultDto,
ExtractReceiptDto,
FinancialChatDto,
FinancialInsightsDto,
FinancialRecommendationsDto,
} from './ai-assistant.dto';
import {
AIAssistantRepository,
......@@ -30,6 +31,7 @@ import {
import {
chatJsonSchema,
classificationJsonSchema,
exchangeRateJsonSchema,
insightsJsonSchema,
receiptJsonSchema,
recommendationsJsonSchema,
......@@ -37,6 +39,7 @@ import {
import {
chatResponseSchema,
classificationResponseSchema,
exchangeRateResponseSchema,
insightsResponseSchema,
receiptResponseSchema,
recommendationsResponseSchema,
......@@ -235,6 +238,71 @@ export class AIAssistantService {
);
}
async getExchangeRate(
userId: string,
input: CurrencyExchangeRateDto,
): Promise<AIServiceResult<ExchangeRateResultDto>> {
const from = input.from.toUpperCase().trim();
const to = input.to.toUpperCase().trim();
const amount = input.amount !== undefined && input.amount > 0 ? input.amount : 1;
// Optimization: When base and target currencies are identical, bypass AI call (0 tokens)
if (from === to) {
return {
data: {
from,
to,
rate: 1,
amount,
convertedAmount: amount,
formattedRate: `1 ${from} = 1 ${to}`,
note: 'Tỷ giá giữa cùng một loại tiền tệ',
},
meta: {
provider: 'system',
model: 'identity',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
},
};
}
const systemInstruction = [
'You are a professional financial assistant specializing in foreign exchange rates and currency markets.',
'Provide the most accurate, realistic real-time or prevailing market exchange rate for the requested currency pair.',
'Rate must represent: 1 unit of base currency [from] = how many units of target currency [to].',
'For example: 1 USD to VND is approximately 25,400, so rate is 25400. 1 VND to USD is approximately 0.000039.',
'Return a positive number for rate. Keep note concise (under 200 chars), explaining the approximate reference market/date or rate source in Vietnamese.',
].join(' ');
const promptText = `Provide the current accurate market exchange rate from ${from} to ${to}. Rate represents how many ${to} equal 1 ${from}.`;
const response = await this.generate(
{
systemInstruction,
parts: [{ text: promptText }],
responseJsonSchema: exchangeRateJsonSchema,
},
exchangeRateResponseSchema,
{ userId, feature: 'CURRENCY_EXCHANGE_RATE' },
);
const rate = response.data.rate;
const convertedAmount = Number((amount * rate).toFixed(4));
return {
data: {
from,
to,
rate,
amount,
convertedAmount,
formattedRate: `1 ${from} = ${rate.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${to}`,
note: response.data.note ?? undefined,
},
meta: response.meta,
};
}
private async generateWithContext<TSchema extends z.ZodTypeAny>(
context: ResolvedContext,
prompt: string,
......
......@@ -93,3 +93,23 @@ export const financialRecommendationsSchema = z
.default('BALANCED'),
})
.superRefine(validateDateRange);
export const currencyExchangeRateSchema = z.object({
from: z
.string({ required_error: 'From currency is required' })
.trim()
.length(3, 'From currency must be a 3-letter code')
.regex(/^[A-Za-z]{3}$/, 'From currency must contain only letters')
.transform((value) => value.toUpperCase()),
to: z
.string({ required_error: 'To currency is required' })
.trim()
.length(3, 'To currency must be a 3-letter code')
.regex(/^[A-Za-z]{3}$/, 'To currency must contain only letters')
.transform((value) => value.toUpperCase()),
amount: z
.number()
.positive('Amount must be positive')
.optional()
.default(1),
});
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