From 63e4da9143e26be2b829c8b80655e83303470b82 Mon Sep 17 00:00:00 2001 From: arys Date: Mon, 5 Jan 2026 23:43:56 +0500 Subject: [PATCH] com --- .../NanoBananaImageGenerationService.java | 328 ++++-------------- .../service/ReportGenerationService.java | 59 +--- src/main/resources/application.properties | 2 +- 3 files changed, 78 insertions(+), 311 deletions(-) diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java index 9afd0c7..dfe5599 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -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 requestBody = buildRequestBody(enrichedPrompt); + String endpoint = model + ":predict"; - String endpoint = model + ":generateContent"; - final String operation = "generateImage"; + Map 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 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(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>() {}) + .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 buildRequestBody(String enrichedPrompt) { - 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); - return requestBody; + private Map buildImagenRequestBody(String prompt) { + Map body = new HashMap<>(); + + List> instances = new ArrayList<>(); + Map instance = new HashMap<>(); + instance.put("prompt", prompt); + instances.add(instance); + + body.put("instances", instances); + + Map 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 response) { + private byte[] extractImageFromImagenResponse(Map response) { + if (response == null) return null; + try { - // Структура ответа Gemini API: - // { - // "candidates": [{ - // "content": { - // "parts": [{ - // "inlineData": { - // "mimeType": "image/png", - // "data": "base64_encoded_image_data" - // } - // }] - // } - // }] - // } + List> predictions = (List>) response.get("predictions"); - List> candidates = (List>) response.get("candidates"); - if (candidates == null || candidates.isEmpty()) { - logger.warn("No candidates in Gemini API response"); - return null; - } + if (predictions != null && !predictions.isEmpty()) { + Map firstPrediction = predictions.get(0); + String base64Image = (String) firstPrediction.get("bytesBase64Encoded"); - Map firstCandidate = candidates.get(0); - Map content = (Map) firstCandidate.get("content"); - if (content == null) { - logger.warn("No content in candidate"); - return null; - } + if (base64Image == null) { + base64Image = (String) firstPrediction.get("b64"); + } - 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); - } + 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; + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java index f19ac61..a1af54d 100644 --- a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java @@ -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 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 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) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5875d41..c88da6e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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