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
AI_PROVIDER=gemini
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
AI_REQUEST_TIMEOUT_MS=30000
AI_MAX_OUTPUT_TOKENS=2048
......
......@@ -123,6 +123,8 @@ export class GeminiProvider implements AIProvider {
);
if (!response.ok) {
const errorText = await response.text();
console.error(`Gemini API error [${response.status}]:`, errorText);
const retryable = response.status === 401
|| response.status === 403
|| response.status === 408
......
......@@ -85,7 +85,7 @@ export const envConfig = {
.map((value) => value.trim())
.filter(Boolean),
)),
geminiModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
geminiModel: process.env.GEMINI_MODEL || 'gemini-3.5-flash-lite',
geminiBaseUrl:
process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta',
requestTimeoutMs: (() => {
......
......@@ -1605,7 +1605,7 @@ export const swaggerSpec = {
required: ['provider', 'model', 'usage'],
properties: {
provider: { type: 'string', example: 'gemini' },
model: { type: 'string', example: 'gemini-2.5-flash' },
model: { type: 'string', example: 'gemini-3.5-flash-lite' },
usage: {
type: 'object',
properties: {
......
......@@ -11,7 +11,6 @@ export const classificationJsonSchema = {
export const receiptJsonSchema = {
type: 'object',
additionalProperties: false,
required: [
'merchant',
'transactionDate',
......@@ -25,30 +24,28 @@ export const receiptJsonSchema = {
'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'] },
merchant: { type: 'string', nullable: true },
transactionDate: { type: 'string', nullable: true },
totalAmount: { type: 'string', nullable: true },
currency: { type: 'string', nullable: true },
taxAmount: { type: 'string', nullable: true },
categoryId: { type: 'string', nullable: true },
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'] },
quantity: { type: 'number', nullable: true },
unitPrice: { type: 'string', nullable: true },
totalAmount: { type: 'string', nullable: true },
},
},
},
rawText: { type: 'string' },
confidence: { type: 'number', minimum: 0, maximum: 1 },
warnings: { type: 'array', maxItems: 10, items: { type: 'string' } },
confidence: { type: 'number' },
warnings: { type: 'array', items: { type: 'string' } },
},
} satisfies Record<string, unknown>;
......
......@@ -2,77 +2,206 @@ import { z } from 'zod';
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 const classificationResponseSchema = z.object({
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),
});
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({
merchant: z.preprocess(cleanStringOrNull, z.string().trim().max(300).nullable()),
transactionDate: z.preprocess(cleanDateString, z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable()),
totalAmount: nullableMoneySchema,
currency: nullableCurrencySchema,
taxAmount: nullableMoneySchema,
categoryId: z.preprocess(cleanStringOrNull, z.string().nullable()),
lineItems: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
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),
quantity: z.preprocess(cleanQuantity, z.number().nonnegative().nullable()),
unitPrice: nullableMoneySchema,
totalAmount: nullableMoneySchema,
})).max(100)
),
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({
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),
highlights: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
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({
summary: z.string().trim().min(1).max(3000),
trends: z.array(z.object({
trends: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
direction: z.enum(['UP', 'DOWN', 'STABLE']),
direction: directionSchema,
description: z.string().trim().min(1).max(1000),
evidence: z.string().trim().min(1).max(1000),
})).max(8),
anomalies: z.array(z.object({
})).max(8)
),
anomalies: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
severity: z.enum(['LOW', 'MEDIUM', 'HIGH']),
severity: severitySchema,
description: z.string().trim().min(1).max(1000),
evidence: z.string().trim().min(1).max(1000),
})).max(8),
recommendations: z.array(z.object({
})).max(8)
),
recommendations: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
priority: prioritySchema,
description: z.string().trim().min(1).max(1000),
})).max(8),
})).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),
budgetRecommendations: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
categoryName: z.preprocess(cleanStringOrNull, z.string().trim().min(1).max(200).nullable()),
currency: currencySchema,
suggestedLimit: moneySchema,
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),
})).max(10)
),
savingRecommendations: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
goalName: z.preprocess(cleanStringOrNull, z.string().trim().min(1).max(200).nullable()),
currency: currencySchema,
suggestedMonthlyContribution: moneySchema,
rationale: z.string().trim().min(1).max(1000),
})).max(10),
actions: z.array(z.object({
})).max(10)
),
actions: z.preprocess(
(val) => (Array.isArray(val) ? val : []),
z.array(z.object({
title: z.string().trim().min(1).max(200),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
priority: prioritySchema,
description: z.string().trim().min(1).max(1000),
})).max(10),
})).max(10)
),
});
......@@ -139,7 +139,8 @@ export class AIAssistantService {
{
text: [
'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.',
`Hints: ${JSON.stringify({
language: input.languageHint ?? null,
......@@ -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(
{
systemInstruction: SYSTEM_INSTRUCTION,
parts,
responseJsonSchema: {
...receiptJsonSchema,
properties: {
...receiptJsonSchema.properties,
categoryId: categorySchema,
},
},
responseJsonSchema: receiptJsonSchema,
temperature: 0.1,
},
receiptResponseSchema,
);
const category = response.data.categoryId
? categories.find((item) => item.id === response.data.categoryId)
? categories.find((item) => item.id === response.data.categoryId) ?? null
: null;
if (response.data.categoryId && !category) {
throw this.invalidAIResponseError();
}
const { categoryId: _categoryId, ...extractedReceipt } = response.data;
return {
......@@ -283,6 +267,8 @@ export class AIAssistantService {
const response = await getAIProvider().generateStructured(request);
const result = outputSchema.safeParse(response.data);
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();
}
return {
......
......@@ -31,6 +31,7 @@ router.use('/reports', reportRoute);
router.use('/notifications', notificationRoute);
router.use('/reminders', reminderRoute);
router.use('/ai-assistant', aiAssistantRoute);
router.use('/ai', aiAssistantRoute);
router.use('/uploads', uploadRoute);
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