This commit is contained in:
arys
2026-03-01 14:55:34 +05:00
parent 19e008b7ff
commit 832b93f151
4 changed files with 157 additions and 169 deletions
@@ -1,86 +1,128 @@
package kz.konturai.parser.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.google.auth.oauth2.GoogleCredentials;
import io.netty.resolver.DefaultAddressResolverGroup;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
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 reactor.netty.http.client.HttpClient;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.time.Duration;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class GeminiVideoGenerationService {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
private static final String VERTEX_API_TEMPLATE = "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict";
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json";
@Value("${gemini.veo.api.url:https://generativelanguage.googleapis.com/v1beta/models/veo:generateVideo}")
private String videoApiUrl;
private final WebClient webClient;
@Value("${gemini.veo.api.key:YOUR_API_KEY}")
private String apiKey;
@Value("${google.cloud.project-id}")
private String projectId;
@Value("${google.cloud.location:us-central1}")
private String location;
@Value("${google.gemini.video.model:veo-2.0-generate-001}")
private String model;
public GeminiVideoGenerationService() {
HttpClient httpClient = HttpClient.create()
.resolver(DefaultAddressResolverGroup.INSTANCE)
.responseTimeout(Duration.ofMillis(300000));
ExchangeStrategies strategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(100 * 1024 * 1024))
.build();
this.webClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.exchangeStrategies(strategies)
.build();
}
public byte[] generateVideo(String prompt) {
if (projectId == null || projectId.isEmpty()) {
return null;
}
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("x-goog-api-key", apiKey);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("prompt", prompt);
requestBody.put("aspectRatio", "9:16");
requestBody.put("resolution", "1080p");
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.exchange(
videoApiUrl,
HttpMethod.POST,
entity,
String.class
);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
String jsonBody = response.getBody();
JsonNode rootNode = objectMapper.readTree(jsonBody);
String base64Video = null;
if (rootNode.has("predictions") && rootNode.get("predictions").isArray()) {
JsonNode prediction = rootNode.get("predictions").get(0);
if (prediction.has("bytesBase64Encoded")) {
base64Video = prediction.get("bytesBase64Encoded").asText();
}
} else if (rootNode.has("candidates") && rootNode.get("candidates").isArray()) {
JsonNode parts = rootNode.at("/candidates/0/content/parts");
if (parts.isArray() && parts.size() > 0 && parts.get(0).has("inlineData")) {
base64Video = parts.get(0).at("/inlineData/data").asText();
}
} else if (rootNode.has("videoBase64")) {
base64Video = rootNode.get("videoBase64").asText();
} else if (rootNode.has("base64")) {
base64Video = rootNode.get("base64").asText();
}
if (base64Video != null && !base64Video.isEmpty()) {
return Base64.getDecoder().decode(base64Video);
} else {
if (!jsonBody.trim().startsWith("{")) {
return jsonBody.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
}
return null;
}
} else {
String accessToken = getAccessTokenFromResources();
if (accessToken == null) {
return null;
}
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model);
Map<String, Object> requestBody = new HashMap<>();
List<Map<String, Object>> instances = new ArrayList<>();
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
instances.add(instance);
requestBody.put("instances", instances);
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
requestBody.put("parameters", parameters);
Map<String, Object> response = webClient.post()
.uri(URI.create(endpointUrl))
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(reactor.util.retry.Retry.backoff(3, Duration.ofSeconds(15))
.filter(t -> t instanceof WebClientResponseException &&
((WebClientResponseException) t).getStatusCode().value() == 429))
.block(Duration.ofMillis(300000));
return extractVideoFromResponse(response);
} catch (Exception e) {
return null;
}
}
private String getAccessTokenFromResources() {
try {
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
if (!resource.exists()) return null;
try (InputStream is = resource.getInputStream()) {
GoogleCredentials credentials = GoogleCredentials.fromStream(is)
.createScoped("https://www.googleapis.com/auth/cloud-platform");
credentials.refreshIfExpired();
return credentials.getAccessToken().getTokenValue();
}
} catch (IOException e) {
return null;
}
}
@SuppressWarnings("unchecked")
private byte[] extractVideoFromResponse(Map<String, Object> response) {
if (response == null) return null;
try {
List<Map<String, Object>> predictions = (List<Map<String, Object>>) response.get("predictions");
if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> firstPrediction = predictions.get(0);
String base64Video = (String) firstPrediction.get("bytesBase64Encoded");
if (base64Video != null) {
return Base64.getDecoder().decode(base64Video);
}
}
} catch (Exception e) {
}
return null;
}
}
@@ -42,7 +42,7 @@ public class MarketingStrategyV3Service {
@Value("${openai.model.name.text:gpt-4o}")
private String highIntelligenceModel;
@Value("${image.generation.delayBetweenRequestsMs:5000}") // Увеличили задержку до 5 сек
@Value("${image.generation.delayBetweenRequestsMs:5000}")
private long delayBetweenRequestsMs;
public Optional<MarketingStrategy> getStrategyById(String id) {
@@ -204,8 +204,6 @@ public class MarketingStrategyV3Service {
}
MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex);
// Убеждаемся, что тип контента фото, если регенерируем фото
item.setContentType("фото");
Optional<MarketingAnalysisV3Document> analysisOpt = analysisRepository.findById(strategy.getAnalysisId());
@@ -237,7 +235,6 @@ public class MarketingStrategyV3Service {
return item;
}
// НОВЫЙ МЕТОД ДЛЯ РЕГЕНЕРАЦИИ ВИДЕО
public MarketingStrategy.PostCalendarItem regeneratePostVideo(String strategyId, int postIndex) {
Optional<MarketingStrategy> optStrategy = repository.findById(strategyId);
if (optStrategy.isEmpty()) {
@@ -252,8 +249,6 @@ public class MarketingStrategyV3Service {
}
MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex);
// Меняем тип контента на видео
item.setContentType("видео");
Optional<MarketingAnalysisV3Document> analysisOpt = analysisRepository.findById(strategy.getAnalysisId());
@@ -266,7 +261,6 @@ public class MarketingStrategyV3Service {
doGenerateVideo(item, businessContext);
// Если видео не сгенерировалось (фоллбек на фото), сохраняем изменения
repository.save(strategy);
return item;
}
@@ -403,7 +397,7 @@ public class MarketingStrategyV3Service {
try {
if (contentType.contains("видео") || contentType.contains("reels") || contentType.contains("tiktok")) {
doGenerateVideoWithFallback(item, businessContext, brandName, currentRefBytes);
doGenerateVideo(item, businessContext);
} else {
doGenerateImage(item, businessContext, brandName, currentRefBytes);
}
@@ -416,30 +410,6 @@ public class MarketingStrategyV3Service {
}
}
// Выделенный метод для генерации видео с фоллбеком
private void doGenerateVideoWithFallback(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName, byte[] currentRefBytes) {
String videoPrompt = "High quality cinematic commercial video. Business niche: " + businessContext + ". Scene: " + item.getTheme() + ". Photorealistic, dynamic motion, 4k.";
byte[] videoBytes = null;
try {
videoBytes = geminiVideoService.generateVideo(videoPrompt);
} catch (Exception e) {
log.warn("Video service failed: {}", e.getMessage());
}
if (videoBytes != null && videoBytes.length > 0) {
String filename = "video_" + System.currentTimeMillis() + "_" + item.hashCode() + ".mp4";
minIOService.uploadFile(filename, videoBytes, "video/mp4");
item.setVideoUrl(filename);
item.setVideoFilename(filename);
} else {
log.warn("Fallback: Video generation failed. Switching to Image generation for theme: {}", item.getTheme());
item.setContentType("фото");
doGenerateImage(item, businessContext, brandName, currentRefBytes);
}
}
// Выделенный метод генерации видео БЕЗ фоллбека (для ручной регенерации)
private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, String businessContext) {
String videoPrompt = "High quality cinematic commercial video. Business niche: " + businessContext + ". Scene: " + item.getTheme() + ". Photorealistic, dynamic motion, 4k.";
try {
@@ -449,14 +419,17 @@ public class MarketingStrategyV3Service {
minIOService.uploadFile(filename, videoBytes, "video/mp4");
item.setVideoUrl(filename);
item.setVideoFilename(filename);
// Очищаем данные фото, так как теперь это видео
item.setImageUrl(null);
item.setImageFilename(null);
} else {
log.error("Failed to regenerate video, returned null bytes");
log.error("Video generation returned null for theme: {}", item.getTheme());
item.setVideoFilename(null);
item.setVideoUrl(null);
}
} catch (Exception e) {
log.error("Error regenerating video: {}", e.getMessage());
log.error("Error generating video: {}", e.getMessage());
item.setVideoFilename(null);
item.setVideoUrl(null);
}
}
@@ -469,12 +442,17 @@ public class MarketingStrategyV3Service {
minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG_VALUE);
item.setImageUrl(filename);
item.setImageFilename(filename);
// Очищаем данные видео, так как теперь это фото
item.setVideoUrl(null);
item.setVideoFilename(null);
} else {
log.error("Image generation returned null for theme: {}", item.getTheme());
item.setImageFilename(null);
item.setImageUrl(null);
}
} catch (Exception e) {
log.error("Image generation failed: {}", e.getMessage());
log.error("Image generation crashed: {}", e.getMessage());
item.setImageFilename(null);
item.setImageUrl(null);
}
}
@@ -46,10 +46,10 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
@Value("${google.gemini.timeoutMs:120000}")
private long timeoutMs;
@Value("${google.gemini.retry.maxAttempts:3}")
@Value("${google.gemini.retry.maxAttempts:5}")
private int maxRetryAttempts;
@Value("${google.gemini.rateLimit.delayMs:12000}")
@Value("${google.gemini.rateLimit.delayMs:15000}")
private long rateLimitDelayMs;
public NanoBananaImageGenerationService() {
@@ -79,14 +79,12 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
try {
return generateImageWithReference(prompt, referenceLogo.getBytes());
} catch (IOException e) {
logger.error("Error reading MultipartFile logo: {}", e.getMessage());
return generateImage(prompt);
}
}
public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) {
if (projectId == null || projectId.trim().isEmpty()) {
logger.error("Project ID is missing! Check application.properties");
return null;
}
@@ -104,7 +102,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
}
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model);
String finalPrompt = sanitizePromptForText(prompt);
String finalPrompt = prompt != null ? prompt : "High quality image";
try {
Map<String, Object> requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes);
@@ -113,18 +111,17 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
return extractImageFromImagenResponse(response);
} catch (WebClientResponseException.BadRequest badRequestEx) {
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
logger.warn("Vertex AI rejected the request with reference image (400 Bad Request). Retrying WITHOUT reference image...");
Map<String, Object> fallbackBody = buildImagenRequestBody(finalPrompt, null);
Map<String, Object> fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody);
lastRequestTimestamp = System.currentTimeMillis();
return extractImageFromImagenResponse(fallbackResponse);
} else {
throw badRequestEx;
}
return null;
} catch (Exception ex) {
return null;
}
} catch (Exception e) {
logger.error("Fatal error in NanoBanana (Vertex AI): {}", e.getMessage(), e);
return null;
} finally {
semaphore.release();
@@ -132,15 +129,27 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
}
private Map<String, Object> executeVertexRequest(String endpointUrl, String accessToken, Map<String, Object> requestBody) {
return webClient.post()
.uri(URI.create(endpointUrl))
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(createRetrySpecFor429())
.block(Duration.ofMillis(timeoutMs));
try {
return webClient.post()
.uri(URI.create(endpointUrl))
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(10))
.maxBackoff(Duration.ofSeconds(60))
.filter(t -> t instanceof WebClientResponseException &&
((WebClientResponseException) t).getStatusCode().value() == 429))
.block(Duration.ofMillis(timeoutMs));
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 400) {
throw e;
}
return null;
} catch (Exception e) {
return null;
}
}
private String getAccessTokenFromResources() {
@@ -149,7 +158,6 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
if (!resource.exists()) {
return null;
}
try (InputStream is = resource.getInputStream()) {
GoogleCredentials credentials = GoogleCredentials.fromStream(is)
.createScoped("https://www.googleapis.com/auth/cloud-platform");
@@ -161,11 +169,6 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
}
}
private String sanitizePromptForText(String originalPrompt) {
if (originalPrompt == null) return "High quality, photorealistic image";
return originalPrompt; // Убрали лишнюю очистку, которая могла ломать логику ИИ
}
private Map<String, Object> buildImagenRequestBody(String prompt, byte[] referenceImageBytes) {
Map<String, Object> body = new HashMap<>();
List<Map<String, Object>> instances = new ArrayList<>();
@@ -175,22 +178,18 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes);
Map<String, Object> referenceImageParams = new HashMap<>();
Map<String, Object> imageBytesMap = new HashMap<>();
imageBytesMap.put("bytesBase64Encoded", base64Image);
// Убрали mimeType, чтобы избежать 400 Bad Request из-за несоответствия типов
imageBytesMap.put("bytesBase64Encoded", base64Image);
referenceImageParams.put("referenceImage", imageBytesMap);
// Возвращаем STYLE, так как он меньше ломает геометрию
referenceImageParams.put("referenceType", "STYLE");
referenceImageParams.put("referenceType", "SUBJECT");
List<Map<String, Object>> refImagesList = new ArrayList<>();
refImagesList.add(referenceImageParams);
instance.put("referenceImages", refImagesList);
instance.put("prompt", prompt + ". Use the provided reference image.");
instance.put("prompt", prompt + ". Naturally integrate the provided reference subject into the scene.");
}
instances.add(instance);
@@ -201,8 +200,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
parameters.put("aspectRatio", "1:1");
parameters.put("safetyFilterLevel", "block_some");
parameters.put("personGeneration", "allow_adult");
// Убрали text из негативного промпта
parameters.put("negativePrompt", "nsfw, nudity, violence, deformed, ugly, blurry, distorted");
parameters.put("negativePrompt", "text, typography, watermark, signature, blurry, distorted, bad anatomy");
body.put("parameters", parameters);
return body;
@@ -224,15 +222,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
}
}
} catch (Exception e) {
logger.error("Parsing error: {}", e.getMessage());
}
return null;
}
private reactor.util.retry.RetryBackoffSpec createRetrySpecFor429() {
return reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(5))
.maxBackoff(Duration.ofSeconds(30))
.filter(t -> t instanceof WebClientResponseException &&
((WebClientResponseException) t).getStatusCode().value() == 429);
}
}
+4 -26
View File
@@ -3,17 +3,15 @@ ollama.model=gemma3:1b
ollama.timeoutMs=18000000
ollama.connectTimeoutMs=10000000
ollama.responseTimeoutMs=18000000
# MinIO configuration
minio.endpoint=http://92.38.48.166:9000
minio.access-key=z9HhZTjzhVWqw3D20u7q
minio.secret-key=lhhHtJow1b10z6EZRMTQIU1tsKkoaxCVpSypCrcb
minio.bucket-name=konturai
# Headless mode for chart generation in Docker
java.awt.headless=true
spring.application.name=parser
# MongoDB Configuration
spring.data.mongodb.host=92.38.48.166
spring.data.mongodb.port=27017
spring.data.mongodb.database=parser_db
@@ -21,24 +19,20 @@ spring.data.mongodb.username=konturai
spring.data.mongodb.password=konturai2024
spring.data.mongodb.authentication-database=admin
# RSS Feed URLs
rss.feed.url=https://kursiv.media/feed/
rss.kapital.url=https://kapital.kz/rss/
rss.lsm.url=https://lsm.kz/rss
rss.rbc.url=https://static.feed.rbc.ru/rbc/logical/footer/news.rss
rss.vedomosti.url=https://www.vedomosti.ru/rss/rubric/technology/internet
# Scheduler Configuration
parser.scheduler.enabled=false
spring.task.scheduling.pool.size=5
spring.task.scheduling.thread-name-prefix=scheduled-task-
# MongoDB Connection Settings
spring.data.mongodb.connection-timeout=30000
spring.data.mongodb.socket-timeout=30000
spring.data.mongodb.server-selection-timeout=30000
# Logging Configuration
logging.level.kz.konturai.parser.service.KursivParserService=INFO
logging.level.kz.konturai.parser.service.KapitalParserService=INFO
logging.level.kz.konturai.parser.service.LsmParserService=INFO
@@ -53,7 +47,6 @@ logging.level.kz.konturai.parser.service.MarketingAnalysisService=DEBUG
logging.level.kz.konturai.parser.config.MongoConfig=DEBUG
logging.level.org.springframework.data.mongodb.core.convert=DEBUG
# OpenAI Configuration
openai.api.key=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
openai.api.url=https://api.openai.com/v1/chat/completions
openai.model.name=gpt-4o-mini
@@ -69,7 +62,6 @@ openai.retry.rateLimitInitialDelayMs=5000
openai.retry.rateLimitMaxDelayMs=300000
openai.rateLimit.maxConcurrentRequests=3
# Serper.dev Web Search Configuration
serper.api.key=5ae7ad0baf7dfacb8bcd5fc8dca01f61b3207e75
serper.api.url=https://google.serper.dev
serper.timeoutMs=30000
@@ -78,27 +70,25 @@ serper.retry.initialDelayMs=1000
serper.retry.maxDelayMs=10000
serper.retry.multiplier=2.0
# OpenAI DALL-E Image Generation Configuration
openai.image.model=dall-e-3
openai.image.size=1024x1024
openai.image.quality=hd
openai.image.style=natural
# Image Generation Provider Selection
image.generation.provider=nano-banana
image.generation.delayBetweenRequestsMs=3000
google.cloud.project-id=data-totality-483616-t5
google.cloud.location=us-central1
google.gemini.image.model=imagen-3.0-generate-001
google.gemini.video.model=veo-2.0-generate-001
google.gemini.timeoutMs=120000
google.gemini.retry.maxAttempts=3
google.gemini.retry.maxAttempts=5
google.gemini.retry.initialDelayMs=10000
google.gemini.retry.maxDelayMs=120000
google.gemini.retry.multiplier=2.0
google.gemini.rateLimit.delayMs=12000
google.gemini.rateLimit.delayMs=20000
# Email Configuration
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=your-email@gmail.com
@@ -107,29 +97,17 @@ spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
# Deep Research API Configuration
deep-research.api.url=http://185.35.223.45:3051
deep-research.api.timeout=1800000
# JWT Configuration
security.jwt.secret-base64=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl
# Encryption Configuration
encryption.secret-key=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl
# Posting Scheduler Configuration
posting.scheduler.enabled=true
# Telegram API Configuration
telegram.api.timeout=30000
# ==========================================
# FACEBOOK MESSENGER CONFIGURATION
# ==========================================
facebook.api.timeout=30000
facebook.verify.token=konturAI
# ???? ?????? ??????? ????? ???????? (Page Access Token)
# ?????? ??? ? Meta Developers -> Messenger -> Settings -> Access Tokens (Generate Token)
facebook.page.access.token=EAAaocqgT3JoBQtNeU4uqBrywSRwGmFZBQDZBnd1ynWjX070wAa09QBKebjrd9vyjAZCiJuZAZCU8266VlJYTAfwue20QNvENgM3wpjWNmCrTO0gTMAkmPZBKZBXKvZA8eCA4aZCUhmU2jsBRluYqKUyqpYV3ZCK928OVqLqCNZCUhqU6nUU0xP0DOzk90x58tmxpS2y1i7hjQZDZD