Files
smanab/backend/routes/ai.js
2026-02-22 14:54:55 +08:00

240 lines
9.3 KiB
JavaScript
Executable File

import express from 'express';
import dotenv from 'dotenv';
dotenv.config();
const router = express.Router();
// Gemini API Base URL - Using REST API directly to avoid SDK issues
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
const getApiKey = () => {
const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
if (!key) throw new Error("Server GOOGLE_API_KEY or GEMINI_API_KEY is missing.");
return key;
};
// POST /api/ai/analyze - Analyze Face Quality
router.post('/analyze', async (req, res) => {
try {
const { image } = req.body;
if (!image) throw new Error("Image data missing");
const apiKey = getApiKey();
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
// Detect image type from data URI
let mimeType = 'image/jpeg';
if (image.startsWith('data:image/png')) {
mimeType = 'image/png';
} else if (image.startsWith('data:image/webp')) {
mimeType = 'image/webp';
}
const prompt = `Anda adalah sistem keamanan biometrik sekolah yang SANGAT KETAT.
Tugas: Analisis keaslian foto untuk absensi.
KRITERIA TOLAK MUTLAK (SPOOFING):
1. Terdeteksi sebagai foto dari layar HP/Laptop (Cari pola Moire/Pixel grid/Glare layar).
2. Terdeteksi sebagai foto cetak (kertas) atau pas foto yang difoto ulang.
3. Terdeteksi bingkai HP atau jari yang memegang HP lain di dalam foto.
4. Wajah tidak menghadap kamera secara langsung (Terlalu miring/profil).
5. Wajah tertutup masker, helm, atau kacamata hitam gelap.
PENTING: Berikan respons HANYA dalam format JSON yang valid, tanpa markdown atau backticks:
{"hasFace": true/false, "confidence": 0-100, "description": "penjelasan singkat"}
hasFace harus TRUE hanya jika foto selfie ASLI, LIVE, JELAS, dan WAJAH TUNGGAL.
Jika false, jelaskan alasannya di description dalam Bahasa Indonesia.`;
console.log("Calling Gemini REST API for face analysis...");
console.log("Using MIME type:", mimeType);
console.log("Base64 length:", base64Data.length);
const requestBody = {
contents: [{
parts: [
{
inline_data: {
mime_type: mimeType,
data: base64Data
}
},
{ text: prompt }
]
}]
};
const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API HTTP Error:", response.status, errorText);
throw new Error(`Gemini API Error: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("Raw Gemini response:", JSON.stringify(data, null, 2));
// Extract text from response
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
console.error("No text content in response:", data);
throw new Error("No response text from AI");
}
// Parse JSON from the response (remove any markdown formatting if present)
let resultText = textContent.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
// Fix common JSON issues
resultText = resultText.replace(/'/g, '"'); // Replace single quotes with double quotes
console.log("Cleaned response text:", resultText);
const result = JSON.parse(resultText);
console.log("AI Analyze Result:", result);
res.json(result);
} catch (error) {
console.error("AI Analyze Error:", error);
res.status(500).json({
hasFace: false,
confidence: 0,
description: `Layanan AI Error: ${error.message}`
});
}
});
// POST /api/ai/compare - Compare Faces
router.post('/compare', async (req, res) => {
try {
const { registeredFace, currentImage } = req.body;
if (!registeredFace || !currentImage) throw new Error("Images missing");
const apiKey = getApiKey();
// Handle Registered Face (could be URL or Base64)
let regBase64 = registeredFace;
let regMimeType = 'image/jpeg';
if (registeredFace.startsWith('http')) {
try {
console.log("Fetching registered face from URL:", registeredFace);
const fetchResp = await fetch(registeredFace);
if (!fetchResp.ok) throw new Error(`Failed to fetch registered face: ${fetchResp.status}`);
const contentType = fetchResp.headers.get('content-type');
if (contentType) {
regMimeType = contentType.split(';')[0];
}
const arrayBuffer = await fetchResp.arrayBuffer();
regBase64 = Buffer.from(arrayBuffer).toString('base64');
console.log("Successfully fetched and converted to base64");
} catch (fetchError) {
console.error("Error fetching registered face:", fetchError);
throw new Error(`Failed to fetch registered face image: ${fetchError.message}`);
}
} else {
if (registeredFace.startsWith('data:image/png')) {
regMimeType = 'image/png';
} else if (registeredFace.startsWith('data:image/webp')) {
regMimeType = 'image/webp';
}
regBase64 = registeredFace.replace(/^data:image\/\w+;base64,/, "");
}
// Handle current image
let currMimeType = 'image/jpeg';
if (currentImage.startsWith('data:image/png')) {
currMimeType = 'image/png';
} else if (currentImage.startsWith('data:image/webp')) {
currMimeType = 'image/webp';
}
const currBase64 = currentImage.replace(/^data:image\/\w+;base64,/, "");
const prompt = `Anda adalah ahli forensik digital. Verifikasi apakah dua foto ini menampilkan orang yang sama untuk absensi sekolah.
INSTRUKSI KEAMANAN TINGGI:
1. Bandingkan fitur biometrik wajah (mata, hidung, mulut, struktur tulang).
2. PERHATIKAN UMUR: Pastikan Candidate Image adalah foto TERBARU dari orang yang sama.
3. ANTI-MANIPULASI: Jika Candidate Image terlihat seperti foto hasil crop, foto layar, atau foto kertas, TOLAK dengan tegas.
4. Pastikan ekspresi wajah wajar (bukan sedang tidur atau dipaksa).
Gambar pertama adalah REFERENCE (wajah terdaftar). Gambar kedua adalah CANDIDATE (foto absensi sekarang).
PENTING: Berikan respons HANYA dalam format JSON yang valid, tanpa markdown atau backticks:
{"match": true/false, "confidence": 0-100, "reason": "alasan teknis singkat dalam Bahasa Indonesia"}
match harus true jika 95%+ yakin orang yang sama DAN foto asli.`;
console.log("Calling Gemini REST API for face comparison...");
const requestBody = {
contents: [{
parts: [
{ text: "REFERENCE IMAGE (Wajah Terdaftar):" },
{
inline_data: {
mime_type: regMimeType,
data: regBase64
}
},
{ text: "CANDIDATE IMAGE (Foto Absensi Sekarang):" },
{
inline_data: {
mime_type: currMimeType,
data: currBase64
}
},
{ text: prompt }
]
}]
};
const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API HTTP Error:", response.status, errorText);
throw new Error(`Gemini API Error: ${response.status}`);
}
const data = await response.json();
console.log("Raw Gemini response:", JSON.stringify(data, null, 2));
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
console.error("No text content in response:", data);
throw new Error("No comparison response text");
}
let resultText = textContent.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
resultText = resultText.replace(/'/g, '"');
const result = JSON.parse(resultText);
console.log("AI Compare Result:", result);
res.json(result);
} catch (error) {
console.error("AI Compare Error:", error);
res.status(500).json({
match: false,
confidence: 0,
reason: `Server AI Verify Error: ${error.message}`
});
}
});
export default router;