This commit is contained in:
root
2026-01-03 11:46:08 +05:00
parent 414c9ff697
commit 2c5d15fb33
6 changed files with 381 additions and 3 deletions
@@ -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;
}
}
}
@@ -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);
}
@@ -31,7 +31,7 @@ public class MarketingStrategyService {
private final MarketingAnalysisService marketingAnalysisService; private final MarketingAnalysisService marketingAnalysisService;
private final MarketingAnalysisRepository analysisRepository; private final MarketingAnalysisRepository analysisRepository;
private final OpenAIAnalyticsService openAIAnalyticsService; private final OpenAIAnalyticsService openAIAnalyticsService;
private final OpenAIImageGenerationService imageGenerationService; private final ImageGenerationService imageGenerationService;
private final MinIOService minIOService; private final MinIOService minIOService;
private final PostingTaskService postingTaskService; private final PostingTaskService postingTaskService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@@ -41,7 +41,7 @@ public class MarketingStrategyService {
MarketingAnalysisService marketingAnalysisService, MarketingAnalysisService marketingAnalysisService,
MarketingAnalysisRepository analysisRepository, MarketingAnalysisRepository analysisRepository,
OpenAIAnalyticsService openAIAnalyticsService, OpenAIAnalyticsService openAIAnalyticsService,
OpenAIImageGenerationService imageGenerationService, ImageGenerationService imageGenerationService,
MinIOService minIOService, MinIOService minIOService,
PostingTaskService postingTaskService) { PostingTaskService postingTaskService) {
this.repository = repository; this.repository = repository;
@@ -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<String, Object> requestBody = new HashMap<>();
List<Map<String, Object>> contents = new ArrayList<>();
Map<String, Object> content = new HashMap<>();
List<Map<String, Object>> parts = new ArrayList<>();
Map<String, Object> 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<String, Object> 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<Map<String, Object>>() {
})
.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<String, Object> response) {
try {
// Структура ответа Gemini API:
// {
// "candidates": [{
// "content": {
// "parts": [{
// "inlineData": {
// "mimeType": "image/png",
// "data": "base64_encoded_image_data"
// }
// }]
// }
// }]
// }
List<Map<String, Object>> candidates = (List<Map<String, Object>>) response.get("candidates");
if (candidates == null || candidates.isEmpty()) {
logger.warn("No candidates in Gemini API response");
return null;
}
Map<String, Object> firstCandidate = candidates.get(0);
Map<String, Object> content = (Map<String, Object>) firstCandidate.get("content");
if (content == null) {
logger.warn("No content in candidate");
return null;
}
List<Map<String, Object>> parts = (List<Map<String, Object>>) content.get("parts");
if (parts == null || parts.isEmpty()) {
logger.warn("No parts in content");
return null;
}
// Ищем часть с изображением
for (Map<String, Object> part : parts) {
Map<String, Object> inlineData = (Map<String, Object>) 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();
});
}
}
@@ -21,7 +21,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
public class OpenAIImageGenerationService { public class OpenAIImageGenerationService implements ImageGenerationService {
private static final Logger logger = LoggerFactory.getLogger(OpenAIImageGenerationService.class); private static final Logger logger = LoggerFactory.getLogger(OpenAIImageGenerationService.class);
private static final String DALL_E_API_URL = "https://api.openai.com/v1/images/generations"; private static final String DALL_E_API_URL = "https://api.openai.com/v1/images/generations";
+15
View File
@@ -89,6 +89,21 @@ openai.image.size=1024x1024
openai.image.quality=hd openai.image.quality=hd
openai.image.style=natural 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 # Email Configuration
spring.mail.host=smtp.gmail.com spring.mail.host=smtp.gmail.com
spring.mail.port=587 spring.mail.port=587