Files
ContentGen_BE/src/modules/gemini/gemini.service.ts
T
Harun CAN a40619ef33
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled
main
2026-05-06 10:48:07 +02:00

509 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI } from '@google/genai';
export interface GeminiGenerateOptions {
model?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
}
export interface GeminiChatMessage {
role: 'user' | 'model';
content: string;
}
/**
* Gemini AI Service
*
* Provides AI-powered text generation using Google Gemini API.
* This service is globally available when ENABLE_GEMINI=true.
*
* @example
* ```typescript
* // Simple text generation
* const response = await geminiService.generateText('Write a poem about coding');
*
* // With options
* const response = await geminiService.generateText('Translate to Turkish', {
* temperature: 0.7,
* systemPrompt: 'You are a professional translator',
* });
*
* // Chat conversation
* const messages = [
* { role: 'user', content: 'Hello!' },
* { role: 'model', content: 'Hi there!' },
* { role: 'user', content: 'What is 2+2?' },
* ];
* const response = await geminiService.chat(messages);
* ```
*/
@Injectable()
export class GeminiService implements OnModuleInit {
private readonly logger = new Logger(GeminiService.name);
private client: GoogleGenAI | null = null;
private isEnabled = false;
private defaultModel: string;
constructor(private readonly configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
this.defaultModel = this.configService.get<string>(
'gemini.defaultModel',
'gemini-2.5-flash',
);
}
onModuleInit() {
if (!this.isEnabled) {
this.logger.log(
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
);
return;
}
const apiKey = this.configService.get<string>('gemini.apiKey');
if (!apiKey) {
this.logger.warn(
'GOOGLE_API_KEY is not set. Gemini features will not work.',
);
return;
}
try {
this.client = new GoogleGenAI({ apiKey });
this.logger.log('✅ Gemini AI initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize Gemini AI', error);
}
}
/**
* Check if Gemini is available and properly configured
*/
isAvailable(): boolean {
return this.isEnabled && this.client !== null;
}
/**
* Generate text content from a prompt
*
* @param prompt - The text prompt to send to the AI
* @param options - Optional configuration for the generation
* @returns Generated text response
*/
async generateText(
prompt: string,
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
try {
const contents: any[] = [];
// Add system prompt if provided
if (options.systemPrompt) {
contents.push({
role: 'user',
parts: [{ text: options.systemPrompt }],
});
contents.push({
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
});
}
contents.push({
role: 'user',
parts: [{ text: prompt }],
});
const response = await this.client!.models.generateContent({
model,
contents,
config: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
},
});
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini generation failed', error);
throw error;
}
}
/**
* Have a multi-turn chat conversation
*
* @param messages - Array of chat messages
* @param options - Optional configuration for the generation
* @returns Generated text response
*/
async chat(
messages: GeminiChatMessage[],
options: GeminiGenerateOptions = {},
): Promise<{ text: string; usage?: any }> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const model = options.model || this.defaultModel;
try {
const contents = messages.map((msg) => ({
role: msg.role,
parts: [{ text: msg.content }],
}));
// Prepend system prompt if provided
if (options.systemPrompt) {
contents.unshift(
{
role: 'user',
parts: [{ text: options.systemPrompt }],
},
{
role: 'model',
parts: [{ text: 'Understood. I will follow these instructions.' }],
},
);
}
const response = await this.client!.models.generateContent({
model,
contents,
config: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
},
});
return {
text: (response.text || '').trim(),
usage: response.usageMetadata,
};
} catch (error) {
this.logger.error('Gemini chat failed', error);
throw error;
}
}
/**
* Generate structured JSON output
*
* @param prompt - The prompt describing what JSON to generate
* @param schema - JSON schema description for the expected output
* @param options - Optional configuration for the generation
* @returns Parsed JSON object
*/
async generateJSON<T = any>(
prompt: string,
schema: string,
options: GeminiGenerateOptions = {},
): Promise<{ data: T; usage?: any }> {
const fullPrompt = `${prompt}
Output the result as valid JSON that matches this schema:
${schema}
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
const response = await this.generateText(fullPrompt, options);
try {
// Try to extract JSON from the response
let jsonStr = response.text;
// Remove potential markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
const data = JSON.parse(jsonStr) as T;
return { data, usage: response.usage };
} catch (error) {
this.logger.error('Failed to parse JSON response', error);
throw new Error('Failed to parse AI response as JSON');
}
}
// ── Görsel Üretim (Gemini Image Generation) ─────────────────────────
/**
* Gemini Image Generation API ile görsel üret.
* 3 katmanlı fallback mimarisi:
* 1) gemini-2.5-flash-image (Nano Banana — hızlı, stabil)
* 2) gemini-3.1-flash-image-preview (Nano Banana 2 — en yeni)
* 3) Imagen 4 Fast (generateImages API)
*
* Raspberry Pi 5 bellek koruması için buffer olarak döner.
*
* @param prompt - İngilizce görsel açıklaması
* @param aspectRatio - Görsel en-boy oranı (16:9, 9:16, 1:1)
* @returns Base64 decoded image buffer ve mime type
*/
async generateImage(
prompt: string,
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
isIllustration: boolean = false,
forceImagen: boolean = false,
): Promise<{ buffer: Buffer; mimeType: string } | null> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
// Güncel model sıralaması (Nisan 2026):
// - gemini-2.5-flash-image: Nano Banana — stabil, hızlı
// - gemini-3.1-flash-image-preview: Nano Banana 2 — en yeni, yüksek kalite
const primaryModel = 'gemini-2.5-flash-image';
const fallbackModel = 'gemini-3.1-flash-image-preview';
try {
this.logger.log(
`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`,
);
// En-boy oranına göre yönlendirmeyi zorla
const orientation =
aspectRatio === '9:16'
? '(VERTICAL / PORTRAIT)'
: aspectRatio === '16:9'
? '(HORIZONTAL / LANDSCAPE)'
: '(SQUARE)';
// Gemini modelleri ana konunun (subject) prompt'un en başında olmasını tercih eder.
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
const enhancedPrompt = isIllustration
? `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a premium digital illustration of the EXACT SUBJECT described above. Make it highly detailed and vivid, but NOT photorealistic. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`
: `SUBJECT: ${prompt}\n\nINSTRUCTIONS: Generate a high-quality photorealistic cinematic image of the EXACT SUBJECT described above. Use professional lighting and make it highly detailed. Aspect ratio: ${aspectRatio} ${orientation}. DO NOT deviate from the SUBJECT.`;
// ── Katman 1: gemini-2.5-flash-image (Nano Banana) — 2 deneme ──
if (!forceImagen) {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
const result = await this.tryGenerateContentImage(
primaryModel,
enhancedPrompt,
);
if (result && result.buffer.length > 0) {
this.logger.log(
`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer: result.buffer, mimeType: result.mimeType };
}
const reason = result?.errorReason || 'null response';
this.logger.warn(
`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`,
);
if (
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)
) {
this.logger.warn(
`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`,
);
break; // Fail fast for safety blocks
}
if (attempt < 2) await this.sleep(2000);
} catch (err1: any) {
this.logger.warn(
`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`,
);
if (attempt < 2) await this.sleep(2000);
}
}
}
// ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ──
if (!forceImagen) {
try {
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
const result = await this.tryGenerateContentImage(
fallbackModel,
enhancedPrompt,
);
if (result && result.buffer.length > 0) {
this.logger.log(
`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer: result.buffer, mimeType: result.mimeType };
}
this.logger.warn(
`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`,
);
if (
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(
result?.errorReason || '',
)
) {
this.logger.warn(
`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`,
);
}
} catch (err2: any) {
this.logger.warn(
`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`,
);
}
}
// ── Katman 3: Imagen 4 Fast (generateImages API) ──
try {
this.logger.log(`🔄 Katman 3: Imagen 4 Fast`);
const response = await this.client!.models.generateImages({
model: 'imagen-4.0-fast-generate-001',
prompt: enhancedPrompt,
config: {
numberOfImages: 1,
aspectRatio: aspectRatio,
outputMimeType: 'image/jpeg',
},
});
if (response.generatedImages?.[0]?.image?.imageBytes) {
const buffer = Buffer.from(
response.generatedImages[0].image.imageBytes,
'base64',
);
const mimeType = 'image/jpeg';
this.logger.log(
`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`,
);
return { buffer, mimeType };
}
this.logger.warn(
`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`,
);
} catch (err3: any) {
this.logger.warn(
`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`,
);
}
this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu');
return null;
} catch (error) {
this.logger.error(
`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`,
);
throw error;
}
}
/**
* generateContent API ile görsel üretim denemesi.
* responseModalities: ['IMAGE', 'TEXT'] kullanarak inlineData içinden resim çıkarır.
*/
private async tryGenerateContentImage(
model: string,
prompt: string,
): Promise<{
buffer: Buffer;
mimeType: string;
errorReason?: string;
} | null> {
const response = await this.client!.models.generateContent({
model,
contents: prompt,
config: {
responseModalities: ['IMAGE', 'TEXT'],
},
});
const candidate = response.candidates?.[0];
// Safety filter veya boş yanıt kontrolü
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
const finishReason = candidate?.finishReason || 'UNKNOWN';
this.logger.warn(
`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`,
);
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: finishReason,
};
}
const imagePart = candidate.content.parts.find((p: any) =>
p.inlineData?.mimeType?.startsWith('image/'),
);
if (imagePart?.inlineData?.data) {
const buffer = Buffer.from(imagePart.inlineData.data, 'base64');
const mimeType = imagePart.inlineData.mimeType || 'image/png';
return { buffer, mimeType };
}
// Text-only response geldi (görsel yok)
const textParts = candidate.content.parts.filter((p: any) => p.text);
if (textParts.length > 0) {
this.logger.warn(
`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`,
);
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: 'TEXT_ONLY',
};
}
return {
buffer: Buffer.from([]),
mimeType: '',
errorReason: 'NO_IMAGE_DATA',
};
}
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Sahne bazlı görsel üret — visualPrompt ve video stili kullanarak.
*
* @param visualPrompt - Sahnenin İngilizce görsel açıklaması
* @param style - Video stili (cinematic, documentary, educational vb.)
* @param aspectRatio - En-boy oranı
* @returns Buffer ve mimeType
*/
async generateImageForScene(
visualPrompt: string,
style: string = 'cinematic',
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const enhancedPrompt = `${visualPrompt}. Style: ${style}, professional production quality, volumetric lighting, sharp details, 8K resolution.`;
return this.generateImage(enhancedPrompt, aspectRatio);
}
/**
* Video için thumbnail görsel üret — proje başlığı ve açıklamasından.
*
* @param title - Video başlığı
* @param description - Video açıklaması
* @returns Buffer ve mimeType
*/
async generateThumbnail(
title: string,
description: string,
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const prompt = `Create a compelling YouTube video thumbnail for a video titled "${title}". ${description}. Make it eye-catching with bold, dynamic composition. No text overlay needed.`;
return this.generateImage(prompt, '16:9');
}
}