feat: Gemini AI (doc OCR + face match) + role-assignment endpoints
- GeminiService (integrations/gemini, @Global): extractDocument (OCR fields) + faceMatch; graceful if no GEMINI_API_KEY - documents: face-match now real via Gemini; POST /admin/documents/:id/ocr extracts fields for CS auto-fill - roles: PATCH /admin/users/:id/role (admin) + POST /platform/users/role (bootstrap via x-platform-secret) - config: gemini + platform.secret; .env.example entries Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface GeminiImage {
|
||||
buffer: Buffer;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* محوّل Gemini (رؤية) — قراءة الوثائق (استخراج الحقول) ومطابقة الوجه.
|
||||
* يُستخدم في توثيق السائق كذكاء اصطناعي (نمط سيرو). بلا GEMINI_API_KEY:
|
||||
* يرجّع { enabled:false } دون فشل.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeminiService {
|
||||
private readonly logger = new Logger('Gemini');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return !!this.config.get<string>('gemini.apiKey');
|
||||
}
|
||||
|
||||
private endpoint(): string {
|
||||
const model = this.config.get<string>('gemini.model') ?? 'gemini-2.0-flash';
|
||||
const key = this.config.get<string>('gemini.apiKey');
|
||||
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
|
||||
}
|
||||
|
||||
/** يرسل نصاً + صوراً ويعيد JSON مُحلَّلاً من رد Gemini. */
|
||||
private async generateJson(prompt: string, images: GeminiImage[]): Promise<any> {
|
||||
const parts: any[] = [{ text: prompt }];
|
||||
for (const img of images) {
|
||||
parts.push({
|
||||
inline_data: { mime_type: img.mime ?? 'image/jpeg', data: img.buffer.toString('base64') },
|
||||
});
|
||||
}
|
||||
const res = await fetch(this.endpoint(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts }],
|
||||
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Gemini ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
const data: any = await res.json();
|
||||
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
|
||||
try {
|
||||
return JSON.parse(text.replace(/^```json\s*|\s*```$/g, '').trim());
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
/** يستخرج حقول وثيقة من صورتها. */
|
||||
async extractDocument(image: GeminiImage, docType: string): Promise<any> {
|
||||
if (!this.enabled) return { enabled: false };
|
||||
const prompt =
|
||||
`استخرج بيانات وثيقة من نوع "${docType}" من الصورة. ` +
|
||||
`أعِد JSON فقط بالمفاتيح: number, holder_name, issue_date (YYYY-MM-DD), expiry_date (YYYY-MM-DD). ` +
|
||||
`استخدم null لأي حقل غير موجود.`;
|
||||
return this.generateJson(prompt, [image]);
|
||||
}
|
||||
|
||||
/** يقارن وجه السيلفي بصورة الهوية. */
|
||||
async faceMatch(selfie: GeminiImage, id: GeminiImage): Promise<any> {
|
||||
if (!this.enabled) return { enabled: false };
|
||||
const prompt =
|
||||
`الصورة الأولى سيلفي شخص، والثانية صورة هوية. قارن الوجهين. ` +
|
||||
`أعِد JSON فقط: {"match": true/false, "score": رقم 0..1, "reason": "سبب مختصر"}.`;
|
||||
return this.generateJson(prompt, [selfie, id]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user