From 1fa126286c86aa76ea38a5451752afd01ff70285 Mon Sep 17 00:00:00 2001 From: arys Date: Thu, 26 Mar 2026 10:22:19 +0500 Subject: [PATCH] fix --- .../MarketingAnalysisV3Controller.java | 2 +- .../service/GeminiVideoGenerationService.java | 200 +++-- .../service/MarketingStrategyV3Service.java | 727 ++++++++++-------- .../NanoBananaImageGenerationService.java | 291 +++++-- 4 files changed, 748 insertions(+), 472 deletions(-) diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java index e663edf..7d90819 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -614,7 +614,7 @@ public class MarketingAnalysisV3Controller { if (title.length() > 80) { title = title.substring(0, 77) + "..."; } - + response.setAnalysisTitle(title); } }); diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java index 1cf5032..c6a3325 100644 --- a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -25,12 +25,23 @@ import java.time.Duration; import java.util.*; /** - * Сервис генерации видео через Google Veo 3. + * Сервис генерации видео через Google Veo 3 (с fallback на Veo 2). * - * КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3: - * - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек - * - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский - * - Все промпты передаются на русском языке для казахстанского рынка + * ══ ЗВУК В VEO 3 ══ + * Veo 3 добавляет звук АВТОМАТИЧЕСКИ если в промпте описан звук. + * Параметр generateAudio пока НЕ поддерживается через Vertex AI REST API — + * он доступен только через Gemini API (gemini.google.com). + * Через Vertex AI (aiplatform.googleapis.com) звук включается описанием в промпте: + * "фоновая музыка в казахстанском стиле", "закадровый голос на русском" — работает. + * parameters.put("generateAudio", true) — вызывает 400 Bad Request, не используем. + * + * ══ FALLBACK ══ + * Если Veo 3 недоступен (модель ещё не в вашем проекте или 404/403) — + * автоматически пробуем Veo 2. Это решает проблему "видео не генерируются". + * + * ══ ПРОМПТЫ ══ + * enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский. + * Все промпты на русском — для казахстанского рынка. */ @Service public class GeminiVideoGenerationService { @@ -38,6 +49,9 @@ public class GeminiVideoGenerationService { private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class); private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json"; + // Fallback модель если Veo 3 недоступна в проекте + private static final String FALLBACK_MODEL = "veo-2.0-generate-001"; + private final WebClient webClient; private GoogleCredentials credentials; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -76,32 +90,32 @@ public class GeminiVideoGenerationService { this.credentials = GoogleCredentials.fromStream(is) .createScoped(Collections.singletonList( "https://www.googleapis.com/auth/cloud-platform")); - logger.info("VEO3: Credentials loaded. Project={}, Location={}, Model={}", + logger.info("VEO: Credentials loaded. Project={}, Location={}, PrimaryModel={}", projectId, location, model); } } else { - logger.error("VEO3: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH); + logger.error("VEO: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH); } } catch (IOException e) { - logger.error("VEO3: Error reading credentials: {}", e.getMessage()); + logger.error("VEO: Error reading credentials: {}", e.getMessage()); } } private String getValidAccessToken() { if (this.credentials == null) { - logger.error("VEO3: Credentials not initialized!"); + logger.error("VEO: Credentials not initialized!"); return null; } try { this.credentials.refreshIfExpired(); String token = this.credentials.getAccessToken().getTokenValue(); if (token == null || token.isEmpty()) { - logger.error("VEO3: Access token is null/empty after refresh!"); + logger.error("VEO: Token is null/empty after refresh!"); return null; } return token; } catch (IOException e) { - logger.error("VEO3: Token refresh error: {}", e.getMessage()); + logger.error("VEO: Token refresh error: {}", e.getMessage()); return null; } } @@ -109,24 +123,51 @@ public class GeminiVideoGenerationService { /** * Генерирует видео по промпту на РУССКОМ языке. * - * Промпт формируется в MarketingStrategyV3Service.doGenerateVideo() - * с учётом казахстанского контекста, города, платформы и темы поста. - * Veo 3 автоматически добавляет звук (музыку/диалоги) к видео. + * Промпт должен содержать описание звука для активации аудио в Veo 3: + * Например: "Звук: современная казахстанская музыка, лёгкий фон" + * + * Если Veo 3 недоступен — автоматический fallback на Veo 2. */ public byte[] generateVideo(String prompt) { if (projectId == null || projectId.trim().isEmpty()) { - logger.error("VEO3: Project ID is not configured!"); + logger.error("VEO: Project ID not configured!"); return null; } String accessToken = getValidAccessToken(); if (accessToken == null) return null; + // Сначала пробуем основную модель (Veo 3) + logger.info("VEO: Trying primary model: {}", model); + byte[] result = generateWithModel(prompt, model, accessToken); + + // Если не получилось — fallback на Veo 2 + if (result == null && !model.equals(FALLBACK_MODEL)) { + logger.warn("VEO: Primary model {} failed. Trying fallback: {}", model, FALLBACK_MODEL); + // Обновляем токен на случай если старый истёк за время первой попытки + String freshToken = getValidAccessToken(); + if (freshToken != null) { + result = generateWithModel(prompt, FALLBACK_MODEL, freshToken); + } + if (result != null) { + logger.info("VEO: Fallback model {} succeeded!", FALLBACK_MODEL); + } else { + logger.error("VEO: Both models failed. Video generation unavailable."); + } + } + + return result; + } + + /** + * Генерация через конкретную модель Veo. + */ + private byte[] generateWithModel(String prompt, String modelName, String accessToken) { try { - // ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════ + // ═══ ШАГ 1: Запуск генерации ════════════════════════════════════ String generateEndpoint = String.format( "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", - location, projectId, location, model); + location, projectId, location, modelName); Map instance = new HashMap<>(); instance.put("prompt", prompt); @@ -134,21 +175,25 @@ public class GeminiVideoGenerationService { Map parameters = new HashMap<>(); parameters.put("aspectRatio", "9:16"); parameters.put("sampleCount", 1); - // КРИТИЧНО: false — не давать Google переводить промпт на английский + // КРИТИЧНО: false — иначе Google переводит промпт на английский! parameters.put("enhancePrompt", false); - // Негативный промпт тоже на русском + // Негативный промпт на русском — единый язык с основным промптом parameters.put("negativePrompt", - "размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " + - "мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " + - "водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы"); + "размытое изображение, плохая анатомия, лишние конечности, " + + "деформированные лица, мультяшный стиль, 3D рендер, " + + "искусственный неестественный вид, субтитры на английском, " + + "водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " + + "дёрганые движения, низкое качество, пиксели"); + // ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST. + // Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI). Map requestBody = new HashMap<>(); requestBody.put("instances", Collections.singletonList(instance)); requestBody.put("parameters", parameters); - logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint); - logger.info("VEO3: Prompt (preview 300): '{}'", - prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt); + logger.info("VEO: Starting. Model={}", modelName); + logger.info("VEO: Prompt preview: '{}'", + prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt); Map initResponse; try { @@ -159,46 +204,45 @@ public class GeminiVideoGenerationService { .bodyValue(requestBody) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) - .block(Duration.ofSeconds(120)); + .block(Duration.ofSeconds(60)); } catch (WebClientResponseException e) { - logger.error("VEO3: HTTP {} on initial request. Body: {}", - e.getStatusCode(), e.getResponseBodyAsString()); + logger.error("VEO: HTTP {} on initial request (model={}). Body: {}", + e.getStatusCode(), modelName, e.getResponseBodyAsString()); + // 404 или 403 — модель недоступна в проекте, возвращаем null для fallback return null; } if (initResponse == null) { - logger.error("VEO3: Initial response is null!"); + logger.error("VEO: Null initial response for model={}", modelName); return null; } - logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse)); - String operationName = (String) initResponse.get("name"); if (operationName == null || operationName.isEmpty()) { - logger.error("VEO3: No 'name' field! Full response: {}", - objectMapper.writeValueAsString(initResponse)); + logger.error("VEO: No 'name' in response! model={} response={}", + modelName, objectMapper.writeValueAsString(initResponse)); return null; } - logger.info("VEO3: Operation started: {}", operationName); + logger.info("VEO: Operation started: {} (model={})", operationName, modelName); - // ═══ ШАГ 2: Polling через fetchPredictOperation ══════════════════ + // ═══ ШАГ 2: Polling ══════════════════════════════════════════════ String fetchEndpoint = String.format( "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", - location, projectId, location, model); + location, projectId, location, modelName); Map fetchBody = new HashMap<>(); fetchBody.put("operationName", operationName); int attempts = 0; - int maxAttempts = 120; // 30 минут (120 × 15 сек) + int maxAttempts = 120; // 30 минут while (attempts < maxAttempts) { - Thread.sleep(15000); + Thread.sleep(15_000); attempts++; String currentToken = getValidAccessToken(); if (currentToken == null) { - logger.warn("VEO3: No token on attempt {}, skipping...", attempts); + logger.warn("VEO: No token on attempt {}", attempts); continue; } @@ -213,15 +257,16 @@ public class GeminiVideoGenerationService { .block(Duration.ofSeconds(60)); if (statusResponse == null) { - logger.warn("VEO3: Null status on attempt {}", attempts); + logger.warn("VEO: Null status on attempt {}", attempts); continue; } String rawJson = objectMapper.writeValueAsString(statusResponse); - logger.info("VEO3: [Attempt {}/{}] Raw: {}", attempts, maxAttempts, rawJson); + logger.info("VEO: [Attempt {}/{}] model={} raw={}", + attempts, maxAttempts, modelName, rawJson); if (statusResponse.containsKey("error")) { - logger.error("VEO3: Google returned error: {}", + logger.error("VEO: Error in response: {}", objectMapper.writeValueAsString(statusResponse.get("error"))); return null; } @@ -231,33 +276,33 @@ public class GeminiVideoGenerationService { || "true".equalsIgnoreCase(String.valueOf(doneObj)); if (!isDone) { - logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts); + logger.info("VEO: Still processing {}/{}", attempts, maxAttempts); continue; } - logger.info("VEO3: DONE on attempt {}! Extracting...", attempts); + logger.info("VEO: DONE on attempt {}! Extracting video...", attempts); // ═══ ШАГ 3: Извлечение видео ═════════════════════════════ @SuppressWarnings("unchecked") Map responseObj = (Map) statusResponse.get("response"); if (responseObj == null) { - logger.warn("VEO3: No 'response' field, trying root..."); + logger.warn("VEO: No 'response' field — trying root..."); responseObj = statusResponse; } // Вариант 1 — base64 String b64 = findFirstValue(responseObj, "bytesBase64Encoded"); if (b64 != null && !b64.isEmpty()) { - logger.info("VEO3: Found base64 (length={})", b64.length()); - return decodeBase64Video(b64); + logger.info("VEO: Found base64 (len={})", b64.length()); + return decodeBase64(b64); } // Вариант 2 — GCS URI String videoUri = findFirstValue(responseObj, "videoUri"); if (videoUri == null) videoUri = findFirstValue(responseObj, "uri"); if (videoUri != null && !videoUri.isEmpty()) { - logger.info("VEO3: Found video URI: {}", videoUri); + logger.info("VEO: Found URI: {}", videoUri); return downloadFromGcs(videoUri, currentToken); } @@ -267,87 +312,78 @@ public class GeminiVideoGenerationService { (List>) responseObj.get("predictions"); if (predictions != null && !predictions.isEmpty()) { Map first = predictions.get(0); - logger.info("VEO3: Prediction keys: {}", first.keySet()); + logger.info("VEO: Prediction keys: {}", first.keySet()); b64 = (String) first.get("bytesBase64Encoded"); - if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64); + if (b64 != null && !b64.isEmpty()) return decodeBase64(b64); String uri = (String) first.get("videoUri"); if (uri == null) uri = (String) first.get("uri"); if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken); } - logger.error("VEO3: Video not found in response! Full dump: {}", rawJson); + logger.error("VEO: Video not found! Full: {}", rawJson); return null; } catch (WebClientResponseException e) { - logger.error("VEO3: HTTP error on attempt {}: {} — {}", - attempts, e.getStatusCode(), e.getResponseBodyAsString()); + logger.error("VEO: HTTP {} on attempt {}: {}", + e.getStatusCode(), attempts, e.getResponseBodyAsString()); if (e.getStatusCode().is4xxClientError()) { - logger.error("VEO3: 4xx — stopping poll."); + logger.error("VEO: 4xx — stopping poll for model={}", modelName); return null; } - // При 5xx продолжаем + // 5xx — продолжаем } } - logger.error("VEO3: TIMEOUT after {} minutes.", (maxAttempts * 15 / 60)); + logger.error("VEO: TIMEOUT after {} min for model={}", (maxAttempts * 15 / 60), modelName); return null; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - logger.error("VEO3: Thread interrupted"); + logger.error("VEO: Interrupted"); return null; } catch (Exception e) { - logger.error("VEO3: Fatal error: {}", e.getMessage(), e); + logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e); return null; } } - private byte[] decodeBase64Video(String b64) { + private byte[] decodeBase64(String b64) { try { String clean = b64.replaceAll("\\s", ""); - if (clean.contains("base64,")) { - clean = clean.substring(clean.indexOf("base64,") + 7); - } + if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7); byte[] bytes = Base64.getDecoder().decode(clean); - logger.info("VEO3: Decoded {} bytes from base64", bytes.length); + logger.info("VEO: Decoded {} bytes", bytes.length); return bytes; } catch (Exception e) { - logger.error("VEO3: Failed to decode base64: {}", e.getMessage()); + logger.error("VEO: base64 decode failed: {}", e.getMessage()); return null; } } private byte[] downloadFromGcs(String uri, String accessToken) { try { - String downloadUrl; - if (uri.startsWith("gs://")) { - downloadUrl = "https://storage.googleapis.com/" + uri.substring(5); - logger.info("VEO3: GCS → HTTPS: {}", downloadUrl); - } else if (uri.startsWith("https://")) { - downloadUrl = uri; - } else { - logger.error("VEO3: Unknown URI format: {}", uri); - return null; - } + String downloadUrl = uri.startsWith("gs://") + ? "https://storage.googleapis.com/" + uri.substring(5) + : uri; + logger.info("VEO: Downloading from: {}", downloadUrl); - byte[] videoBytes = webClient.get() + byte[] bytes = webClient.get() .uri(URI.create(downloadUrl)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() .bodyToMono(byte[].class) .block(Duration.ofMinutes(5)); - if (videoBytes != null && videoBytes.length > 0) { - logger.info("VEO3: Downloaded {} bytes from GCS", videoBytes.length); - return videoBytes; - } else { - logger.error("VEO3: Empty download from: {}", uri); - return null; + if (bytes != null && bytes.length > 0) { + logger.info("VEO: Downloaded {} bytes from GCS", bytes.length); + return bytes; } + logger.error("VEO: Empty download from: {}", uri); + return null; } catch (Exception e) { - logger.error("VEO3: Download error from {}: {}", uri, e.getMessage()); + logger.error("VEO: GCS download error {}: {}", uri, e.getMessage()); return null; } } diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index f1f5036..a159440 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -50,7 +50,7 @@ public class MarketingStrategyV3Service { @Value("${openai.model.name.text:gpt-4o}") private String highIntelligenceModel; - @Value("${image.generation.delayBetweenRequestsMs:5000}") + @Value("${image.generation.delayBetweenRequestsMs:3000}") private long delayBetweenRequestsMs; // ===================================================================== @@ -111,15 +111,12 @@ public class MarketingStrategyV3Service { ScoringResult scoring = calculateScoringModel(analysis.getRequestData(), analysis); log.info("[Strategy {}] Scoring → model={} (E:{} Au:{} T:{} C:{}) modifiers={}", - strategyId, - scoring.getModel(), + strategyId, scoring.getModel(), scoring.getEntryScore(), scoring.getAuthorityScore(), - scoring.getTrustScore(), scoring.getConversionScore(), + scoring.getTrustScore(), scoring.getConversionScore(), scoring.getActiveModifiers().stream() - .map(StrategyModifier::getTitle) - .collect(Collectors.joining(", "))); + .map(StrategyModifier::getTitle).collect(Collectors.joining(", "))); - // ── Сразу сохраняем название модели в entity ────────────────────── strategy.setScoringModelName(scoring.getModel().name()); strategy.setScoringModelTitle(scoring.getModel().getTitle()); addStatusHistory(strategy, "processing", "✅ " + scoring.getRationale()); @@ -129,7 +126,7 @@ public class MarketingStrategyV3Service { addStatusHistory(strategy, "processing", "Генерирую контент-план на 4 недели..."); repository.save(strategy); - String systemPrompt = buildSystemPrompt(scoring); + String systemPrompt = buildSystemPrompt(scoring, analysis); String userPrompt = buildUserPrompt(analysis, scoring); // Step 3: AI call @@ -138,25 +135,20 @@ public class MarketingStrategyV3Service { systemPrompt, 16000, 240_000L); String jsonResponse = extractCleanJson(rawResponse); - if (jsonResponse == null) { - throw new IllegalStateException("AI вернул невалидный JSON"); - } + if (jsonResponse == null) throw new IllegalStateException("AI вернул невалидный JSON"); Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); - // Переносим referenceFilenames из initialData if (strategy.getStrategyData() != null) { strategy.getStrategyData().forEach(strategyData::putIfAbsent); } - // Скоринг-метаданные для фронта (дублируем в strategyData) - strategyData.put("scoringModel", scoring.getModel().name()); - strategyData.put("scoringModelTitle", scoring.getModel().getTitle()); - strategyData.put("scoringRationale", scoring.getRationale()); + strategyData.put("scoringModel", scoring.getModel().name()); + strategyData.put("scoringModelTitle", scoring.getModel().getTitle()); + strategyData.put("scoringRationale", scoring.getRationale()); strategyData.put("activeModifiers", scoring.getActiveModifiers().stream() - .map(StrategyModifier::getTitle) - .collect(Collectors.toList())); + .map(StrategyModifier::getTitle).collect(Collectors.toList())); strategyData.put("scores", Map.of( "entry", scoring.getEntryScore(), "authority", scoring.getAuthorityScore(), @@ -167,9 +159,13 @@ public class MarketingStrategyV3Service { populateStrategyEntity(strategy, strategyData, scoring); repository.save(strategy); - // Step 4: Media generation + // Step 4: Чистим хэштеги от случайных двойных ## + cleanHashtags(strategy); + repository.save(strategy); + + // Step 5: Media generation addStatusHistory(strategy, "processing", - "Создаю фото и видео креативы (Veo 3). Это займёт несколько минут..."); + "Создаю фото и видео под каждый пост. Это займёт несколько минут..."); repository.save(strategy); generateMediaAssets(strategy, analysis); @@ -204,15 +200,14 @@ public class MarketingStrategyV3Service { if (req.getCustomerBehaviors() != null) { for (var cb : req.getCustomerBehaviors()) { switch (cb) { - case BUY_QUICKLY -> { conversion += 3; rationaleFactors.add("клиенты покупают быстро"); } + case BUY_QUICKLY -> { conversion += 3; rationaleFactors.add("клиенты покупают быстро"); } case COMPARE_OPTIONS -> { authority += 2; trust += 1; rationaleFactors.add("клиенты сравнивают варианты"); } - case REQUEST_PROPOSAL -> { authority += 3; rationaleFactors.add("клиенты просят расчёт/КП"); } - case CHECK_REVIEWS -> { trust += 3; rationaleFactors.add("клиенты проверяют кейсы и отзывы"); } - case NEED_CONSULTATION -> { authority += 3; rationaleFactors.add("клиенты приходят на консультацию"); } + case REQUEST_PROPOSAL-> { authority += 3; rationaleFactors.add("клиенты просят расчёт/КП"); } + case CHECK_REVIEWS -> { trust += 3; rationaleFactors.add("клиенты проверяют кейсы и отзывы"); } + case NEED_CONSULTATION->{ authority += 3; rationaleFactors.add("клиенты приходят на консультацию"); } } } } - if (req.getAverageCheck() != null) { switch (req.getAverageCheck()) { case LOW -> { conversion += 2; rationaleFactors.add("низкий средний чек"); } @@ -220,41 +215,29 @@ public class MarketingStrategyV3Service { case HIGH -> { authority += 2; trust += 1; rationaleFactors.add("высокий средний чек"); } } } - if (req.getBusinessStage() != null) { switch (req.getBusinessStage()) { - case JUST_STARTING -> { entry += 3; rationaleFactors.add("бизнес только запускается"); } - case LESS_THAN_ONE_YEAR -> { entry += 2; authority += 1; rationaleFactors.add("бизнес работает менее года"); } - case ONE_TO_THREE_YEARS -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("бизнес работает 1–3 года"); } - case MORE_THAN_THREE_YEARS -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("бизнес работает более 3 лет"); } + case JUST_STARTING -> { entry += 3; rationaleFactors.add("бизнес только запускается"); } + case LESS_THAN_ONE_YEAR -> { entry += 2; authority += 1; rationaleFactors.add("менее года"); } + case ONE_TO_THREE_YEARS -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("1–3 года"); } + case MORE_THAN_THREE_YEARS-> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("более 3 лет"); } } } - if (req.getClientTarget() != null) { switch (req.getClientTarget()) { - case BUSINESS -> { authority += 2; trust += 1; rationaleFactors.add("аудитория B2B"); } + case BUSINESS -> { authority += 2; trust += 1; rationaleFactors.add("аудитория B2B"); } case PRIVATE_CLIENTS -> { trust += 1; conversion += 2; rationaleFactors.add("аудитория B2C"); } - case MIXED -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("смешанная B2B+B2C"); } + case MIXED -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("B2B+B2C"); } } } - if (analysis != null && analysis.getResultData() != null && analysis.getResultData().getCompetitionIntensity() != null) { int cii = analysis.getResultData().getCompetitionIntensity().getCiiIndex(); - if (cii < 35) { - conversion += 1; - rationaleFactors.add("низкая конкуренция (CII=" + cii + ")"); - } else if (cii < 65) { - trust += 1; conversion += 1; - rationaleFactors.add("средняя конкуренция (CII=" + cii + ")"); - } else { - trust += 2; - rationaleFactors.add("высокая конкуренция (CII=" + cii + ")"); - } + if (cii < 35) { conversion += 1; rationaleFactors.add("низкая конкуренция (CII=" + cii + ")"); } + else if (cii < 65) { trust += 1; conversion += 1; rationaleFactors.add("средняя конкуренция (CII=" + cii + ")"); } + else { trust += 2; rationaleFactors.add("высокая конкуренция (CII=" + cii + ")"); } } - log.debug("Scoring raw: E={} Au={} T={} C={}", entry, authority, trust, conversion); - int maxScore = Math.max(Math.max(entry, authority), Math.max(trust, conversion)); StrategyModel selectedModel = resolveModelWithTiebreaker( entry, authority, trust, conversion, maxScore, req, analysis); @@ -262,8 +245,7 @@ public class MarketingStrategyV3Service { String rationale = buildRationaleText(selectedModel, rationaleFactors, entry, authority, trust, conversion); - return new ScoringResult(selectedModel, entry, authority, trust, conversion, - modifiers, rationale); + return new ScoringResult(selectedModel, entry, authority, trust, conversion, modifiers, rationale); } private StrategyModel resolveModelWithTiebreaker( @@ -273,16 +255,15 @@ public class MarketingStrategyV3Service { boolean highCheck = req.getAverageCheck() != null && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH; - boolean highCompetition = analysis != null && analysis.getResultData() != null + boolean highComp = analysis != null && analysis.getResultData() != null && analysis.getResultData().getCompetitionIntensity() != null && analysis.getResultData().getCompetitionIntensity().getCiiIndex() >= 65; boolean isLaunch = req.getBusinessStage() != null && req.getBusinessStage() == kz.konturai.parser.enums.BusinessStage.JUST_STARTING; - if (authority == maxScore && trust == maxScore && highCheck) return StrategyModel.AUTHORITY; - if (trust == maxScore && conversion == maxScore && highCompetition) return StrategyModel.TRUST; - if (entry == maxScore && isLaunch) return StrategyModel.ENTRY; - + if (authority == maxScore && trust == maxScore && highCheck) return StrategyModel.AUTHORITY; + if (trust == maxScore && conversion == maxScore && highComp) return StrategyModel.TRUST; + if (entry == maxScore && isLaunch) return StrategyModel.ENTRY; if (authority == maxScore) return StrategyModel.AUTHORITY; if (conversion == maxScore) return StrategyModel.CONVERSION; if (trust == maxScore) return StrategyModel.TRUST; @@ -292,119 +273,126 @@ public class MarketingStrategyV3Service { private List resolveModifiers( kz.konturai.parser.dto.MarketingAnalysisV3Request req, MarketingAnalysisV3Document analysis) { - List mods = new ArrayList<>(); - if (mods.size() < 3 && analysis != null && analysis.getResultData() != null && analysis.getResultData().getCompetitionIntensity() != null && analysis.getResultData().getCompetitionIntensity().getCiiIndex() > 60) mods.add(StrategyModifier.HIGH_COMPETITION); - if (mods.size() < 3 && req.getAverageCheck() != null && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH) mods.add(StrategyModifier.HIGH_CHECK); - if (mods.size() < 3 && req.getCustomerBehaviors() != null && req.getCustomerBehaviors().contains(kz.konturai.parser.enums.CustomerBehavior.BUY_QUICKLY)) mods.add(StrategyModifier.SHORT_DECISION); - if (mods.size() < 3 && req.getCustomerBehaviors() != null && req.getCustomerBehaviors().contains(kz.konturai.parser.enums.CustomerBehavior.CHECK_REVIEWS)) mods.add(StrategyModifier.SOCIAL_PROOF_SENSITIVE); - if (mods.size() < 3 && req.getPurchaseFrequency() != null && req.getPurchaseFrequency() == kz.konturai.parser.enums.PurchaseFrequency.REGULARLY) mods.add(StrategyModifier.RETENTION); - if (mods.size() < 3) { List cities = req.getPromotionCities() != null ? req.getPromotionCities() : (req.getPresenceCities() != null ? req.getPresenceCities() : List.of()); if (cities.size() > 1) mods.add(StrategyModifier.MULTI_GEO); } - return mods; } private String buildRationaleText(StrategyModel model, List factors, int entry, int authority, int trust, int conversion) { - String topFactors = factors.stream().limit(3).collect(Collectors.joining(", ")); - return String.format( - "Выбрана модель %s (баллы: Entry=%d, Authority=%d, Trust=%d, Conversion=%d). Причины: %s.", + String top = factors.stream().limit(3).collect(Collectors.joining(", ")); + return String.format("Выбрана модель %s (E=%d Au=%d T=%d C=%d). Причины: %s.", model.getTitle(), entry, authority, trust, conversion, - topFactors.isEmpty() ? "анализ профиля бизнеса" : topFactors); + top.isEmpty() ? "анализ профиля бизнеса" : top); } // ===================================================================== // PROMPT BUILDING // ===================================================================== - private String buildSystemPrompt(ScoringResult scoring) { + /** + * Системный промпт — теперь включает контекст ниши для более релевантного контента. + */ + private String buildSystemPrompt(ScoringResult scoring, MarketingAnalysisV3Document analysis) { String modifiersBlock = scoring.getActiveModifiers().isEmpty() - ? "Активных модификаторов нет — применяй базовую модель без корректировок." - : "ПРИМЕНИТЬ СЛЕДУЮЩИЕ МОДИФИКАТОРЫ К БАЗОВОЙ МОДЕЛИ:\n\n" - + scoring.getActiveModifiers().stream() - .map(StrategyModifier::getInstructions) - .collect(Collectors.joining("\n")); + ? "Нет активных модификаторов." + : scoring.getActiveModifiers().stream() + .map(StrategyModifier::getInstructions).collect(Collectors.joining("\n")); - String contentRulesSafe = scoring.getModel().getContentRules().replace("%", "%%"); - String modifiersBlockSafe = modifiersBlock.replace("%", "%%"); - String rationaleSafe = scoring.getRationale().replace("%", "%%"); + // Извлекаем конкурентов из анализа для примеров контента + String competitorContext = ""; + if (analysis != null && analysis.getResultData() != null + && analysis.getResultData().getCompetitorMap() != null + && !analysis.getResultData().getCompetitorMap().isEmpty()) { + competitorContext = analysis.getResultData().getCompetitorMap().stream() + .limit(3) + .map(c -> " • " + c.getName() + ": сильные стороны — " + + (c.getStrengths() != null ? String.join(", ", c.getStrengths()) : "N/A")) + .collect(Collectors.joining("\n")); + } - return """ - ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА. - Скоринговая система рассчитала оптимальную модель на основе данных бизнеса. + String niche = (analysis != null && analysis.getRequestData() != null) + ? analysis.getRequestData().getBusinessNiche() : "бизнес"; + String product = (analysis != null && analysis.getRequestData() != null) + ? analysis.getRequestData().getProductName() : ""; - ════════════════════════════════════════════════ - ВЫБРАННАЯ МОДЕЛЬ: %s - ОБОСНОВАНИЕ: %s - ════════════════════════════════════════════════ + return String.format(""" + ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА С 15-ЛЕТНИМ ОПЫТОМ. + Твоя задача — создать контент-план, который РЕАЛЬНО ПРИНОСИТ ЛИДЫ. - ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА МОДЕЛИ (НАРУШАТЬ ЗАПРЕЩЕНО): + ════════ БИЗНЕС КЛИЕНТА ════════ + Ниша: %s + Продукт/Бренд: %s + Выбранная стратегическая модель: %s + Обоснование: %s + + ════════ КОНКУРЕНТЫ (учти при создании контента) ════════ %s - ════════════════════════════════════════════════ - МОДИФИКАТОРЫ (%d активных): + ════════ ПРАВИЛА МОДЕЛИ (СТРОГО) ════════ %s - ════════════════════════════════════════════════ - КРИТИЧЕСКИЕ ПРАВИЛА ДЛЯ КАЗАХСТАНСКОГО РЫНКА: + ════════ МОДИФИКАТОРЫ ════════ + %s - 1. КОЛИЧЕСТВО ПОСТОВ: - - 4 недели × минимум 3 поста = МИНИМУМ 12 постов в postCalendar - - Оптимально: 14–16 постов (равномерно по неделям) + ════════ КРИТИЧЕСКИЕ ПРАВИЛА — НАРУШАТЬ ЗАПРЕЩЕНО ════════ - 2. ФОРМАТЫ — ОГРАНИЧЕНИЕ НА ВИДЕО: - - contentType: ТОЛЬКО "фото" или "видео" - - МАКСИМУМ 2 ВИДЕО-ПОСТА НА ВЕСЬ ПЛАН. Остальные — "фото". - - Это техническое ограничение системы генерации видео. + ПРАВИЛО 1 — ТЕМА ПОСТА = КОНКРЕТНАЯ СИТУАЦИЯ В НИШЕ: + Каждый пост — это конкретная история или кейс из ниши "%s". + ЗАПРЕЩЕНО: абстрактные темы ("О нашей компании", "Введение в продукт") + ОБЯЗАТЕЛЬНО: конкретика ("Как выбрать ракетку для начинающего", "3 ошибки при первой тренировке") - 3. ЯЗЫК И ЛОКАЛИЗАЦИЯ — СТРОГО: - - ВСЕ тексты постов (postText) — ТОЛЬКО на русском языке - - ВСЕ хэштеги — ТОЛЬКО на русском или казахском языке - - ЗАПРЕЩЕНО использовать английские хэштеги (#marketing, #beauty, #business и т.д.) - - ПРАВИЛЬНО: #маркетингказахстан #бьютикз #бизнесалматы #астана #алматы #казахстан - - Упоминать конкретные казахстанские города, реалии, культурный контекст + ПРАВИЛО 2 — ТЕКСТ ПОСТА = ПРОДАЮЩИЙ КОНТЕНТ НА РУССКОМ: + - Минимум 150 слов живого текста + - Первые 2 строки — КРЮЧОК, который останавливает скролл + - Конкретные цифры, факты, примеры из казахстанской реальности + - В конце — ОДИН конкретный призыв к действию (запись, звонок, директ) + - Тенге, алматинские районы, казахстанские реалии - 4. CTA — ТОЛЬКО из стиля своей модели (указано в правилах выше) + ПРАВИЛО 3 — ХЭШТЕГИ (ТОЛЬКО ОДИНОЧНЫЙ #): + - От 5 до 8 хэштегов на русском/казахском + - Каждый хэштег начинается с ОДНОГО символа # + - ЗАПРЕЩЕНО: ##хэштег, # хэштег, английские хэштеги + - ПРАВИЛЬНО: #теннисалматы #спортказахстан #алматы - 5. ТЕКСТЫ ПОСТОВ: - - Минимум 120 слов, живой разговорный русский язык - - Конкретика для Казахстана: цены в тенге, названия районов, локальные праздники - - Эмодзи уместно, умеренно + ПРАВИЛО 4 — ФОРМАТЫ: + - contentType: ТОЛЬКО "фото" или "видео" + - РОВНО 2 видео-поста на весь план — расставить на самые важные посты + - Остальные посты — "фото" - 6. РАСПИСАНИЕ: - - Instagram: 09:00, 12:00, 18:00, 20:00 - - TikTok: 18:00, 20:00, 22:00 - - Telegram: 10:00, 13:00, 19:00 - - Не публиковать чаще 1 раза в день на одной платформе + ПРАВИЛО 5 — РАСПИСАНИЕ: + - Instagram: 09:00, 12:00, 18:00, 20:00 + - TikTok: 18:00, 20:00, 22:00 + - Не публиковать чаще 1 раза в день на одной платформе - ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Никакого текста до или после. - """.formatted( - scoring.getModel().getTitle(), - rationaleSafe, - contentRulesSafe, - scoring.getActiveModifiers().size(), - modifiersBlockSafe + ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Без markdown, без пояснений. + """, + niche, product, + scoring.getModel().getTitle(), scoring.getRationale(), + competitorContext.isEmpty() ? "Данные конкурентов не загружены." : competitorContext, + scoring.getModel().getContentRules().replace("%", "%%"), + modifiersBlock.replace("%", "%%"), + niche ); } @@ -415,35 +403,37 @@ public class MarketingStrategyV3Service { String postCountPlan = buildPostCountPlan(scoring.getModel(), 4); LocalDateTime startDate = LocalDateTime.now().plusDays(1); - return """ - ЗАДАНИЕ: Сгенерируй SMM-стратегию на 4 недели для рынка Казахстана. + String niche = analysis.getRequestData() != null + ? analysis.getRequestData().getBusinessNiche() : "бизнес"; + + return String.format(""" + ЗАДАНИЕ: Создай SMM-стратегию на 4 недели для "%s" на рынке Казахстана. + Стратегия должна РЕАЛЬНО ПРИВЛЕКАТЬ КЛИЕНТОВ И ПРИНОСИТЬ ЛИДЫ. ══ ПРОФИЛЬ БИЗНЕСА ══ %s - ══ ДАННЫЕ РЫНКА (из маркетингового анализа) ══ + ══ ДАННЫЕ РЫНКА ══ %s - ══ ПАРАМЕТРЫ СТРАТЕГИИ ══ - Старт публикаций: %s - Выбранная модель: %s - Активные модификаторы: %s + ══ ПАРАМЕТРЫ ══ + Старт: %s | Модель: %s | Модификаторы: %s - ══ ОБЯЗАТЕЛЬНЫЙ ПЛАН ПОСТОВ ══ + ══ ПЛАН ПОСТОВ ══ %s - ══ ТРЕБУЕМАЯ JSON СТРУКТУРА ══ + ══ JSON СТРУКТУРА ══ { "selectedModel": "%s", "scoringRationale": "%s", "recommendedDurationWeeks": 4, "recommendedPlatforms": ["Instagram", "TikTok"], - "rationale": "Обоснование выбора платформ (минимум 60 слов)", + "rationale": "Минимум 60 слов — почему именно эти платформы для данной ниши", "weeklyPlans": [ { "weekNumber": 1, - "mainThemes": ["Конкретная тема 1", "Конкретная тема 2"], - "contentRecommendations": "Что снимать и писать на этой неделе (минимум 40 слов)", + "mainThemes": ["Конкретная тема 1 для ниши", "Конкретная тема 2"], + "contentRecommendations": "Что конкретно снимать и писать — минимум 50 слов", "priorityPlatforms": ["Instagram"] } ], @@ -452,141 +442,151 @@ public class MarketingStrategyV3Service { "publishDate": "2024-01-15T10:00:00", "platform": "Instagram", "contentType": "фото", - "theme": "Конкретная тема поста", - "postText": "Полный текст поста минимум 120 слов на русском языке. Конкретика для Казахстана. CTA в конце.", - "hashtags": ["#хэштегнарусском", "#алматы", "#казахстан"], + "theme": "КОНКРЕТНАЯ тема поста — не общая фраза, а конкретная ситуация из ниши", + "postText": "150+ слов живого текста на русском. Первые 2 строки — крючок. Цифры и факты. CTA в конце.", + "hashtags": ["#хэштег1", "#хэштег2", "#алматы"], "publishTime": "10:00" } ] } - СТРОГИЕ ТРЕБОВАНИЯ: - - postCalendar: МИНИМУМ 12 постов - - postText: ТОЛЬКО на русском языке - - hashtags: ТОЛЬКО на русском/казахском (#алматы, #астана, #казахстан, #бизнескз и т.д.) - - НОЛЬ английских слов в хэштегах - ОТВЕТ: только JSON. Никакого текста вне JSON. - """.formatted( - clientJson, - analysisCtx, + ЖЁСТКИЕ ТРЕБОВАНИЯ: + 1. postCalendar: МИНИМУМ 12 постов, оптимально 14-16 + 2. РОВНО 2 поста с contentType: "видео", остальные "фото" + 3. theme: конкретная тема из ниши "%s" — никаких абстракций + 4. postText: 150+ слов, только русский язык + 5. hashtags: 5-8 штук, каждый начинается с одного #, только русский/казахский + 6. Равномерно распредели по 4 неделям + + ОТВЕТ: только JSON объект. Никакого текста вне JSON. + """, + niche, clientJson, analysisCtx, startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), scoring.getModel().getTitle(), scoring.getActiveModifiers().stream() - .map(StrategyModifier::getTitle) - .collect(Collectors.joining(", ")), + .map(StrategyModifier::getTitle).collect(Collectors.joining(", ")), postCountPlan, scoring.getModel().name(), - scoring.getRationale().replace("\"", "'") + scoring.getRationale().replace("\"", "'"), + niche ); } private String buildAnalysisContext(MarketingAnalysisV3Document analysis) { if (analysis == null || analysis.getResultData() == null) - return "Данные анализа недоступны — ориентируйся на профиль бизнеса."; - - var r = analysis.getResultData(); + return "Данные анализа недоступны."; + var r = analysis.getResultData(); var sb = new StringBuilder(); - if (r.getExecutiveSummary() != null) { var es = r.getExecutiveSummary(); - sb.append("Конкурентов в нише: ").append(es.getActiveCompetitors()).append("\n"); - sb.append("Уровень конкуренции: ").append(es.getCompetitionLevel()).append("\n"); - sb.append("Средний ER: ").append(es.getAverageNicheEr()).append("%\n"); - sb.append("Средний рейтинг: ").append(es.getAverageRating()).append("/5.0\n"); - sb.append("Тренд спроса: ").append(es.getDemandTrend()).append("\n"); + sb.append("Конкурентов: ").append(es.getActiveCompetitors()) + .append(" | Конкуренция: ").append(es.getCompetitionLevel()) + .append(" | ER ниши: ").append(es.getAverageNicheEr()).append("%") + .append(" | Рейтинг: ").append(es.getAverageRating()).append("/5\n"); if (es.getKeyFigures() != null && !es.getKeyFigures().isEmpty()) - sb.append("Ключевые факты: ").append(String.join("; ", es.getKeyFigures())).append("\n"); - } - if (r.getCompetitionIntensity() != null) { - sb.append("CII: ").append(r.getCompetitionIntensity().getCiiIndex()).append("/100 — ") - .append(r.getCompetitionIntensity().getIntensityLabel()).append("\n"); - if (r.getCompetitionIntensity().getContributingFactors() != null) - sb.append("Факторы: ") - .append(String.join("; ", r.getCompetitionIntensity().getContributingFactors())).append("\n"); - } - if (r.getContentProfile() != null) { - sb.append("Доля видео у конкурентов: ").append(r.getContentProfile().getVideoShare()).append("%\n"); - sb.append("CTA используют: ").append(r.getContentProfile().getCtaUsagePercent()).append("%\n"); + sb.append("Факты: ").append(String.join("; ", es.getKeyFigures())).append("\n"); } + if (r.getCompetitionIntensity() != null) + sb.append("CII: ").append(r.getCompetitionIntensity().getCiiIndex()) + .append("/100 — ").append(r.getCompetitionIntensity().getIntensityLabel()).append("\n"); + if (r.getContentProfile() != null) + sb.append("Видео у конкурентов: ").append(r.getContentProfile().getVideoShare()) + .append("% | CTA используют: ").append(r.getContentProfile().getCtaUsagePercent()).append("%\n"); if (r.getBehavioralPattern() != null && r.getBehavioralPattern().getCommonCta() != null) - sb.append("Популярные CTA: ") + sb.append("Топ CTA конкурентов: ") .append(String.join(", ", r.getBehavioralPattern().getCommonCta())).append("\n"); if (r.getSearchDemand() != null) { - sb.append("Поисковый спрос: ").append(r.getSearchDemand().getAvgMonthlySearches()) - .append(" запросов/мес\n"); - if (r.getSearchDemand().getPeakPeriods() != null && !r.getSearchDemand().getPeakPeriods().isEmpty()) - sb.append("Пиковые периоды: ") - .append(String.join(", ", r.getSearchDemand().getPeakPeriods())).append("\n"); + sb.append("Спрос: ").append(r.getSearchDemand().getAvgMonthlySearches()).append(" запросов/мес\n"); + if (r.getSearchDemand().getRelatedQueries() != null && !r.getSearchDemand().getRelatedQueries().isEmpty()) + sb.append("Популярные запросы: ") + .append(String.join(", ", r.getSearchDemand().getRelatedQueries())).append("\n"); } if (r.getStructuredConclusions() != null && !r.getStructuredConclusions().isEmpty()) { - sb.append("\nКлючевые выводы:\n"); + sb.append("Выводы анализа:\n"); r.getStructuredConclusions().forEach(c -> sb.append(" • ").append(c).append("\n")); } - if (r.getSmmStrategyRationale() != null && !r.getSmmStrategyRationale().isBlank()) - sb.append("\nСтратегическое обоснование:\n").append(r.getSmmStrategyRationale()).append("\n"); - return sb.toString(); } private String buildPostCountPlan(StrategyModel model, int weeks) { - int total = weeks * 3; - int optimal = weeks * 4; + int total = weeks * 3, optimal = weeks * 4; StringBuilder sb = new StringBuilder(); - sb.append("Минимум: ").append(total).append(" | Оптимально: ").append(optimal) - .append(" (").append(weeks).append(" нед.)\n\n"); - sb.append("Распределение для ").append(total).append(" постов:\n"); + sb.append("Минимум: ").append(total).append(" | Оптимально: ").append(optimal).append("\n"); + sb.append("РОВНО 2 видео-поста, остальные фото\n\nТипы постов:\n"); switch (model) { case ENTRY -> { - sb.append(" Объясняющий: ").append(pct(total, 0.30)).append(" (30%)\n"); - sb.append(" Демонстрационный: ").append(pct(total, 0.25)).append(" (25%)\n"); + sb.append(" Объясняющий: ").append(pct(total, 0.30)).append(" (30%)\n"); + sb.append(" Демонстрационный: ").append(pct(total, 0.25)).append(" (25%)\n"); sb.append(" Социальное доказательство: ").append(pct(total, 0.20)).append(" (20%)\n"); - sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" (15%)\n"); - sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); + sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" (15%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); } case AUTHORITY -> { - sb.append(" Экспертный: ").append(pct(total, 0.40)).append(" (40%)\n"); - sb.append(" Кейсы: ").append(pct(total, 0.25)).append(" (25%)\n"); - sb.append(" Объясняющий: ").append(pct(total, 0.15)).append(" (15%)\n"); + sb.append(" Экспертный: ").append(pct(total, 0.40)).append(" (40%)\n"); + sb.append(" Кейсы: ").append(pct(total, 0.25)).append(" (25%)\n"); + sb.append(" Объясняющий: ").append(pct(total, 0.15)).append(" (15%)\n"); sb.append(" Социальное доказательство: ").append(pct(total, 0.10)).append(" (10%)\n"); - sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); } case TRUST -> { - sb.append(" Кейсы: ").append(pct(total, 0.30)).append(" (30%)\n"); - sb.append(" Отзывы клиентов: ").append(pct(total, 0.25)).append(" (25%)\n"); - sb.append(" Демонстрация процесса: ").append(pct(total, 0.20)).append(" (20%)\n"); - sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" (15%)\n"); - sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); + sb.append(" Кейсы: ").append(pct(total, 0.30)).append(" (30%)\n"); + sb.append(" Отзывы: ").append(pct(total, 0.25)).append(" (25%)\n"); + sb.append(" Процесс: ").append(pct(total, 0.20)).append(" (20%)\n"); + sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" (15%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" (10%)\n"); } case CONVERSION -> { - sb.append(" Продающий: ").append(pct(total, 0.35)).append(" (35%)\n"); - sb.append(" Демонстрационный: ").append(pct(total, 0.30)).append(" (30%)\n"); - sb.append(" Вовлекающий: ").append(pct(total, 0.20)).append(" (20%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.35)).append(" (35%)\n"); + sb.append(" Демонстрационный: ").append(pct(total, 0.30)).append(" (30%)\n"); + sb.append(" Вовлекающий: ").append(pct(total, 0.20)).append(" (20%)\n"); sb.append(" Социальное доказательство: ").append(pct(total, 0.10)).append(" (10%)\n"); - sb.append(" Объясняющий: ").append(pct(total, 0.05)).append(" (5%)\n"); + sb.append(" Объясняющий: ").append(pct(total, 0.05)).append(" (5%)\n"); } } - sb.append("\nМАКСИМУМ 2 видео-поста на весь план! Остальные — фото.\n"); return sb.toString(); } - private int pct(int total, double ratio) { - return (int) Math.round(total * ratio); + private int pct(int total, double ratio) { return (int) Math.round(total * ratio); } + + // ===================================================================== + // HASHTAG CLEANUP — чистим ## и лишние пробелы + // ===================================================================== + + private void cleanHashtags(MarketingStrategy strategy) { + if (strategy.getPostCalendar() == null) return; + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { + if (item.getHashtags() == null) continue; + List cleaned = item.getHashtags().stream() + .map(tag -> { + // Убираем пробелы + tag = tag.trim(); + // Убираем все # в начале + while (tag.startsWith("#")) tag = tag.substring(1); + // Убираем пробелы внутри тега + tag = tag.replaceAll("\\s+", ""); + // Добавляем ровно один # + return tag.isEmpty() ? null : "#" + tag; + }) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + item.setHashtags(cleaned); + } } // ===================================================================== - // MEDIA GENERATION — промпты на русском, KZ-контекст + // MEDIA GENERATION // ===================================================================== @SuppressWarnings("unchecked") private void generateMediaAssets(MarketingStrategy strategy, MarketingAnalysisV3Document analysis) { - String businessContext = getBusinessContext(analysis); - String brandName = analysis.getRequestData().getProductName(); - String niche = analysis.getRequestData().getBusinessNiche(); - // Берём основной город для контекста промпта - String mainCity = analysis.getRequestData().getMainCity() != null - && !analysis.getRequestData().getMainCity().isBlank() - ? analysis.getRequestData().getMainCity() : "Казахстан"; + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + String mainCity = safeGet(analysis, a -> { + String c = a.getRequestData().getMainCity(); + return (c != null && !c.isBlank()) ? c : "Алматы"; + }, "Алматы"); List refFiles = new ArrayList<>(); if (strategy.getStrategyData() != null @@ -598,13 +598,45 @@ public class MarketingStrategyV3Service { int total = strategy.getPostCalendar().size(); int current = 0; + // Считаем сколько видео уже запланировано AI + long plannedVideos = strategy.getPostCalendar().stream() + .filter(p -> "видео".equalsIgnoreCase(p.getContentType())) + .count(); + log.info("[Media] Total posts={}, planned videos={}", total, plannedVideos); + + // Если AI не назначил ни одного видео — принудительно назначаем 2 + if (plannedVideos == 0) { + log.warn("[Media] AI не назначил ни одного видео! Принудительно назначаю 2 поста как видео."); + int assigned = 0; + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { + if (assigned >= 2) break; + item.setContentType("видео"); + assigned++; + } + } + + int videoGenerated = 0; + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { current++; String contentType = item.getContentType() != null - ? item.getContentType().toLowerCase() : "фото"; + ? item.getContentType().trim().toLowerCase() : "фото"; - log.info("[Media {}/{}] type={} theme={}", current, total, contentType, item.getTheme()); + // Нормализуем — на случай если AI написал что-то кроме "фото"/"видео" + if (!contentType.equals("видео")) contentType = "фото"; + log.info("[Media {}/{}] type='{}' platform='{}' theme='{}'", + current, total, contentType, + item.getPlatform(), item.getTheme()); + + // Лимит: максимум 2 видео + if (contentType.equals("видео") && videoGenerated >= 2) { + log.warn("[Media] Video limit (2) reached — switching post {} to photo", current); + contentType = "фото"; + item.setContentType("фото"); + } + + // Загружаем референсный файл если есть byte[] refBytes = null; if (!refFiles.isEmpty()) { String selectedRef = refFiles.get(random.nextInt(refFiles.size())); @@ -615,106 +647,124 @@ public class MarketingStrategyV3Service { } } - // Проверяем лимит видео (максимум 2) - if (contentType.contains("видео")) { - long videoCount = strategy.getPostCalendar().stream() - .filter(p -> p.getVideoUrl() != null - && !p.getVideoUrl().isEmpty() - && !p.getVideoUrl().equals("generating...")) - .count(); - if (videoCount >= 2) { - log.warn("[Media] Video limit (2) reached. Falling back to image for post {}", current); - contentType = "фото"; - item.setContentType("фото"); + try { + if (contentType.equals("видео")) { + boolean success = doGenerateVideo(item, niche, brand, mainCity); + if (success) { + videoGenerated++; + } else { + // Видео не получилось — генерируем фото вместо + log.warn("[Media] Video failed for post {}, falling back to image", current); + item.setContentType("фото"); + doGenerateImage(item, niche, brand, mainCity, refBytes); + } + } else { + doGenerateImage(item, niche, brand, mainCity, refBytes); } + + if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs); + + } catch (Exception e) { + log.error("[Media] Error for post {}: {}", current, e.getMessage()); } - try { - if (contentType.contains("видео")) { - doGenerateVideo(item, businessContext, niche, mainCity); - } else { - doGenerateImage(item, businessContext, brandName, niche, refBytes); - } - if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs); - } catch (Exception e) { - log.error("[Media] Error for post '{}': {}", item.getTheme(), e.getMessage()); + // Сохраняем прогресс после каждых 3 постов + if (current % 3 == 0) { + repository.save(strategy); + log.info("[Media] Progress saved at post {}/{}", current, total); } } + + log.info("[Media] Done. Generated {} images, {} videos out of {} posts", + total - videoGenerated, videoGenerated, total); } /** - * Генерация видео через Veo 3. - * Промпт ПОЛНОСТЬЮ на русском языке — для казахстанского рынка. - * Veo 3 нативно добавляет звук (музыку, атмосферу) без доп. настроек. + * Генерация видео через Veo 3 (с fallback на Veo 2). + * Промпт строится из КОНКРЕТНОЙ темы и текста поста. + * Возвращает true если видео успешно сгенерировано. */ - private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, - String businessContext, String niche, String city) { + private boolean doGenerateVideo(MarketingStrategy.PostCalendarItem item, + String niche, String brand, String city) { String theme = item.getTheme() != null ? item.getTheme() : "Презентация продукта"; + String postText = item.getPostText() != null ? item.getPostText() : ""; String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; - // Стиль и звук по платформе — описание на русском - String styleDesc; - String audioDesc; + // Извлекаем суть поста для визуализации (первые 100 символов текста) + String postSummary = postText.length() > 100 + ? postText.substring(0, 100) : postText; + + // Стиль по платформе + String styleDesc, audioDesc; switch (platform) { case "tiktok" -> { - styleDesc = "динамичный TikTok-стиль, вертикальный формат, быстрый монтаж, энергичная подача, трендовые переходы"; - audioDesc = "энергичная трендовая музыка в современном стиле, подходящая для казахстанской аудитории"; + styleDesc = "динамичный TikTok-стиль, быстрый монтаж, трендовые переходы, энергичная подача"; + audioDesc = "энергичная трендовая музыка для казахстанской молодёжи"; } case "telegram" -> { - styleDesc = "лаконичный информативный стиль, чёткая подача материала, профессиональный вид"; - audioDesc = "спокойная фоновая музыка или закадровый голос на русском языке"; + styleDesc = "лаконичный информативный стиль, профессиональный вид"; + audioDesc = "спокойная фоновая музыка или закадровый голос на русском"; } default -> { // instagram - styleDesc = "кинематографичный Instagram Reels стиль, плавные движения камеры, эстетичная картинка, современный lifestyle"; - audioDesc = "мягкая современная фоновая музыка, атмосферные звуки или закадровый голос на русском языке"; + styleDesc = "кинематографичный Instagram Reels стиль, плавные движения камеры, эстетичная картинка"; + audioDesc = "современная казахстанская музыка или атмосферный звук"; } } + // КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из конкретной темы поста String videoPrompt = String.format( - "Кинематографичное вертикальное видео 9:16 для социальных сетей Казахстана. " + - "Бизнес: %s. Ниша: %s. Город: %s. " + - "Тема видео: «%s». " + + "Кинематографичное вертикальное видео 9:16 для %s в Казахстане. " + + "Ниша: %s. Бренд: %s. Город: %s. " + + "ТЕМА ВИДЕО: «%s». " + + "ЧТО ПОКАЗАТЬ: %s. " + "Стиль: %s. " + - "Звуковое оформление: %s. " + - "Технические требования: фотореалистичное качество 8K, профессиональное освещение, " + - "правильная анатомия людей, естественные движения, натуральные эмоции. " + - "Люди — жители современного казахстанского города (Алматы/Астана/Шымкент), " + - "стильный образ жизни, уютная обстановка, натуральные цвета и тёплый свет. " + - "Без текстовых оверлеев, без водяных знаков, без субтитров.", - businessContext, niche, city, theme, styleDesc, audioDesc); + "Звук: %s. " + + "Требования: фотореалистичное 8K, профессиональное освещение, " + + "правильная анатомия, естественные движения и эмоции, " + + "казахстанский контекст — современный город, стильные люди, реальная жизнь. " + + "БЕЗ текстовых оверлеев, БЕЗ водяных знаков, БЕЗ субтитров.", + platform, niche, brand, city, theme, postSummary, styleDesc, audioDesc); + + log.info("[VEO3] Generating video. Platform={}, Theme='{}'", platform, theme); + log.debug("[VEO3] Prompt: {}", videoPrompt); try { - log.info("[VEO3] Generating video. Platform={}, Theme='{}'", platform, theme); byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt); - if (videoBytes != null && videoBytes.length > 0) { String filename = "video_" + System.currentTimeMillis() + "_" + Math.abs(item.hashCode()) + ".mp4"; minIOService.uploadFile(filename, videoBytes, "video/mp4"); item.setVideoUrl(filename); item.setVideoFilename(filename); + item.setVideoGenerationPrompt(videoPrompt); // сохраняем промпт для отладки item.setImageUrl(null); item.setImageFilename(null); - log.info("[VEO3] Saved: {} ({} bytes)", filename, videoBytes.length); + log.info("[VEO3] ✅ Saved: {} ({} bytes)", filename, videoBytes.length); + return true; } else { - log.warn("[VEO3] Empty response for '{}'", theme); + log.warn("[VEO3] Empty response for theme '{}'", theme); item.setVideoFilename(null); item.setVideoUrl(null); + return false; } } catch (Exception e) { - log.error("[VEO3] Error for '{}': {}", theme, e.getMessage(), e); + log.error("[VEO3] Error for '{}': {}", theme, e.getMessage()); item.setVideoFilename(null); item.setVideoUrl(null); + return false; } } /** - * Генерация изображений — промпт на русском, казахстанский контекст. + * Генерация изображений. + * КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из темы И текста поста — картинка строго в тему. */ private void doGenerateImage(MarketingStrategy.PostCalendarItem item, - String businessContext, String brandName, - String niche, byte[] refBytes) { - String imagePrompt = buildImagePrompt(item, businessContext, brandName, niche); + String niche, String brand, String city, byte[] refBytes) { + String imagePrompt = buildImagePrompt(item, niche, brand, city); + log.info("[Image] Generating. Theme='{}'", item.getTheme()); + log.debug("[Image] Prompt: {}", imagePrompt); + try { byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, refBytes); if (imageBytes != null && imageBytes.length > 0) { @@ -725,6 +775,7 @@ public class MarketingStrategyV3Service { item.setImageFilename(filename); item.setVideoUrl(null); item.setVideoFilename(null); + log.info("[Image] ✅ Saved: {}", filename); } else { log.warn("[Image] Empty response for: {}", item.getTheme()); item.setImageFilename(null); @@ -738,38 +789,51 @@ public class MarketingStrategyV3Service { } /** - * Промпт для изображений — на русском с казахстанским контекстом. + * ГЛАВНОЕ УЛУЧШЕНИЕ: промпт для изображения строится из КОНКРЕТНОЙ темы поста. + * Картинка должна визуально иллюстрировать тему — а не быть случайным портретом. */ private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, - String businessContext, String brandName, String niche) { - StringBuilder prompt = new StringBuilder(); + String niche, String brand, String city) { + String theme = item.getTheme() != null ? item.getTheme() : ""; + String postText = item.getPostText() != null ? item.getPostText() : ""; String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; + // Берём первые 150 символов текста поста для контекста + String textContext = postText.length() > 150 ? postText.substring(0, 150) : postText; + + StringBuilder prompt = new StringBuilder(); + + // Базовый стиль по платформе switch (platform) { - case "linkedin" -> prompt.append( - "Кинематографичная, masterpiece, 8K. Профессиональная корпоративная фотография, " + - "чистый минималистичный дизайн, деловой контекст, мягкое естественное освещение, " + - "современный офис или деловая встреча, высокая детализация. "); case "tiktok" -> prompt.append( - "Кинематографичная, masterpiece, 8K. Яркие насыщенные цвета, молодёжная эстетика, " + - "динамичная композиция, трендовый современный стиль, высокий контраст, " + - "энергичная подача, высокая детализация. "); + "Яркая динамичная фотография 8K, трендовый молодёжный стиль, " + + "насыщенные цвета, энергичная композиция, высокий контраст. "); + case "linkedin" -> prompt.append( + "Профессиональная корпоративная фотография 8K, деловой стиль, " + + "чистый минималистичный дизайн, мягкое естественное освещение. "); default -> prompt.append( - "Кинематографичная, masterpiece, 8K. Коммерческая фотография высокого класса, " + - "профессиональное студийное освещение, эстетика Instagram, лайфстайл атмосфера, " + - "современный казахстанский контекст, чистый фон, высокая детализация. "); + "Кинематографичная коммерческая фотография 8K высокого класса, " + + "профессиональное освещение, эстетика Instagram, современный казахстанский lifestyle. "); } - prompt.append("Бизнес: ").append(businessContext).append(". "); - prompt.append("Ниша: ").append(niche).append(". "); + // КОНКРЕТНАЯ ТЕМА — это главное для релевантности изображения + prompt.append(String.format( + "Ниша: %s. Бренд: %s. Город: %s. ", niche, brand, city)); - if (item.getTheme() != null && !item.getTheme().isBlank()) - prompt.append("Визуальная концепция: ").append(item.getTheme()).append(". "); + if (!theme.isBlank()) { + prompt.append(String.format("ГЛАВНАЯ ТЕМА ИЗОБРАЖЕНИЯ: «%s». ", theme)); + } - prompt.append("Жители Казахстана — современный образ жизни, естественный и аутентичный. ") - .append("Продукт или услуга в главном фокусе. Люди выглядят естественно и счастливо. ") - .append("Без текстовых надписей на изображении. Без водяных знаков. ") - .append("Бренд: ").append(brandName).append("."); + // Используем контекст текста поста для точности + if (!textContext.isBlank()) { + prompt.append(String.format("Контекст: %s. ", textContext)); + } + + prompt.append( + "Показывай КОНКРЕТНУЮ сцену из ниши — действие, процесс, результат, продукт. " + + "Люди казахстанской внешности, естественные позы и эмоции, реальная жизнь. " + + "Продукт или услуга в главном фокусе. " + + "БЕЗ текстовых надписей. БЕЗ водяных знаков. Фотореалистично."); return prompt.toString(); } @@ -782,7 +846,6 @@ public class MarketingStrategyV3Service { public MarketingStrategy.PostCalendarItem regeneratePostImage(String strategyId, int postIndex) { MarketingStrategy strategy = repository.findById(strategyId).orElse(null); if (strategy == null) return null; - List calendar = strategy.getPostCalendar(); if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) return null; @@ -793,6 +856,13 @@ public class MarketingStrategyV3Service { .findById(strategy.getAnalysisId()).orElse(null); if (analysis == null) return null; + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + String mainCity = safeGet(analysis, a -> { + String c = a.getRequestData().getMainCity(); + return (c != null && !c.isBlank()) ? c : "Алматы"; + }, "Алматы"); + byte[] refBytes = null; if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("referenceFilenames")) { @@ -802,16 +872,12 @@ public class MarketingStrategyV3Service { try (InputStream s = minIOService.downloadFile(randomRef)) { refBytes = s.readAllBytes(); } catch (Exception e) { - log.warn("[Regen] Failed to load ref file: {}", e.getMessage()); + log.warn("[Regen] Failed to load ref: {}", e.getMessage()); } } } - doGenerateImage(item, - getBusinessContext(analysis), - analysis.getRequestData().getProductName(), - analysis.getRequestData().getBusinessNiche(), - refBytes); + doGenerateImage(item, niche, brand, mainCity, refBytes); repository.save(strategy); return item; } @@ -839,24 +905,30 @@ public class MarketingStrategyV3Service { .findById(strategy.getAnalysisId()).orElse(null); if (analysis == null) { log.error("[VideoRegen] Analysis not found"); return; } - String city = analysis.getRequestData().getMainCity() != null - && !analysis.getRequestData().getMainCity().isBlank() - ? analysis.getRequestData().getMainCity() : "Казахстан"; + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + String mainCity = safeGet(analysis, a -> { + String c = a.getRequestData().getMainCity(); + return (c != null && !c.isBlank()) ? c : "Алматы"; + }, "Алматы"); + + boolean success = doGenerateVideo(item, niche, brand, mainCity); + if (!success) { + // Если видео не получилось — генерируем фото + log.warn("[VideoRegen] Video failed, generating image instead for post {}", postIndex); + item.setContentType("фото"); + doGenerateImage(item, niche, brand, mainCity, null); + } - doGenerateVideo(item, - getBusinessContext(analysis), - analysis.getRequestData().getBusinessNiche(), - city); repository.save(strategy); log.info("[VideoRegen] Done for post {} in strategy {}", postIndex, strategyId); - } catch (Exception e) { log.error("[VideoRegen] Failed: {}", e.getMessage(), e); } } // ===================================================================== - // STRATEGY RESULT — scoringModelName и scoringModelTitle в корне ответа + // STRATEGY RESULT // ===================================================================== public Map getStrategyResult(String strategyId) { @@ -864,18 +936,17 @@ public class MarketingStrategyV3Service { if (strategy == null) return null; Map response = new LinkedHashMap<>(); - response.put("strategyId", strategy.getId()); - response.put("analysisId", strategy.getAnalysisId()); - response.put("status", strategy.getStatus()); - response.put("createdAt", strategy.getCreatedAt()); - response.put("completedAt", strategy.getCompletedAt()); - response.put("durationWeeks", strategy.getDurationWeeks()); - response.put("priorityPlatforms", strategy.getPriorityPlatforms()); + response.put("strategyId", strategy.getId()); + response.put("analysisId", strategy.getAnalysisId()); + response.put("status", strategy.getStatus()); + response.put("createdAt", strategy.getCreatedAt()); + response.put("completedAt", strategy.getCompletedAt()); + response.put("durationWeeks", strategy.getDurationWeeks()); + response.put("priorityPlatforms", strategy.getPriorityPlatforms()); - // ── Название модели — всегда в корне, фронт читает отсюда ────────── + // Название модели — из entity, резерв из strategyData String modelName = strategy.getScoringModelName(); String modelTitle = strategy.getScoringModelTitle(); - // Резерв: старые записи без поля в entity — берём из strategyData if (modelName == null && strategy.getStrategyData() != null) { Object v = strategy.getStrategyData().get("scoringModel"); if (v != null) modelName = v.toString(); @@ -886,13 +957,10 @@ public class MarketingStrategyV3Service { } response.put("scoringModelName", modelName); response.put("scoringModelTitle", modelTitle); - // ─────────────────────────────────────────────────────────────────── - - response.put("strategyData", strategy.getStrategyData()); + response.put("strategyData", strategy.getStrategyData()); if (strategy.getWeeklyPlans() != null && strategy.getPostCalendar() != null) { List tasks = postingTaskService.getStrategyTasks(strategyId); - List> postCalendarList = new ArrayList<>(); List items = strategy.getPostCalendar(); @@ -926,8 +994,7 @@ public class MarketingStrategyV3Service { postCalendarList.add(dto); } - response.put("strategy", Map.of( - "weeklyPlans", strategy.getWeeklyPlans(), + response.put("strategy", Map.of("weeklyPlans", strategy.getWeeklyPlans(), "postCalendar", postCalendarList)); response.put("postCalendar", postCalendarList); response.put("weeklyPlans", strategy.getWeeklyPlans()); @@ -940,10 +1007,20 @@ public class MarketingStrategyV3Service { // HELPERS // ===================================================================== - private String getBusinessContext(MarketingAnalysisV3Document analysis) { - if (analysis == null || analysis.getRequestData() == null) return ""; - return analysis.getRequestData().getBusinessNiche() - + ", " + analysis.getRequestData().getProductName(); + @FunctionalInterface + private interface AnalysisExtractor { + T extract(MarketingAnalysisV3Document a); + } + + private T safeGet(MarketingAnalysisV3Document analysis, + AnalysisExtractor extractor, T defaultValue) { + try { + if (analysis == null || analysis.getRequestData() == null) return defaultValue; + T val = extractor.extract(analysis); + return val != null ? val : defaultValue; + } catch (Exception e) { + return defaultValue; + } } @SuppressWarnings("unchecked") @@ -951,8 +1028,6 @@ public class MarketingStrategyV3Service { Map data, ScoringResult scoring) { strategy.setStrategyData(data); - - // Сохраняем название модели прямо в поля entity strategy.setScoringModelName(scoring.getModel().name()); strategy.setScoringModelTitle(scoring.getModel().getTitle()); @@ -993,8 +1068,13 @@ public class MarketingStrategyV3Service { } else { item.setPublishDate(startDate.plusDays(postCalendar.size() * 2L)); } + + // Нормализуем contentType + String ct = (String) c.get("contentType"); + if (ct != null) ct = ct.trim().toLowerCase(); + item.setContentType("видео".equals(ct) ? "видео" : "фото"); + item.setPlatform((String) c.get("platform")); - item.setContentType((String) c.get("contentType")); item.setTheme((String) c.get("theme")); item.setPostText((String) c.get("postText")); item.setHashtags((List) c.get("hashtags")); @@ -1004,8 +1084,9 @@ public class MarketingStrategyV3Service { } strategy.setPostCalendar(postCalendar); - log.info("Populated: {} weeklyPlans, {} posts, model={}", - weeklyPlans.size(), postCalendar.size(), scoring.getModel().name()); + log.info("Populated: {} weeklyPlans, {} posts, model={}, videos={}", + weeklyPlans.size(), postCalendar.size(), scoring.getModel().name(), + postCalendar.stream().filter(p -> "видео".equals(p.getContentType())).count()); } private String extractCleanJson(String response) { diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java index 0c77313..36c9971 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -23,15 +23,38 @@ import java.time.Duration; import java.util.*; import java.util.concurrent.Semaphore; +/** + * Сервис генерации изображений через Google Imagen 3. + * + * УЛУЧШЕНИЯ: + * - Соотношение сторон зависит от платформы (4:5 для Instagram, 9:16 для TikTok/Reels) + * - Негативный промпт на русском — единый язык с основным промптом + * - Референс-изображение не перебивает основной промпт английскими инструкциями + * - Метод generateImageForPlatform() для точного контроля формата + */ @Service public class NanoBananaImageGenerationService implements ImageGenerationService { private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class); - 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 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"; + // Негативный промпт на русском — соответствует языку основных промптов + private static final String NEGATIVE_PROMPT_RU = + "размытое изображение, плохая анатомия, лишние конечности, шесть пальцев, три руки, " + + "деформированное тело, неправильные пропорции, мультяшный стиль, 3D рендер, " + + "искусственный вид, водяной знак, текстовые надписи на изображении, " + + "нечёткий фон, пересвет, плохое качество, низкое разрешение"; + + // Один запрос в момент времени — Imagen 3 не любит параллельные запросы private final Semaphore semaphore = new Semaphore(1); private volatile long lastRequestTimestamp = 0; + + // Минимальный интервал между запросами (15 сек — требование rate limit Imagen 3) + private static final long MIN_REQUEST_INTERVAL_MS = 15_000; + private final WebClient webClient; @Value("${google.cloud.project-id}") @@ -46,10 +69,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService public NanoBananaImageGenerationService() { HttpClient httpClient = HttpClient.create() .resolver(DefaultAddressResolverGroup.INSTANCE) - .responseTimeout(Duration.ofMillis(180000)); + .responseTimeout(Duration.ofMillis(180_000)); ExchangeStrategies strategies = ExchangeStrategies.builder() - .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024)) + .codecs(configurer -> configurer.defaultCodecs() + .maxInMemorySize(30 * 1024 * 1024)) .build(); this.webClient = WebClient.builder() @@ -58,11 +82,21 @@ public class NanoBananaImageGenerationService implements ImageGenerationService .build(); } + // ===================================================================== + // PUBLIC API + // ===================================================================== + + /** + * Базовая генерация — квадрат 1:1 (обратная совместимость). + */ @Override public byte[] generateImage(String prompt) { - return generateImageWithReference(prompt, (byte[]) null); + return generateImageInternal(prompt, null, "1:1"); } + /** + * Генерация с логотипом — квадрат 1:1. + */ public byte[] generateImage(String prompt, MultipartFile referenceLogo) { if (referenceLogo == null || referenceLogo.isEmpty()) { return generateImage(prompt); @@ -74,52 +108,169 @@ public class NanoBananaImageGenerationService implements ImageGenerationService } } + /** + * Генерация с референсным изображением — квадрат 1:1. + * Используется для обратной совместимости со старым кодом. + */ + @Override public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) { + return generateImageInternal(prompt, referenceImageBytes, "1:1"); + } + + /** + * ОСНОВНОЙ МЕТОД для генерации постов. + * Соотношение сторон выбирается автоматически по платформе: + * - Instagram → 4:5 (портретный, оптимален для ленты) + * - TikTok → 9:16 (вертикальный, полный экран) + * - Telegram → 4:5 (портретный) + * - LinkedIn → 4:5 (портретный) + * - По умолчанию → 4:5 + * + * @param prompt Промпт на русском языке + * @param referenceImageBytes Байты логотипа/референса (может быть null) + * @param platform Платформа: "instagram", "tiktok", "telegram", "linkedin" + */ + public byte[] generateImageForPlatform(String prompt, byte[] referenceImageBytes, String platform) { + String aspectRatio = resolveAspectRatio(platform); + logger.info("[Imagen3] Platform='{}' → aspectRatio='{}'", platform, aspectRatio); + return generateImageInternal(prompt, referenceImageBytes, aspectRatio); + } + + // ===================================================================== + // CORE GENERATION + // ===================================================================== + + private byte[] generateImageInternal(String prompt, byte[] referenceImageBytes, String aspectRatio) { if (projectId == null || projectId.trim().isEmpty()) { + logger.error("[Imagen3] Project ID not configured!"); return null; } try { semaphore.acquire(); + // Rate limit — минимум 15 сек между запросами long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp; - if (timeSinceLast < 15000) { - Thread.sleep(15000 - timeSinceLast); + if (timeSinceLast < MIN_REQUEST_INTERVAL_MS) { + long waitMs = MIN_REQUEST_INTERVAL_MS - timeSinceLast; + logger.debug("[Imagen3] Rate limit wait: {}ms", waitMs); + Thread.sleep(waitMs); } - String accessToken = getAccessTokenFromResources(); + String accessToken = getAccessToken(); if (accessToken == null) { + logger.error("[Imagen3] Failed to get access token"); return null; } - String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model); - String finalPrompt = prompt != null ? prompt : "High quality image"; + String endpointUrl = String.format(VERTEX_API_TEMPLATE, + location, projectId, location, model); + + String finalPrompt = (prompt != null && !prompt.isBlank()) ? prompt : "Качественное изображение"; + + logger.info("[Imagen3] Generating. AspectRatio={}, PromptLen={}", + aspectRatio, finalPrompt.length()); try { - Map requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes); - Map response = executeVertexRequest(endpointUrl, accessToken, requestBody); + Map requestBody = + buildRequestBody(finalPrompt, referenceImageBytes, aspectRatio); + Map response = + executeRequest(endpointUrl, accessToken, requestBody); lastRequestTimestamp = System.currentTimeMillis(); - return extractImageFromImagenResponse(response); + + byte[] result = extractImage(response); + if (result != null) { + logger.info("[Imagen3] ✅ Success. Bytes={}", result.length); + } else { + logger.warn("[Imagen3] Empty result from API"); + } + return result; + } catch (WebClientResponseException.BadRequest badRequestEx) { + logger.warn("[Imagen3] 400 BadRequest with reference — retrying without reference. Body: {}", + badRequestEx.getResponseBodyAsString()); + + // Если референс вызвал ошибку — пробуем без него if (referenceImageBytes != null && referenceImageBytes.length > 0) { - Map fallbackBody = buildImagenRequestBody(finalPrompt, null); - Map fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody); + Map fallbackBody = + buildRequestBody(finalPrompt, null, aspectRatio); + Map fallbackResponse = + executeRequest(endpointUrl, accessToken, fallbackBody); lastRequestTimestamp = System.currentTimeMillis(); - return extractImageFromImagenResponse(fallbackResponse); + return extractImage(fallbackResponse); } return null; + } catch (Exception ex) { + logger.error("[Imagen3] Generation error: {}", ex.getMessage()); return null; } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("[Imagen3] Interrupted"); + return null; } catch (Exception e) { + logger.error("[Imagen3] Fatal: {}", e.getMessage(), e); return null; } finally { semaphore.release(); } } - private Map executeVertexRequest(String endpointUrl, String accessToken, Map requestBody) { + // ===================================================================== + // REQUEST BUILDING + // ===================================================================== + + private Map buildRequestBody(String prompt, + byte[] referenceImageBytes, + String aspectRatio) { + Map body = new HashMap<>(); + List> instances = new ArrayList<>(); + Map instance = new HashMap<>(); + + // Промпт — только на русском, без добавления английских инструкций + instance.put("prompt", prompt); + + // Референсное изображение (логотип клиента) — если передан + if (referenceImageBytes != null && referenceImageBytes.length > 0) { + String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes); + + Map imageBytesMap = new HashMap<>(); + imageBytesMap.put("bytesBase64Encoded", base64Image); + + Map referenceImageParams = new HashMap<>(); + referenceImageParams.put("referenceImage", imageBytesMap); + // STYLE — используем стиль лого, не SUBJECT (SUBJECT часто вызывает 400) + referenceImageParams.put("referenceType", "STYLE"); + + instance.put("referenceImages", List.of(referenceImageParams)); + + logger.debug("[Imagen3] Using reference image ({} bytes)", referenceImageBytes.length); + } + + instances.add(instance); + body.put("instances", instances); + + // Параметры генерации + Map parameters = new HashMap<>(); + parameters.put("sampleCount", 1); + parameters.put("aspectRatio", aspectRatio); + parameters.put("safetyFilterLevel", "block_some"); + parameters.put("personGeneration", "allow_adult"); + parameters.put("negativePrompt", NEGATIVE_PROMPT_RU); + + body.put("parameters", parameters); + return body; + } + + // ===================================================================== + // HTTP + // ===================================================================== + + private Map executeRequest(String endpointUrl, + String accessToken, + Map requestBody) { try { return webClient.post() .uri(URI.create(endpointUrl)) @@ -128,25 +279,37 @@ public class NanoBananaImageGenerationService implements ImageGenerationService .bodyValue(requestBody) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) - .retryWhen(reactor.util.retry.Retry.backoff(10, Duration.ofSeconds(10)) + // Retry только на 429 (rate limit) + .retryWhen(reactor.util.retry.Retry + .backoff(5, Duration.ofSeconds(10)) .maxBackoff(Duration.ofSeconds(60)) - .filter(t -> t instanceof WebClientResponseException && - ((WebClientResponseException) t).getStatusCode().value() == 429)) - .block(Duration.ofMillis(180000)); + .filter(t -> t instanceof WebClientResponseException + && ((WebClientResponseException) t).getStatusCode().value() == 429) + .doBeforeRetry(signal -> + logger.warn("[Imagen3] 429 rate limit — retrying ({}/5)...", + signal.totalRetries() + 1))) + .block(Duration.ofMillis(180_000)); } catch (WebClientResponseException e) { if (e.getStatusCode().value() == 400) { - throw e; + throw e; // Пробрасываем для обработки в вызывающем коде (retry без референса) } + logger.error("[Imagen3] HTTP {}: {}", e.getStatusCode(), e.getResponseBodyAsString()); return null; } catch (Exception e) { + logger.error("[Imagen3] Request error: {}", e.getMessage()); return null; } } - private String getAccessTokenFromResources() { + // ===================================================================== + // AUTH + // ===================================================================== + + private String getAccessToken() { try { ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH); if (!resource.exists()) { + logger.error("[Imagen3] Credentials file not found: {}", CREDENTIALS_FILE_PATH); return null; } try (InputStream is = resource.getInputStream()) { @@ -156,64 +319,60 @@ public class NanoBananaImageGenerationService implements ImageGenerationService return credentials.getAccessToken().getTokenValue(); } } catch (IOException e) { + logger.error("[Imagen3] Failed to get access token: {}", e.getMessage()); return null; } } - private Map buildImagenRequestBody(String prompt, byte[] referenceImageBytes) { - Map body = new HashMap<>(); - List> instances = new ArrayList<>(); - Map instance = new HashMap<>(); - - instance.put("prompt", prompt); - - if (referenceImageBytes != null && referenceImageBytes.length > 0) { - String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes); - Map referenceImageParams = new HashMap<>(); - Map imageBytesMap = new HashMap<>(); - - imageBytesMap.put("bytesBase64Encoded", base64Image); - referenceImageParams.put("referenceImage", imageBytesMap); - referenceImageParams.put("referenceType", "SUBJECT"); - - List> refImagesList = new ArrayList<>(); - refImagesList.add(referenceImageParams); - - instance.put("referenceImages", refImagesList); - instance.put("prompt", prompt + ". CRITICAL: Preserve the text, letters, and typography of the provided logo EXACTLY as they appear. Do not invent new text, do not distort letters. Integrate naturally."); - } - - instances.add(instance); - body.put("instances", instances); - - Map parameters = new HashMap<>(); - parameters.put("sampleCount", 1); - parameters.put("aspectRatio", "1:1"); - parameters.put("safetyFilterLevel", "block_some"); - parameters.put("personGeneration", "allow_adult"); - parameters.put("negativePrompt", "gibberish text, alien language, wrong spelling, distorted letters, bad anatomy, 6 fingers, 3 arms, mutated, distorted, extra limbs, bad proportions, unnatural bodies, nsfw, watermark"); - - body.put("parameters", parameters); - return body; - } + // ===================================================================== + // RESPONSE PARSING + // ===================================================================== @SuppressWarnings("unchecked") - private byte[] extractImageFromImagenResponse(Map response) { + private byte[] extractImage(Map response) { if (response == null) return null; try { - List> predictions = (List>) response.get("predictions"); + List> predictions = + (List>) response.get("predictions"); if (predictions != null && !predictions.isEmpty()) { - Map firstPrediction = predictions.get(0); - String base64Image = (String) firstPrediction.get("bytesBase64Encoded"); - if (base64Image == null) { - base64Image = (String) firstPrediction.get("b64"); + Map first = predictions.get(0); + // Imagen 3 возвращает bytesBase64Encoded + String base64 = (String) first.get("bytesBase64Encoded"); + if (base64 == null) { + // Fallback — старый ключ + base64 = (String) first.get("b64"); } - if (base64Image != null) { - return java.util.Base64.getDecoder().decode(base64Image); + if (base64 != null && !base64.isBlank()) { + return Base64.getDecoder().decode(base64); } } } catch (Exception e) { + logger.error("[Imagen3] Failed to extract image from response: {}", e.getMessage()); } return null; } + + // ===================================================================== + // UTILS + // ===================================================================== + + /** + * Выбирает правильное соотношение сторон под платформу. + * + * Imagen 3 поддерживает: 1:1, 4:3, 3:4, 16:9, 9:16 + * + * Instagram лента → 4:5 НЕ поддерживается Imagen 3, поэтому используем 3:4 (близко к 4:5) + * TikTok / Reels → 9:16 (вертикальный полный экран) + */ + private String resolveAspectRatio(String platform) { + if (platform == null) return "3:4"; + return switch (platform.toLowerCase().trim()) { + case "tiktok" -> "9:16"; // вертикальный для TikTok Reels + case "instagram" -> "3:4"; // портретный для Instagram ленты (ближайший к 4:5) + case "telegram" -> "3:4"; // портретный + case "linkedin" -> "4:3"; // горизонтальный для LinkedIn + case "youtube" -> "16:9"; // горизонтальный для YouTube + default -> "3:4"; // по умолчанию портретный + }; + } } \ No newline at end of file