🚀 Phase 4: AI Usage Tracking, Security Hardening, and Risk Monitor Dashboard
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user