Commit ab1b126a authored by ThinhNC's avatar ThinhNC

feat(ai-assistant): integrate Gemini provider and AI financial advice validation schemas

parent 600a3273
...@@ -52,7 +52,7 @@ NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000 ...@@ -52,7 +52,7 @@ NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
AI_PROVIDER=gemini AI_PROVIDER=gemini
GEMINI_API_KEYS=replace_with_key_1,replace_with_key_2 GEMINI_API_KEYS=replace_with_key_1,replace_with_key_2
GEMINI_MODEL=gemini-2.5-flash GEMINI_MODEL=gemini-3.6-flash
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
AI_REQUEST_TIMEOUT_MS=30000 AI_REQUEST_TIMEOUT_MS=30000
AI_MAX_OUTPUT_TOKENS=2048 AI_MAX_OUTPUT_TOKENS=2048
......
...@@ -123,6 +123,8 @@ export class GeminiProvider implements AIProvider { ...@@ -123,6 +123,8 @@ export class GeminiProvider implements AIProvider {
); );
if (!response.ok) { if (!response.ok) {
const errorText = await response.text();
console.error(`Gemini API error [${response.status}]:`, errorText);
const retryable = response.status === 401 const retryable = response.status === 401
|| response.status === 403 || response.status === 403
|| response.status === 408 || response.status === 408
......
...@@ -85,7 +85,7 @@ export const envConfig = { ...@@ -85,7 +85,7 @@ export const envConfig = {
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean), .filter(Boolean),
)), )),
geminiModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash', geminiModel: process.env.GEMINI_MODEL || 'gemini-3.5-flash-lite',
geminiBaseUrl: geminiBaseUrl:
process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta',
requestTimeoutMs: (() => { requestTimeoutMs: (() => {
......
...@@ -1605,7 +1605,7 @@ export const swaggerSpec = { ...@@ -1605,7 +1605,7 @@ export const swaggerSpec = {
required: ['provider', 'model', 'usage'], required: ['provider', 'model', 'usage'],
properties: { properties: {
provider: { type: 'string', example: 'gemini' }, provider: { type: 'string', example: 'gemini' },
model: { type: 'string', example: 'gemini-2.5-flash' }, model: { type: 'string', example: 'gemini-3.5-flash-lite' },
usage: { usage: {
type: 'object', type: 'object',
properties: { properties: {
......
...@@ -11,7 +11,6 @@ export const classificationJsonSchema = { ...@@ -11,7 +11,6 @@ export const classificationJsonSchema = {
export const receiptJsonSchema = { export const receiptJsonSchema = {
type: 'object', type: 'object',
additionalProperties: false,
required: [ required: [
'merchant', 'merchant',
'transactionDate', 'transactionDate',
...@@ -25,30 +24,28 @@ export const receiptJsonSchema = { ...@@ -25,30 +24,28 @@ export const receiptJsonSchema = {
'warnings', 'warnings',
], ],
properties: { properties: {
merchant: { type: ['string', 'null'] }, merchant: { type: 'string', nullable: true },
transactionDate: { type: ['string', 'null'] }, transactionDate: { type: 'string', nullable: true },
totalAmount: { type: ['string', 'null'] }, totalAmount: { type: 'string', nullable: true },
currency: { type: ['string', 'null'] }, currency: { type: 'string', nullable: true },
taxAmount: { type: ['string', 'null'] }, taxAmount: { type: 'string', nullable: true },
categoryId: { type: ['string', 'null'] }, categoryId: { type: 'string', nullable: true },
lineItems: { lineItems: {
type: 'array', type: 'array',
maxItems: 100,
items: { items: {
type: 'object', type: 'object',
additionalProperties: false,
required: ['name', 'quantity', 'unitPrice', 'totalAmount'], required: ['name', 'quantity', 'unitPrice', 'totalAmount'],
properties: { properties: {
name: { type: 'string' }, name: { type: 'string' },
quantity: { type: ['number', 'null'], minimum: 0 }, quantity: { type: 'number', nullable: true },
unitPrice: { type: ['string', 'null'] }, unitPrice: { type: 'string', nullable: true },
totalAmount: { type: ['string', 'null'] }, totalAmount: { type: 'string', nullable: true },
}, },
}, },
}, },
rawText: { type: 'string' }, rawText: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 }, confidence: { type: 'number' },
warnings: { type: 'array', maxItems: 10, items: { type: 'string' } }, warnings: { type: 'array', items: { type: 'string' } },
}, },
} satisfies Record<string, unknown>; } satisfies Record<string, unknown>;
......
...@@ -2,77 +2,206 @@ import { z } from 'zod'; ...@@ -2,77 +2,206 @@ import { z } from 'zod';
const MONEY_PATTERN = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const MONEY_PATTERN = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
const cleanMoneyString = (val: unknown): unknown => {
if (val === null || val === undefined) return null;
if (typeof val === 'number') {
return Number.isFinite(val) ? val.toFixed(2).replace(/\.00$/, '') : null;
}
if (typeof val === 'string') {
let cleaned = val.replace(/[,\s_đ₫VND$USD]/gi, '').trim();
if (!cleaned) return null;
// Detect and remove Vietnamese/European thousand separator dots (e.g. "159.500", "1.500.000", "38.000")
if (/\.\d{3}(\.|$)/.test(cleaned)) {
cleaned = cleaned.replace(/\./g, '');
}
if (MONEY_PATTERN.test(cleaned)) {
return cleaned;
}
// If still has decimal or trailing digits
const numericMatch = cleaned.match(/^\d+(\.\d{1,2})?/);
return numericMatch ? numericMatch[0] : null;
}
return val;
};
const cleanStringOrNull = (val: unknown): unknown => {
if (val === null || val === undefined) return null;
if (typeof val === 'string') {
const trimmed = val.trim();
return trimmed.length > 0 ? trimmed : null;
}
return String(val);
};
const cleanQuantity = (val: unknown): unknown => {
if (val === null || val === undefined) return null;
if (typeof val === 'number') return Math.max(0, val);
if (typeof val === 'string') {
const parsed = parseFloat(val);
return isNaN(parsed) ? null : Math.max(0, parsed);
}
return null;
};
const cleanConfidence = (val: unknown): unknown => {
if (typeof val === 'number') return Math.min(1, Math.max(0, val));
if (typeof val === 'string') {
const parsed = parseFloat(val);
return isNaN(parsed) ? 0.9 : Math.min(1, Math.max(0, parsed));
}
return 0.9;
};
const cleanDateString = (val: unknown): unknown => {
if (typeof val === 'string') {
const isoMatch = val.match(/\d{4}-\d{2}-\d{2}/);
if (isoMatch) return isoMatch[0];
const vnMatch = val.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
if (vnMatch) {
const day = vnMatch[1].padStart(2, '0');
const month = vnMatch[2].padStart(2, '0');
const year = vnMatch[3];
return `${year}-${month}-${day}`;
}
}
return null;
};
const moneySchema = z.preprocess(cleanMoneyString, z.string().regex(MONEY_PATTERN));
const nullableMoneySchema = z.preprocess(cleanMoneyString, z.string().regex(MONEY_PATTERN).nullable());
const directionSchema = z.preprocess(
(val) => (typeof val === 'string' ? val.toUpperCase().trim() : val),
z.enum(['UP', 'DOWN', 'STABLE'])
);
const severitySchema = z.preprocess(
(val) => (typeof val === 'string' ? val.toUpperCase().trim() : val),
z.enum(['LOW', 'MEDIUM', 'HIGH'])
);
const prioritySchema = z.preprocess(
(val) => (typeof val === 'string' ? val.toUpperCase().trim() : val),
z.enum(['LOW', 'MEDIUM', 'HIGH'])
);
const currencySchema = z.preprocess(
(val) => (typeof val === 'string' ? val.toUpperCase().trim() : 'VND'),
z.string().regex(/^[A-Z]{3}$/)
);
const nullableCurrencySchema = z.preprocess(
(val) => (typeof val === 'string' ? val.toUpperCase().trim() : 'VND'),
z.string().regex(/^[A-Z]{3}$/).nullable()
);
export type AIResponseValidator<T> = z.ZodType<T>; export type AIResponseValidator<T> = z.ZodType<T>;
export const classificationResponseSchema = z.object({ export const classificationResponseSchema = z.object({
categoryId: z.string().uuid(), categoryId: z.string().uuid(),
confidence: z.number().min(0).max(1), confidence: z.preprocess(cleanConfidence, z.number().min(0).max(1)),
reasoning: z.string().trim().min(1).max(1000), reasoning: z.string().trim().min(1).max(1000),
}); });
export const receiptResponseSchema = z.object({ export const receiptResponseSchema = z.object({
merchant: z.string().trim().max(300).nullable(), merchant: z.preprocess(cleanStringOrNull, z.string().trim().max(300).nullable()),
transactionDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(), transactionDate: z.preprocess(cleanDateString, z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable()),
totalAmount: z.string().regex(MONEY_PATTERN).nullable(), totalAmount: nullableMoneySchema,
currency: z.string().regex(/^[A-Z]{3}$/).nullable(), currency: nullableCurrencySchema,
taxAmount: z.string().regex(MONEY_PATTERN).nullable(), taxAmount: nullableMoneySchema,
categoryId: z.string().uuid().nullable(), categoryId: z.preprocess(cleanStringOrNull, z.string().nullable()),
lineItems: z.array(z.object({ lineItems: z.preprocess(
name: z.string().trim().min(1).max(300), (val) => (Array.isArray(val) ? val : []),
quantity: z.number().nonnegative().nullable(), z.array(z.object({
unitPrice: z.string().regex(MONEY_PATTERN).nullable(), name: z.string().trim().min(1).max(300),
totalAmount: z.string().regex(MONEY_PATTERN).nullable(), quantity: z.preprocess(cleanQuantity, z.number().nonnegative().nullable()),
})).max(100), unitPrice: nullableMoneySchema,
rawText: z.string().max(8000), totalAmount: nullableMoneySchema,
confidence: z.number().min(0).max(1), })).max(100)
warnings: z.array(z.string().trim().min(1).max(500)).max(10), ),
rawText: z.preprocess((val) => (typeof val === 'string' ? val : ''), z.string().max(8000)),
confidence: z.preprocess(cleanConfidence, z.number().min(0).max(1)),
warnings: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.string().trim().min(1).max(500)).max(10)
),
}); });
export const chatResponseSchema = z.object({ export const chatResponseSchema = z.object({
answer: z.string().trim().min(1).max(5000), answer: z.string().trim().min(1).max(5000),
highlights: z.array(z.string().trim().min(1).max(500)).max(8), highlights: z.preprocess(
caveats: z.array(z.string().trim().min(1).max(500)).max(8), (val) => (Array.isArray(val) ? val : []),
suggestedActions: z.array(z.string().trim().min(1).max(500)).max(8), z.array(z.string().trim().min(1).max(500)).max(8)
),
caveats: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.string().trim().min(1).max(500)).max(8)
),
suggestedActions: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.string().trim().min(1).max(500)).max(8)
),
}); });
export const insightsResponseSchema = z.object({ export const insightsResponseSchema = z.object({
summary: z.string().trim().min(1).max(3000), summary: z.string().trim().min(1).max(3000),
trends: z.array(z.object({ trends: z.preprocess(
title: z.string().trim().min(1).max(200), (val) => (Array.isArray(val) ? val : []),
direction: z.enum(['UP', 'DOWN', 'STABLE']), z.array(z.object({
description: z.string().trim().min(1).max(1000), title: z.string().trim().min(1).max(200),
evidence: z.string().trim().min(1).max(1000), direction: directionSchema,
})).max(8), description: z.string().trim().min(1).max(1000),
anomalies: z.array(z.object({ evidence: z.string().trim().min(1).max(1000),
title: z.string().trim().min(1).max(200), })).max(8)
severity: z.enum(['LOW', 'MEDIUM', 'HIGH']), ),
description: z.string().trim().min(1).max(1000), anomalies: z.preprocess(
evidence: z.string().trim().min(1).max(1000), (val) => (Array.isArray(val) ? val : []),
})).max(8), z.array(z.object({
recommendations: z.array(z.object({ title: z.string().trim().min(1).max(200),
title: z.string().trim().min(1).max(200), severity: severitySchema,
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']), description: z.string().trim().min(1).max(1000),
description: z.string().trim().min(1).max(1000), evidence: z.string().trim().min(1).max(1000),
})).max(8), })).max(8)
),
recommendations: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: prioritySchema,
description: z.string().trim().min(1).max(1000),
})).max(8)
),
}); });
export const recommendationsResponseSchema = z.object({ export const recommendationsResponseSchema = z.object({
summary: z.string().trim().min(1).max(3000), summary: z.string().trim().min(1).max(3000),
budgetRecommendations: z.array(z.object({ budgetRecommendations: z.preprocess(
categoryName: z.string().trim().min(1).max(200).nullable(), (val) => (Array.isArray(val) ? val : []),
currency: z.string().regex(/^[A-Z]{3}$/), z.array(z.object({
suggestedLimit: z.string().regex(MONEY_PATTERN), categoryName: z.preprocess(cleanStringOrNull, z.string().trim().min(1).max(200).nullable()),
rationale: z.string().trim().min(1).max(1000), currency: currencySchema,
})).max(10), suggestedLimit: moneySchema,
savingRecommendations: z.array(z.object({ rationale: z.string().trim().min(1).max(1000),
goalName: z.string().trim().min(1).max(200).nullable(), })).max(10)
currency: z.string().regex(/^[A-Z]{3}$/), ),
suggestedMonthlyContribution: z.string().regex(MONEY_PATTERN), savingRecommendations: z.preprocess(
rationale: z.string().trim().min(1).max(1000), (val) => (Array.isArray(val) ? val : []),
})).max(10), z.array(z.object({
actions: z.array(z.object({ goalName: z.preprocess(cleanStringOrNull, z.string().trim().min(1).max(200).nullable()),
title: z.string().trim().min(1).max(200), currency: currencySchema,
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']), suggestedMonthlyContribution: moneySchema,
description: z.string().trim().min(1).max(1000), rationale: z.string().trim().min(1).max(1000),
})).max(10), })).max(10)
),
actions: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: prioritySchema,
description: z.string().trim().min(1).max(1000),
})).max(10)
),
}); });
...@@ -139,7 +139,8 @@ export class AIAssistantService { ...@@ -139,7 +139,8 @@ export class AIAssistantService {
{ {
text: [ text: [
'Extract the receipt into structured data. Use null when a field is not visible.', 'Extract the receipt into structured data. Use null when a field is not visible.',
'Amounts must be non-negative decimal strings without currency symbols or thousands separators.', 'Amounts must be non-negative numeric strings without thousands separators or currency symbols.',
'For example, a receipt showing "159.500 VND" or "159,500" must be extracted as "159500". A receipt showing "38.000" must be "38000". Do not add extra zeros.',
'transactionDate must use YYYY-MM-DD. currency must be an uppercase three-letter code.', 'transactionDate must use YYYY-MM-DD. currency must be an uppercase three-letter code.',
`Hints: ${JSON.stringify({ `Hints: ${JSON.stringify({
language: input.languageHint ?? null, language: input.languageHint ?? null,
...@@ -155,35 +156,18 @@ export class AIAssistantService { ...@@ -155,35 +156,18 @@ export class AIAssistantService {
}, },
}, },
]; ];
const categorySchema = categories.length > 0
? {
anyOf: [
{ type: 'string', enum: categories.map((category) => category.id) },
{ type: 'null' },
],
}
: { type: 'null' };
const response = await this.generate( const response = await this.generate(
{ {
systemInstruction: SYSTEM_INSTRUCTION, systemInstruction: SYSTEM_INSTRUCTION,
parts, parts,
responseJsonSchema: { responseJsonSchema: receiptJsonSchema,
...receiptJsonSchema,
properties: {
...receiptJsonSchema.properties,
categoryId: categorySchema,
},
},
temperature: 0.1, temperature: 0.1,
}, },
receiptResponseSchema, receiptResponseSchema,
); );
const category = response.data.categoryId const category = response.data.categoryId
? categories.find((item) => item.id === response.data.categoryId) ? categories.find((item) => item.id === response.data.categoryId) ?? null
: null; : null;
if (response.data.categoryId && !category) {
throw this.invalidAIResponseError();
}
const { categoryId: _categoryId, ...extractedReceipt } = response.data; const { categoryId: _categoryId, ...extractedReceipt } = response.data;
return { return {
...@@ -283,6 +267,8 @@ export class AIAssistantService { ...@@ -283,6 +267,8 @@ export class AIAssistantService {
const response = await getAIProvider().generateStructured(request); const response = await getAIProvider().generateStructured(request);
const result = outputSchema.safeParse(response.data); const result = outputSchema.safeParse(response.data);
if (!result.success) { if (!result.success) {
console.error('AI schema validation failed:', JSON.stringify(result.error.issues, null, 2));
console.error('AI response data was:', JSON.stringify(response.data, null, 2));
throw this.invalidAIResponseError(); throw this.invalidAIResponseError();
} }
return { return {
......
...@@ -31,6 +31,7 @@ router.use('/reports', reportRoute); ...@@ -31,6 +31,7 @@ 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); router.use('/ai-assistant', aiAssistantRoute);
router.use('/ai', aiAssistantRoute);
router.use('/uploads', uploadRoute); router.use('/uploads', uploadRoute);
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