diff --git a/src/main/java/kz/konturai/parser/config/ImageGenerationConfig.java b/src/main/java/kz/konturai/parser/config/ImageGenerationConfig.java new file mode 100644 index 0000000..ad3506b --- /dev/null +++ b/src/main/java/kz/konturai/parser/config/ImageGenerationConfig.java @@ -0,0 +1,44 @@ +package kz.konturai.parser.config; + +import kz.konturai.parser.service.ImageGenerationService; +import kz.konturai.parser.service.NanoBananaImageGenerationService; +import kz.konturai.parser.service.OpenAIImageGenerationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class ImageGenerationConfig { + + private static final Logger logger = LoggerFactory.getLogger(ImageGenerationConfig.class); + + @Value("${image.generation.provider:openai}") + private String provider; + + /** + * Создает bean ImageGenerationService на основе конфигурации. + * По умолчанию используется OpenAI, но можно переключиться на nano-banana через application.properties + * + * @param openAIService Сервис OpenAI DALL-E + * @param nanoBananaService Сервис Google Gemini Nano Banana + * @return Выбранный сервис генерации изображений + */ + @Bean + @Primary + public ImageGenerationService imageGenerationService( + OpenAIImageGenerationService openAIService, + NanoBananaImageGenerationService nanoBananaService) { + + if ("nano-banana".equalsIgnoreCase(provider)) { + logger.info("Using Nano Banana (Google Gemini) for image generation"); + return nanoBananaService; + } else { + logger.info("Using OpenAI DALL-E for image generation (default)"); + return openAIService; + } + } +} + diff --git a/src/main/java/kz/konturai/parser/service/ImageGenerationService.java b/src/main/java/kz/konturai/parser/service/ImageGenerationService.java new file mode 100644 index 0000000..bc47d6a --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ImageGenerationService.java @@ -0,0 +1,17 @@ +package kz.konturai.parser.service; + +/** + * Интерфейс для генерации изображений. + * Абстракция для различных провайдеров генерации изображений (OpenAI DALL-E, Google Gemini Nano Banana и т.д.) + */ +public interface ImageGenerationService { + + /** + * Генерирует изображение на основе текстового промпта + * + * @param prompt Текстовое описание изображения для генерации + * @return Массив байтов изображения в формате PNG, или null в случае ошибки + */ + byte[] generateImage(String prompt); +} + diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java index a87b76d..c1983f7 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyService.java @@ -31,7 +31,7 @@ public class MarketingStrategyService { private final MarketingAnalysisService marketingAnalysisService; private final MarketingAnalysisRepository analysisRepository; private final OpenAIAnalyticsService openAIAnalyticsService; - private final OpenAIImageGenerationService imageGenerationService; + private final ImageGenerationService imageGenerationService; private final MinIOService minIOService; private final PostingTaskService postingTaskService; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -41,7 +41,7 @@ public class MarketingStrategyService { MarketingAnalysisService marketingAnalysisService, MarketingAnalysisRepository analysisRepository, OpenAIAnalyticsService openAIAnalyticsService, - OpenAIImageGenerationService imageGenerationService, + ImageGenerationService imageGenerationService, MinIOService minIOService, PostingTaskService postingTaskService) { this.repository = repository; diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java new file mode 100644 index 0000000..3c26489 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -0,0 +1,302 @@ +package kz.konturai.parser.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class NanoBananaImageGenerationService implements ImageGenerationService { + + private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class); + private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/"; + + private final WebClient webClient; + + @Value("${google.gemini.api.key:}") + private String apiKey; + + @Value("${google.gemini.image.model:gemini-2.5-flash-image}") + private String model; + + @Value("${google.gemini.timeoutMs:90000}") + private long timeoutMs; + + @Value("${google.gemini.retry.maxAttempts:3}") + private int maxRetryAttempts; + + @Value("${google.gemini.retry.initialDelayMs:2000}") + private long initialRetryDelayMs; + + @Value("${google.gemini.retry.maxDelayMs:60000}") + private long maxRetryDelayMs; + + @Value("${google.gemini.retry.multiplier:2.0}") + private double retryMultiplier; + + public NanoBananaImageGenerationService() { + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofMillis(90000)); + + // Configure exchange strategies to increase buffer limit for large base64 responses + ExchangeStrategies strategies = ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10 MB + .build(); + + this.webClient = WebClient.builder() + .baseUrl(GEMINI_API_BASE_URL) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .exchangeStrategies(strategies) + .build(); + } + + /** + * Генерирует изображение через Google Gemini Nano Banana API + * + * @param prompt Промпт для генерации изображения + * @return Массив байтов изображения в формате PNG + */ + @Override + public byte[] generateImage(String prompt) { + if (apiKey == null || apiKey.trim().isEmpty()) { + logger.error("Google Gemini API key is not configured. Cannot generate image."); + return null; + } + + if (prompt == null || prompt.trim().isEmpty()) { + logger.warn("Empty prompt provided for image generation"); + return null; + } + + try { + // Обогащаем промпт для лучшего качества генерации + String enrichedPrompt = enrichPromptForGemini(prompt); + + // Формируем запрос согласно формату Gemini API + Map requestBody = new HashMap<>(); + List> contents = new ArrayList<>(); + Map content = new HashMap<>(); + List> parts = new ArrayList<>(); + Map part = new HashMap<>(); + part.put("text", enrichedPrompt); + parts.add(part); + content.put("parts", parts); + contents.add(content); + requestBody.put("contents", contents); + + String endpoint = model + ":generateContent"; + + logger.info("Requesting image generation from Nano Banana (Gemini) with model: {} and prompt: {}", + model, enrichedPrompt.substring(0, Math.min(100, enrichedPrompt.length()))); + + Map response = webClient.post() + .uri(uriBuilder -> uriBuilder + .path(endpoint) + .queryParam("key", apiKey) + .build()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .retryWhen(createRetrySpec("generateImage")) + .onErrorResume(err -> { + logger.error("Gemini API request failed after retries: {} - {}", err.getMessage(), + err.getClass().getSimpleName()); + if (err instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) err; + if (wcre.getStatusCode().value() == 401) { + logger.error( + "Google Gemini API key is invalid or expired. Please check your google.gemini.api.key configuration."); + } else if (wcre.getStatusCode().value() == 429) { + logger.error("Google Gemini API rate limit exceeded after all retry attempts."); + } else if (wcre.getStatusCode().value() >= 500) { + logger.error("Google Gemini API server error after all retry attempts."); + } + } + return Mono.empty(); + }) + .block(Duration.ofMillis(timeoutMs)); + + if (response == null) { + logger.error("Failed to generate image: empty response from Gemini API"); + return null; + } + + // Извлекаем изображение из ответа + byte[] imageBytes = extractImageFromResponse(response); + if (imageBytes != null) { + logger.info("Successfully generated image. Size: {} bytes", imageBytes.length); + } else { + logger.error("Failed to extract image from Gemini API response"); + } + return imageBytes; + + } catch (Exception e) { + logger.error("Error generating image with Gemini: {}", e.getMessage(), e); + return null; + } + } + + /** + * Извлекает изображение из ответа Gemini API + * Поддерживает различные форматы ответа: base64 в inlineData или URL + */ + @SuppressWarnings("unchecked") + private byte[] extractImageFromResponse(Map response) { + try { + // Структура ответа Gemini API: + // { + // "candidates": [{ + // "content": { + // "parts": [{ + // "inlineData": { + // "mimeType": "image/png", + // "data": "base64_encoded_image_data" + // } + // }] + // } + // }] + // } + + List> candidates = (List>) response.get("candidates"); + if (candidates == null || candidates.isEmpty()) { + logger.warn("No candidates in Gemini API response"); + return null; + } + + Map firstCandidate = candidates.get(0); + Map content = (Map) firstCandidate.get("content"); + if (content == null) { + logger.warn("No content in candidate"); + return null; + } + + List> parts = (List>) content.get("parts"); + if (parts == null || parts.isEmpty()) { + logger.warn("No parts in content"); + return null; + } + + // Ищем часть с изображением + for (Map part : parts) { + Map inlineData = (Map) part.get("inlineData"); + if (inlineData != null) { + String data = (String) inlineData.get("data"); + if (data != null && !data.isEmpty()) { + // Декодируем base64 в массив байтов + return java.util.Base64.getDecoder().decode(data); + } + } + } + + logger.warn("No image data found in Gemini API response parts"); + return null; + + } catch (Exception e) { + logger.error("Error extracting image from Gemini API response: {}", e.getMessage(), e); + return null; + } + } + + /** + * Обогащает промпт для Gemini, добавляя инструкции для корректной генерации изображений + * и правильного отображения русского текста + * + * @param originalPrompt Исходный промпт от пользователя + * @return Обогащенный промпт с инструкциями для Gemini + */ + private String enrichPromptForGemini(String originalPrompt) { + // Проверка на пустую строку + if (originalPrompt == null || originalPrompt.trim().isEmpty()) { + return originalPrompt; + } + + // Базовый шаблон обертки + String baseWrapper = "I will provide a description of an image. Generate the image strictly following this description without adding extra objects or changing the artistic style unless specified. "; + + // Проверка на текст в кавычках (русский текст) + boolean hasQuotedText = originalPrompt.contains("\"") || originalPrompt.contains("«") + || originalPrompt.contains("»"); + if (hasQuotedText) { + baseWrapper += "If the description contains text in quotes, write that text clearly and accurately in Russian. The text in quotes must be written exactly as provided, using the Russian alphabet (Cyrillic), with correct grammar and spelling. "; + } + + // Проверка на запрос фото и добавление профессиональных ключевых слов + String lowerPrompt = originalPrompt.toLowerCase(); + if (lowerPrompt.contains("фото") || lowerPrompt.contains("фотография") || lowerPrompt.contains("photo")) { + baseWrapper += "Use natural lighting, high resolution, editorial style. "; + } + + return baseWrapper + "Description: " + originalPrompt; + } + + private RetryBackoffSpec createRetrySpec(String operation) { + return Retry.backoff(maxRetryAttempts, Duration.ofMillis(initialRetryDelayMs)) + .maxBackoff(Duration.ofMillis(maxRetryDelayMs)) + .multiplier(retryMultiplier) + .filter(throwable -> { + if (throwable instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) throwable; + int statusCode = wcre.getStatusCode().value(); + // Only retry on 429 (rate limit) and 5xx (server errors) + return statusCode == 429 || statusCode >= 500; + } + // Retry on network errors + return throwable instanceof java.util.concurrent.TimeoutException + || throwable instanceof java.net.ConnectException + || throwable instanceof java.io.IOException; + }) + .doBeforeRetry(retrySignal -> { + long attempt = retrySignal.totalRetries() + 1; + Throwable failure = retrySignal.failure(); + + Duration retryAfter = null; + if (failure instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) failure; + if (wcre.getStatusCode().value() == 429) { + String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After"); + if (retryAfterHeader != null) { + try { + int seconds = Integer.parseInt(retryAfterHeader); + retryAfter = Duration.ofSeconds(seconds); + logger.warn( + "Gemini API returned 429 for operation '{}'. Retry-After: {} seconds. Will retry in {}ms (attempt {}/{})", + operation, seconds, retryAfter.toMillis(), attempt, maxRetryAttempts); + } catch (NumberFormatException e) { + // Ignore if header is not a number + } + } + } + } + + if (retryAfter == null) { + logger.warn("Gemini API returned error for operation '{}'. Will retry (attempt {}/{})", + operation, attempt, maxRetryAttempts); + } + }) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + logger.error("Gemini request for operation '{}' exhausted all {} retry attempts. Giving up.", + operation, maxRetryAttempts); + return retrySignal.failure(); + }); + } +} + diff --git a/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java b/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java index 1ac3d9d..804277f 100644 --- a/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java @@ -21,7 +21,7 @@ import java.util.List; import java.util.Map; @Service -public class OpenAIImageGenerationService { +public class OpenAIImageGenerationService implements ImageGenerationService { private static final Logger logger = LoggerFactory.getLogger(OpenAIImageGenerationService.class); private static final String DALL_E_API_URL = "https://api.openai.com/v1/images/generations"; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 549746c..1cf3073 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -89,6 +89,21 @@ openai.image.size=1024x1024 openai.image.quality=hd openai.image.style=natural +# Image Generation Provider Selection +# Options: 'openai' (default) or 'nano-banana' +# Set to 'nano-banana' to use Google Gemini Nano Banana for image generation +image.generation.provider=nano-banana + +# Google Gemini Nano Banana Image Generation Configuration +# Get API key from: https://aistudio.google.com/ +google.gemini.api.key=AIzaSyAHv_R0KIk7WyZLn1tL4MglrTepsjQWT_k +google.gemini.image.model=gemini-2.5-flash-image +google.gemini.timeoutMs=90000 +google.gemini.retry.maxAttempts=3 +google.gemini.retry.initialDelayMs=2000 +google.gemini.retry.maxDelayMs=60000 +google.gemini.retry.multiplier=2.0 + # Email Configuration spring.mail.host=smtp.gmail.com spring.mail.port=587