Commit a2887c4d authored by ThinhNC's avatar ThinhNC

feat: add AI assistant service, admin repository, and exchange rate tests

parent 854a1442
...@@ -19,9 +19,13 @@ export class AdminAiRepository { ...@@ -19,9 +19,13 @@ export class AdminAiRepository {
latencyMs: number; latencyMs: number;
errorMessage?: string | null; errorMessage?: string | null;
}) { }) {
const isValidUuid =
typeof data.userId === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data.userId);
return prisma.aiRequestLog.create({ return prisma.aiRequestLog.create({
data: { data: {
userId: data.userId ?? null, userId: isValidUuid ? data.userId : null,
feature: data.feature, feature: data.feature,
provider: data.provider, provider: data.provider,
model: data.model, model: data.model,
......
...@@ -121,39 +121,40 @@ describe('Currency Exchange Rate AI & Validation Tests', () => { ...@@ -121,39 +121,40 @@ describe('Currency Exchange Rate AI & Validation Tests', () => {
expect(getAIProvider).not.toHaveBeenCalled(); expect(getAIProvider).not.toHaveBeenCalled();
}); });
it('should call AI provider when currencies differ and return accurate calculation', async () => { it('should fetch live market rate without calling AI (0 tokens)', async () => {
const mockGenerate = jest.fn().mockResolvedValue({ const result = await service.getExchangeRate('test-user-id', {
data: { from: 'USD',
from: 'USD', to: 'VND',
to: 'VND', amount: 10,
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({ expect(getAIProvider).not.toHaveBeenCalled();
generateStructured: mockGenerate, expect(result.data.from).toBe('USD');
}); expect(result.data.to).toBe('VND');
expect(result.data.rate).toBeGreaterThan(20000);
expect(result.data.amount).toBe(10);
expect(result.data.convertedAmount).toBeCloseTo(result.data.rate * 10, 1);
expect(result.meta.usage.totalTokens).toBe(0);
});
it('should fallback gracefully to baseline offline rate if live market is unreachable', async () => {
jest.spyOn(service as any, 'fetchLiveMarketRate').mockResolvedValue(null);
const result = await service.getExchangeRate('test-user-id', { const result = await service.getExchangeRate('test-user-id', {
from: 'USD', from: 'USD',
to: 'VND', to: 'VND',
amount: 10, amount: 2,
}); });
expect(mockGenerate).toHaveBeenCalled(); expect(getAIProvider).not.toHaveBeenCalled();
expect(result.data.from).toBe('USD'); expect(result.data.from).toBe('USD');
expect(result.data.to).toBe('VND'); expect(result.data.to).toBe('VND');
expect(result.data.rate).toBe(25400); expect(result.data.rate).toBe(25922.52);
expect(result.data.amount).toBe(10); expect(result.data.amount).toBe(2);
expect(result.data.convertedAmount).toBe(254000); expect(result.data.convertedAmount).toBeCloseTo(51845.04, 1);
expect(result.data.formattedRate).toContain('1 USD = 25,400 VND'); expect(result.data.note).toContain('ngoại tuyến');
expect(result.data.note).toBe('Tỷ giá tham khảo Vietcombank'); expect(result.meta.provider).toBe('offline-fallback');
expect(result.meta.provider).toBe('gemini'); expect(result.meta.usage.totalTokens).toBe(0);
expect(result.meta.usage.totalTokens).toBe(53);
}); });
}); });
}); });
...@@ -14,6 +14,7 @@ import { systemSettingService } from '../system-settings/system-setting.service' ...@@ -14,6 +14,7 @@ import { systemSettingService } from '../system-settings/system-setting.service'
import { adminAiRepository } from './admin-ai.repository'; import { adminAiRepository } from './admin-ai.repository';
import { import {
AIAnalysisScopeDto, AIAnalysisScopeDto,
AIResponseMetaDto,
AIServiceResult, AIServiceResult,
CategorizeTransactionDto, CategorizeTransactionDto,
CurrencyExchangeRateDto, CurrencyExchangeRateDto,
...@@ -67,6 +68,27 @@ interface ResolvedContext { ...@@ -67,6 +68,27 @@ interface ResolvedContext {
promptData: Record<string, unknown>; promptData: Record<string, unknown>;
} }
const DEFAULT_FALLBACK_RATES: Record<string, number> = {
VND: 1,
USD: 25922.52,
EUR: 30067.05,
JPY: 168.3,
KRW: 19.3,
CNY: 3864.5,
GBP: 35022.8,
THB: 784.2,
SGD: 20448.9,
AUD: 18591.4,
CAD: 18710.2,
};
function getFallbackExchangeRate(from: string, to: string): number {
if (from === to) return 1;
const fromInVnd = DEFAULT_FALLBACK_RATES[from] ?? 1;
const toInVnd = DEFAULT_FALLBACK_RATES[to] ?? 1;
return toInVnd > 0 ? fromInVnd / toInVnd : 1;
}
export class AIAssistantService { export class AIAssistantService {
private readonly repository = new AIAssistantRepository(); private readonly repository = new AIAssistantRepository();
...@@ -238,6 +260,67 @@ export class AIAssistantService { ...@@ -238,6 +260,67 @@ export class AIAssistantService {
); );
} }
private async fetchLiveMarketRate(
from: string,
to: string,
): Promise<{ rate: number; source: string; date?: string } | null> {
const fromLower = from.toLowerCase();
const toLower = to.toLowerCase();
// 1. Try FloatRates (real-time interbank market rate feed)
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://www.floatrates.com/daily/${fromLower}.json`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = (await res.json()) as Record<string, { rate: string | number; date?: string; name?: string }>;
if (data[toLower]?.rate) {
const rate = parseFloat(String(data[toLower].rate));
if (!isNaN(rate) && rate > 0) {
return {
rate,
source: 'Thị trường liên ngân hàng (FloatRates)',
date: data[toLower].date,
};
}
}
}
} catch {
// Ignore network errors and try next source
}
// 2. Try Open Exchange Rates API
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://open.er-api.com/v6/latest/${from.toUpperCase()}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = (await res.json()) as {
rates?: Record<string, number>;
time_last_update_utc?: string;
};
const targetRate = data.rates?.[to.toUpperCase()];
if (typeof targetRate === 'number' && targetRate > 0) {
return {
rate: targetRate,
source: 'Thị trường mở (Open Exchange Rates)',
date: data.time_last_update_utc,
};
}
}
} catch {
// Ignore network errors
}
return null;
}
async getExchangeRate( async getExchangeRate(
userId: string, userId: string,
input: CurrencyExchangeRateDto, input: CurrencyExchangeRateDto,
...@@ -266,27 +349,14 @@ export class AIAssistantService { ...@@ -266,27 +349,14 @@ export class AIAssistantService {
}; };
} }
const systemInstruction = [ // Ground AI with real-time verified market data
'You are a professional financial assistant specializing in foreign exchange rates and currency markets.', const liveMarket = await this.fetchLiveMarketRate(from, to);
'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( const rate = liveMarket ? liveMarket.rate : getFallbackExchangeRate(from, to);
{ const note = liveMarket
systemInstruction, ? `Tỷ giá thị trường thời gian thực (${liveMarket.source})`
parts: [{ text: promptText }], : 'Tỷ giá thị trường cơ sở (ngoại tuyến)';
responseJsonSchema: exchangeRateJsonSchema,
},
exchangeRateResponseSchema,
{ userId, feature: 'CURRENCY_EXCHANGE_RATE' },
);
const rate = response.data.rate;
const convertedAmount = Number((amount * rate).toFixed(4)); const convertedAmount = Number((amount * rate).toFixed(4));
return { return {
...@@ -297,9 +367,13 @@ export class AIAssistantService { ...@@ -297,9 +367,13 @@ export class AIAssistantService {
amount, amount,
convertedAmount, convertedAmount,
formattedRate: `1 ${from} = ${rate.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${to}`, formattedRate: `1 ${from} = ${rate.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${to}`,
note: response.data.note ?? undefined, note,
},
meta: {
provider: liveMarket ? 'interbank-feed' : 'offline-fallback',
model: liveMarket ? liveMarket.source : 'standard-rates',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
}, },
meta: response.meta,
}; };
} }
......
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