This commit is contained in:
arys
2026-01-05 23:43:56 +05:00
parent 2c5f78d001
commit 63e4da9143
3 changed files with 78 additions and 311 deletions
@@ -6,9 +6,9 @@ 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.ExchangeStrategies;
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;
@@ -24,20 +24,17 @@ import java.util.concurrent.Semaphore;
public class NanoBananaImageGenerationService implements ImageGenerationService {
private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class);
// Базовый URL остался, но эндпоинты могут отличаться
private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/";
// Strict concurrency control: only one image generation at a time
private final Semaphore semaphore = new Semaphore(1);
// Pacing control: track last request timestamp
private volatile long lastRequestTimestamp = 0;
private final WebClient webClient;
@Value("${google.gemini.api.key:}")
private String apiKey;
@Value("${google.gemini.image.model:gemini-2.5-flash-image}")
@Value("${google.gemini.image.model:imagen-3.0-generate-001}")
private String model;
@Value("${google.gemini.timeoutMs:120000}")
@@ -46,15 +43,6 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
@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;
@Value("${google.gemini.rateLimit.delayMs:12000}")
private long rateLimitDelayMs;
@@ -62,10 +50,8 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofMillis(120000));
// 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
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(15 * 1024 * 1024)) // Увеличим до 15MB
.build();
this.webClient = WebClient.builder()
@@ -75,307 +61,109 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
.build();
}
/**
* Генерирует изображение через Google Gemini Nano Banana API
*
* Logic Flow:
* 1. Acquire Semaphore (strict concurrency control)
* 2. Calculate and execute Sleep (Pacing)
* 3. Execute WebClient call with 15s+ backoff for 429
* 4. Extract Image
* 5. Update Timestamp
* 6. Release Semaphore
*
* @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.");
logger.error("API Key is missing!");
return null;
}
if (prompt == null || prompt.trim().isEmpty()) {
logger.warn("Empty prompt provided for image generation");
return null;
}
// Step 1: Acquire Semaphore (strict concurrency control)
try {
semaphore.acquire();
logger.debug("Acquired semaphore for image generation");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Interrupted while waiting for semaphore");
return null;
}
try {
// Step 2: Calculate and execute Sleep (Pacing)
long currentTime = System.currentTimeMillis();
long timeSinceLastRequest = currentTime - lastRequestTimestamp;
if (timeSinceLastRequest < rateLimitDelayMs) {
long waitTime = rateLimitDelayMs - timeSinceLastRequest;
logger.info("Pacing: Waiting {}ms before next image generation...", waitTime);
try {
Thread.sleep(waitTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Interrupted during pacing delay");
return null;
}
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
if (timeSinceLast < rateLimitDelayMs) {
Thread.sleep(rateLimitDelayMs - timeSinceLast);
}
// Обогащаем промпт для лучшего качества генерации
String enrichedPrompt = enrichPromptForGemini(prompt);
// Формируем тело запроса
Map<String, Object> requestBody = buildRequestBody(enrichedPrompt);
String endpoint = model + ":predict";
String endpoint = model + ":generateContent";
final String operation = "generateImage";
Map<String, Object> requestBody = buildImagenRequestBody(enrichedPrompt);
logger.info("Requesting image generation from Nano Banana (Gemini) with model: {} and prompt: {}",
model, enrichedPrompt.substring(0, Math.min(100, enrichedPrompt.length())));
logger.info("Sending request to Imagen model: {}", model);
// Step 3: Execute WebClient call with enhanced retry logic for 429
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(createRetrySpecFor429(operation))
.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();
})
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(createRetrySpecFor429("generateImage"))
.doOnError(e -> logger.error("API Error: {}", e.getMessage()))
.block(Duration.ofMillis(timeoutMs));
if (response == null) {
logger.error("Failed to generate image: empty response from Gemini API");
return null;
}
// Step 4: Extract Image
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");
}
// Step 5: Update Timestamp (after successful response)
lastRequestTimestamp = System.currentTimeMillis();
return imageBytes;
return extractImageFromImagenResponse(response);
} catch (Exception e) {
logger.error("Error generating image with Gemini: {}", e.getMessage(), e);
logger.error("Fatal error in NanoBanana: {}", e.getMessage(), e);
return null;
} finally {
// Step 6: Release Semaphore
semaphore.release();
logger.debug("Released semaphore after image generation");
}
}
/**
* Создает тело запроса для Gemini API
*
* @param enrichedPrompt Обогащенный промпт
* @return Тело запроса в формате Map
* Специфичный Request Body для модели Imagen
*/
private Map<String, Object> buildRequestBody(String enrichedPrompt) {
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);
return requestBody;
private Map<String, Object> buildImagenRequestBody(String prompt) {
Map<String, Object> body = new HashMap<>();
List<Map<String, Object>> instances = new ArrayList<>();
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
instances.add(instance);
body.put("instances", instances);
Map<String, Object> parameters = new HashMap<>();
parameters.put("sampleCount", 1); // Генерируем 1 картинку
parameters.put("aspectRatio", "1:1"); // Можно вынести в настройки
// parameters.put("personGeneration", "allow_adult"); // Если нужно (зависит от прав API ключа)
body.put("parameters", parameters);
return body;
}
/**
* Создает retry стратегию с улучшенной обработкой 429 ошибок.
* Для 429 ошибок использует минимум 15 секунд перед первым retry.
*
* @param operation Название операции для логирования
* @return RetryBackoffSpec с настройками для 429 и 5xx ошибок
*/
private reactor.util.retry.RetryBackoffSpec createRetrySpecFor429(String operation) {
return reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(15))
.maxBackoff(Duration.ofSeconds(90))
.multiplier(2.0)
.filter(throwable -> {
if (throwable instanceof WebClientResponseException) {
WebClientResponseException wcre = (WebClientResponseException) throwable;
int statusCode = wcre.getStatusCode().value();
if (statusCode == 429) {
String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After");
if (retryAfterHeader != null) {
try {
int seconds = Integer.parseInt(retryAfterHeader);
logger.warn(
"Gemini API returned 429 for operation '{}'. Retry-After: {} seconds",
operation, seconds);
} catch (NumberFormatException e) {
logger.warn(
"Gemini API returned 429 for operation '{}'. Invalid Retry-After header: {}",
operation, retryAfterHeader);
}
} else {
logger.warn(
"Gemini API returned 429 for operation '{}'. No Retry-After header. Using 15s+ backoff",
operation);
}
return true; // Retry для 429
}
return statusCode >= 500; // Retry для 5xx
}
// Не делаем retry на других ошибках
return false;
})
.doBeforeRetry(retrySignal -> {
long attempt = retrySignal.totalRetries() + 1;
Throwable failure = retrySignal.failure();
if (failure instanceof WebClientResponseException) {
WebClientResponseException wcre = (WebClientResponseException) failure;
if (wcre.getStatusCode().value() == 429) {
logger.warn(
"Retrying after rate limit (429) - attempt {}/{}",
attempt, maxRetryAttempts);
} else if (wcre.getStatusCode().value() >= 500) {
logger.warn(
"Retrying after server error ({}) - attempt {}/{}",
wcre.getStatusCode().value(), attempt, maxRetryAttempts);
}
}
})
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
logger.error(
"Gemini request for operation '{}' exhausted all {} retry attempts. Giving up.",
operation, maxRetryAttempts);
return retrySignal.failure();
});
}
/**
* Извлекает изображение из ответа Gemini API
* Поддерживает различные форматы ответа: base64 в inlineData или URL
*/
@SuppressWarnings("unchecked")
private byte[] extractImageFromResponse(Map<String, Object> response) {
private byte[] extractImageFromImagenResponse(Map<String, Object> response) {
if (response == null) return null;
try {
// Структура ответа Gemini API:
// {
// "candidates": [{
// "content": {
// "parts": [{
// "inlineData": {
// "mimeType": "image/png",
// "data": "base64_encoded_image_data"
// }
// }]
// }
// }]
// }
List<Map<String, Object>> predictions = (List<Map<String, Object>>) response.get("predictions");
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;
}
if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> firstPrediction = predictions.get(0);
String base64Image = (String) firstPrediction.get("bytesBase64Encoded");
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;
}
if (base64Image == null) {
base64Image = (String) firstPrediction.get("b64");
}
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);
}
if (base64Image != null) {
return java.util.Base64.getDecoder().decode(base64Image);
}
}
logger.warn("No image data found in Gemini API response parts");
return null;
logger.warn("No 'predictions' or base64 data found in response: {}", response);
} catch (Exception e) {
logger.error("Error extracting image from Gemini API response: {}", e.getMessage(), e);
return null;
logger.error("Parsing error: {}", e.getMessage());
}
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 reactor.util.retry.RetryBackoffSpec createRetrySpecFor429(String operation) {
return reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(5))
.maxBackoff(Duration.ofSeconds(30))
.filter(t -> t instanceof WebClientResponseException &&
((WebClientResponseException) t).getStatusCode().value() == 429);
}
}
private String enrichPromptForGemini(String original) {
return "High quality, photorealistic image of: " + original;
}
}
@@ -52,36 +52,15 @@ public class ReportGenerationService {
}
}
private static class Sections {
private final String annotation;
private final String introduction;
private final String mainNarrative;
private final String recommendations;
private Sections(String annotation, String introduction, String mainNarrative, String recommendations) {
this.annotation = annotation == null ? "" : annotation;
this.introduction = introduction == null ? "" : introduction;
this.mainNarrative = mainNarrative == null ? "" : mainNarrative;
this.recommendations = recommendations == null ? "" : recommendations;
private record Sections(String annotation, String introduction, String mainNarrative, String recommendations) {
private Sections(String annotation, String introduction, String mainNarrative, String recommendations) {
this.annotation = annotation == null ? "" : annotation;
this.introduction = introduction == null ? "" : introduction;
this.mainNarrative = mainNarrative == null ? "" : mainNarrative;
this.recommendations = recommendations == null ? "" : recommendations;
}
}
public String getAnnotation() {
return annotation;
}
public String getIntroduction() {
return introduction;
}
public String getMainNarrative() {
return mainNarrative;
}
public String getRecommendations() {
return recommendations;
}
}
private final MarketItemRepository marketItemRepository;
private final OpenAIAnalyticsService openAIAnalyticsService;
private final ReportHistoryRepository reportHistoryRepository;
@@ -290,20 +269,20 @@ public class ReportGenerationService {
// Annotation
addHeading(doc, "Краткое содержание");
addParagraph(doc, sections.getAnnotation().isBlank() ? "(недоступно)" : sections.getAnnotation());
addParagraph(doc, sections.annotation().isBlank() ? "(недоступно)" : sections.annotation());
// Intro
addHeading(doc, "Введение");
addParagraph(doc, sections.getIntroduction().isBlank()
addParagraph(doc, sections.introduction().isBlank()
? String.format(
"Целью данного отчёта является анализ новостного фона в сфере маркетинга и бизнеса за период с %s по %s. Были проанализированы публикации из ключевых источников для выявления основных трендов.",
req.getStartDate() != null ? req.getStartDate().format(dt) : "не указано",
req.getEndDate() != null ? req.getEndDate().format(dt) : "не указано")
: sections.getIntroduction());
: sections.introduction());
// Main
addHeading(doc, "Основная часть");
addParagraph(doc, sections.getMainNarrative());
addParagraph(doc, sections.mainNarrative());
addSubheading(doc, "Ключевые публикации");
List<MarketItem> top = items.stream()
.sorted(Comparator
@@ -337,9 +316,9 @@ public class ReportGenerationService {
// Recommendations
addHeading(doc, "Рекомендации");
addParagraph(doc, sections.getRecommendations().isBlank()
addParagraph(doc, sections.recommendations().isBlank()
? "Рассмотрите возможность усиления присутствия в медиа по ключевым темам отчёта."
: sections.getRecommendations());
: sections.recommendations());
// Bibliography
addHeading(doc, "Список литературы");
@@ -375,18 +354,18 @@ public class ReportGenerationService {
"\nСодержание: Аннотация, Введение, Основная часть, Рекомендации, Список литературы, Приложения\n"));
pdf.add(new Paragraph("Краткое содержание"));
pdf.add(new Paragraph(sections.getAnnotation().isBlank() ? "(недоступно)" : sections.getAnnotation()));
pdf.add(new Paragraph(sections.annotation().isBlank() ? "(недоступно)" : sections.annotation()));
pdf.add(new Paragraph("Введение"));
pdf.add(new Paragraph(sections.getIntroduction().isBlank()
pdf.add(new Paragraph(sections.introduction().isBlank()
? String.format(
"Целью данного отчёта является анализ новостного фона в сфере маркетинга и бизнеса за период с %s по %s. Были проанализированы публикации из ключевых источников для выявления основных трендов.",
req.getStartDate() != null ? req.getStartDate().format(dt) : "не указано",
req.getEndDate() != null ? req.getEndDate().format(dt) : "не указано")
: sections.getIntroduction()));
: sections.introduction()));
pdf.add(new Paragraph("Основная часть"));
pdf.add(new Paragraph(sections.getMainNarrative()));
pdf.add(new Paragraph(sections.mainNarrative()));
pdf.add(new Paragraph("Ключевые публикации"));
List<MarketItem> top = items.stream()
.sorted(Comparator
@@ -436,9 +415,9 @@ public class ReportGenerationService {
pdf.add(new Paragraph("Персоны: " + formatTop(persons)));
pdf.add(new Paragraph("Рекомендации"));
pdf.add(new Paragraph(sections.getRecommendations().isBlank()
pdf.add(new Paragraph(sections.recommendations().isBlank()
? "Рассмотрите возможность усиления присутствия в медиа по ключевым темам отчёта."
: sections.getRecommendations()));
: sections.recommendations()));
pdf.add(new Paragraph("Список литературы"));
for (MarketItem mi : items) {
+1 -1
View File
@@ -97,7 +97,7 @@ 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.image.model=imagen-2.0
google.gemini.timeoutMs=120000
google.gemini.retry.maxAttempts=3
google.gemini.retry.initialDelayMs=10000