🚀 Phase 4: AI Usage Tracking, Security Hardening, and Risk Monitor Dashboard

This commit is contained in:
Hamza-Ayed
2026-04-22 17:32:22 +03:00
parent 7e0e271be2
commit 6c1d67c695
11 changed files with 361 additions and 13 deletions

View File

@@ -20,6 +20,7 @@ import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthGuard } from '@nestjs/passport';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
@@ -36,7 +37,9 @@ export class AuthController {
/**
* تسجيل الدخول
* Rate limiting: 5 requests per minute
*/
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto) {

View File

@@ -17,4 +17,9 @@ export class DashboardController {
async getMultiEntityStats(@CurrentUser() user: any) {
return this.dashboardService.getMultiEntityStats(user.tenantId);
}
@Get('risk-invoices')
async getRiskInvoices(@CurrentUser() user: any) {
return this.dashboardService.getRiskInvoices(user.tenantId);
}
}

View File

@@ -93,6 +93,9 @@ export class DashboardService {
.select('COUNT(*)', 'total')
.addSelect('SUM(CASE WHEN status = :approved THEN tax_amount ELSE 0 END)', 'totalTax')
.addSelect('COUNT(CASE WHEN status = :failed THEN 1 END)', 'failedCount')
.addSelect('SUM(ai_prompt_tokens)', 'totalPromptTokens')
.addSelect('SUM(ai_completion_tokens)', 'totalCompletionTokens')
.addSelect('SUM(ai_total_cost)', 'totalAiCost')
.where('invoice.company_id = :companyId', {
companyId: company.id,
approved: InvoiceStatus.APPROVED,
@@ -107,6 +110,10 @@ export class DashboardService {
totalInvoices: parseInt(stats.total) || 0,
totalTax: parseFloat(stats.totalTax) || 0,
failedCount: parseInt(stats.failedCount) || 0,
aiStats: {
totalTokens: (parseInt(stats.totalPromptTokens) || 0) + (parseInt(stats.totalCompletionTokens) || 0),
totalCost: parseFloat(stats.totalAiCost) || 0,
},
riskScore: this.calculateMockRiskScore(stats), // Placeholder for AI Risk Score
};
}),
@@ -122,4 +129,19 @@ export class DashboardService {
if (failedRatio > 0.05) return 45; // Medium Risk
return 12; // Low Risk
}
/**
* جلب الفواتير التي بها مخاطر (فشل التحقق أو مرفوضة) عبر كافة الشركات
*/
async getRiskInvoices(tenantId: string) {
return this.invoiceRepository.find({
where: [
{ tenant_id: tenantId, status: InvoiceStatus.VALIDATION_FAILED },
{ tenant_id: tenantId, status: InvoiceStatus.REJECTED },
],
relations: ['company'],
order: { updated_at: 'DESC' },
take: 50,
});
}
}

View File

@@ -119,6 +119,15 @@ export class Invoice {
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
ai_confidence_score?: number;
@Column({ type: 'int', default: 0 })
ai_prompt_tokens!: number;
@Column({ type: 'int', default: 0 })
ai_completion_tokens!: number;
@Column({ type: 'decimal', precision: 10, scale: 6, default: 0 })
ai_total_cost!: number;
@CreateDateColumn({ type: 'timestamp' })
created_at!: Date;

View File

@@ -120,7 +120,22 @@ export class GeminiExtractorService {
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
const data = JSON.parse(cleanedJson);
return data.invoices || [];
// Get usage metadata for token tracking
const usage = result.response.usageMetadata || {
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
};
return {
invoices: data.invoices || [],
usage: {
promptTokens: usage.promptTokenCount,
completionTokens: usage.candidatesTokenCount,
totalTokens: usage.totalTokenCount,
},
};
} catch (error: any) {
this.logger.error(`AI Extraction failed: ${error.message}`);
throw new InternalServerErrorException('AI Extraction failed');

View File

@@ -63,15 +63,24 @@ export class InvoiceProcessor {
// 1. Update status to EXTRACTING
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
// 2. Extract data via Gemini (Now returns an array)
const invoicesData = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
// 2. Extract data via Gemini (Now returns an object with invoices and usage)
const { invoices: invoicesData, usage } = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
if (!invoicesData || invoicesData.length === 0) {
throw new Error('No invoices found in file');
}
// Calculate cost per invoice (pro-rated if multiple invoices in one file)
const costPerInvoice = this.calculateCost(usage) / invoicesData.length;
const promptTokensPerInvoice = Math.floor(usage.promptTokens / invoicesData.length);
const completionTokensPerInvoice = Math.floor(usage.completionTokens / invoicesData.length);
// 3. Process the first invoice (updates the current record)
await this.saveExtractedData(invoiceId, invoicesData[0]);
await this.saveExtractedData(invoiceId, invoicesData[0], {
promptTokens: promptTokensPerInvoice,
completionTokens: completionTokensPerInvoice,
totalCost: costPerInvoice,
});
// 4. If multiple invoices found, create new records for others
if (invoicesData.length > 1) {
@@ -85,7 +94,11 @@ export class InvoiceProcessor {
status: InvoiceStatus.EXTRACTING,
});
const savedNew = await this.invoiceRepository.save(newInvoice);
await this.saveExtractedData(savedNew.id, invoicesData[i]);
await this.saveExtractedData(savedNew.id, invoicesData[i], {
promptTokens: promptTokensPerInvoice,
completionTokens: completionTokensPerInvoice,
totalCost: costPerInvoice,
});
}
}
@@ -98,11 +111,23 @@ export class InvoiceProcessor {
}
}
/**
* حساب تكلفة استخدام Gemini بناءً على التسعيرة الحالية
*/
private calculateCost(usage: any): number {
// Gemini 1.5 Flash Pricing (as of late 2024):
// Prompt: $0.075 / 1M tokens
// Completion: $0.30 / 1M tokens
const promptCost = (usage.promptTokens / 1000000) * 0.075;
const completionCost = (usage.completionTokens / 1000000) * 0.30;
return promptCost + completionCost;
}
/**
* حفظ البيانات المستخرجة في قاعدة البيانات
* Save the JSON data extracted by AI into the SQL database
*/
private async saveExtractedData(invoiceId: string, data: any) {
private async saveExtractedData(invoiceId: string, data: any, usage?: any) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
@@ -127,6 +152,10 @@ export class InvoiceProcessor {
grand_total: data.grand_total,
currency_code: data.currency_code || 'JOD',
status: InvoiceStatus.EXTRACTED,
// Save AI Usage Metadata
ai_prompt_tokens: usage?.promptTokens || 0,
ai_completion_tokens: usage?.completionTokens || 0,
ai_total_cost: usage?.totalCost || 0,
});
// 2. Clear old lines if any (shouldn't happen on first extract)

View File

@@ -86,6 +86,14 @@ export class UsersService {
}
Object.assign(user, dto);
return this.userRepository.save(user);
try {
return await this.userRepository.save(user);
} catch (error: any) {
if (error.code === '23505') { // Postgres unique violation code
throw new ConflictException('البريد الإلكتروني مسجل مسبقاً لمستخدم آخر');
}
throw error;
}
}
}