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:
2026-07-16 20:01:26 +03:00
co-authored by Claude Opus 4.8
parent 04200db438
commit 6234e66471
1541 changed files with 267713 additions and 16 deletions
@@ -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]);
}
}